Skip to content

Commit 6175343

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

12 files changed

Lines changed: 1068 additions & 10 deletions

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: 53 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,54 @@ const results = await db.executeTransaction([
250250

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

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

255303
```typescript
@@ -296,7 +344,10 @@ await db.remove() // Close and DELETE database file(s) - irreversible!
296344
| Method | Description |
297345
| ------ | ----------- |
298346
| `execute(query, values?)` | Execute write query, returns `{ rowsAffected, lastInsertId }` |
299-
| `executeTransaction(statements)` | Execute statements atomically |
347+
| `executeTransaction(statements)` | Execute statements atomically (use for batch writes) |
348+
| `executePausableTransaction(statements)` | Begin pausable transaction, returns token |
349+
| `transactionContinue(token, action)` | Continue, commit, or rollback pausable transaction |
350+
| `transactionRead<T>(token, query, values?)` | Read uncommitted data within transaction |
300351
| `fetchAll<T>(query, values?)` | Execute SELECT, return all rows |
301352
| `fetchOne<T>(query, values?)` | Execute SELECT, return single row or `undefined` |
302353
| `close()` | Close connection, returns `true` if was loaded |

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: 72 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_pausable_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,66 @@ 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('executePausableTransaction', async () => {
115+
const token = await Database.get('t.db').executePausableTransaction([
116+
['INSERT INTO users (name) VALUES ($1)', ['Alice']]
117+
])
118+
expect(lastCmd).toBe('plugin:sqlite|execute_pausable_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(token.dbPath).toBe('t.db')
124+
expect(token.transactionId).toBe('test-tx-id')
125+
})
126+
127+
it('transactionContinue - Continue action', async () => {
128+
const token = { dbPath: 'test.db', transactionId: 'test-tx-id' }
129+
const newToken = await Database.get('test.db').transactionContinue(token, {
130+
type: 'Continue',
131+
statements: [{ query: 'INSERT INTO users (name) VALUES ($1)', values: ['Bob'] }]
132+
})
133+
expect(lastCmd).toBe('plugin:sqlite|transaction_continue')
134+
expect(lastArgs.token).toEqual(token)
135+
expect((lastArgs.action as { type: string }).type).toBe('Continue')
136+
expect(newToken).toBeDefined()
137+
expect(newToken?.transactionId).toBe('test-tx-id')
138+
})
139+
140+
it('transactionContinue - Commit action', async () => {
141+
const token = { dbPath: 'test.db', transactionId: 'test-tx-id' }
142+
const result = await Database.get('test.db').transactionContinue(token, { type: 'Commit' })
143+
expect(lastCmd).toBe('plugin:sqlite|transaction_continue')
144+
expect(lastArgs.token).toEqual(token)
145+
expect((lastArgs.action as { type: string }).type).toBe('Commit')
146+
expect(result).toBeUndefined()
147+
})
148+
149+
it('transactionContinue - Rollback action', async () => {
150+
const token = { dbPath: 'test.db', transactionId: 'test-tx-id' }
151+
const result = await Database.get('test.db').transactionContinue(token, { type: 'Rollback' })
152+
expect(lastCmd).toBe('plugin:sqlite|transaction_continue')
153+
expect(lastArgs.token).toEqual(token)
154+
expect((lastArgs.action as { type: string }).type).toBe('Rollback')
155+
expect(result).toBeUndefined()
156+
})
157+
158+
it('transactionRead', async () => {
159+
const token = { dbPath: 'test.db', transactionId: 'test-tx-id' }
160+
await Database.get('test.db').transactionRead(token, 'SELECT * FROM users WHERE name = $1', ['Alice'])
161+
expect(lastCmd).toBe('plugin:sqlite|transaction_read')
162+
expect(lastArgs.token).toEqual(token)
163+
expect(lastArgs.query).toBe('SELECT * FROM users WHERE name = $1')
164+
expect(lastArgs.values).toEqual(['Alice'])
165+
})
166+
95167
it('handles errors from backend', async () => {
96168
mockIPC(() => {
97169
throw new Error('Database error')

guest-js/index.ts

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

41+
/**
42+
* Token representing an active pausable transaction.
43+
* Used to continue, commit, or rollback the transaction.
44+
*/
45+
export interface TransactionToken {
46+
/** Database path */
47+
dbPath: string
48+
/** Unique transaction ID */
49+
transactionId: string
50+
}
51+
52+
/**
53+
* Actions that can be performed on a pausable transaction.
54+
*/
55+
export type TransactionAction =
56+
| { type: 'Continue'; statements: Array<{ query: string; values: SqlValue[] }> }
57+
| { type: 'Commit' }
58+
| { type: 'Rollback' }
59+
4160
/**
4261
* Custom configuration for SQLite database connection
4362
*/
@@ -196,6 +215,10 @@ export default class Database {
196215
* Executes multiple write statements atomically within a transaction.
197216
* All statements either succeed together or fail together.
198217
*
218+
* **Use this method** when you have a batch of writes to execute and don't need to
219+
* read data mid-transaction. For transactions that require reading uncommitted data
220+
* to decide how to proceed, use `executePausableTransaction()` instead.
221+
*
199222
* The function automatically:
200223
* - Begins a transaction (BEGIN)
201224
* - Executes all statements in order
@@ -365,6 +388,154 @@ export default class Database {
365388
return success
366389
}
367390

391+
/**
392+
* **executePausableTransaction**
393+
*
394+
* Begins a pausable transaction for cases where you need to **read data mid-transaction
395+
* to decide how to proceed**. For example, inserting a record and then reading its
396+
* generated ID or computed values before continuing with related writes.
397+
*
398+
* The transaction remains open, holding a write lock on the database, until you
399+
* commit or rollback using `transactionContinue()`.
400+
*
401+
* **Use this method when:**
402+
* - You need to read back generated IDs (e.g., AUTOINCREMENT columns)
403+
* - You need to see computed values (e.g., triggers, default values)
404+
* - Your next writes depend on data from earlier writes in the same transaction
405+
*
406+
* **Use `executeTransaction()` instead when:**
407+
* - You just need to execute a batch of writes atomically
408+
* - You know all the data upfront and don't need to read mid-transaction
409+
*
410+
* While paused, you can:
411+
* - Read uncommitted data using `transactionRead()`
412+
* - Continue with more statements using `transactionContinue()` with Continue action
413+
* - Commit the transaction using `transactionContinue()` with Commit action
414+
* - Rollback the transaction using `transactionContinue()` with Rollback action
415+
*
416+
* **Important:** Only one transaction can be active per database at a time. The
417+
* writer connection is held for the entire duration - keep transactions short.
418+
*
419+
* @param initialStatements - Array of [query, values?] tuples to execute initially
420+
* @returns Promise that resolves with a transaction token
421+
*
422+
* @example
423+
* ```ts
424+
* // Insert an order and read back its ID
425+
* const token = await db.executePausableTransaction([
426+
* ['INSERT INTO orders (user_id, total) VALUES ($1, $2)', [userId, 0]]
427+
* ]);
428+
*
429+
* // Read the generated order ID
430+
* const orders = await db.transactionRead<Array<{ id: number }>>(
431+
* token,
432+
* 'SELECT id FROM orders WHERE user_id = $1 ORDER BY id DESC LIMIT 1',
433+
* [userId]
434+
* );
435+
* const orderId = orders[0].id;
436+
*
437+
* // Use the ID in subsequent writes
438+
* const newToken = await db.transactionContinue(token, {
439+
* type: 'Continue',
440+
* statements: [
441+
* { query: 'INSERT INTO order_items (order_id, product_id) VALUES ($1, $2)', values: [orderId, productId] }
442+
* ]
443+
* });
444+
*
445+
* await db.transactionContinue(newToken!, { type: 'Commit' });
446+
* ```
447+
*/
448+
async executePausableTransaction(
449+
initialStatements: Array<[string, SqlValue[]?]>
450+
): Promise<TransactionToken> {
451+
return await invoke<TransactionToken>('plugin:sqlite|execute_pausable_transaction', {
452+
db: this.path,
453+
initialStatements: initialStatements.map(([query, values]) => ({
454+
query,
455+
values: values ?? []
456+
}))
457+
})
458+
}
459+
460+
/**
461+
* **transactionContinue**
462+
*
463+
* Continue, commit, or rollback a pausable transaction.
464+
*
465+
* Actions:
466+
* - `{ type: 'Continue', statements: [...] }` - Execute more statements and return updated token
467+
* - `{ type: 'Commit' }` - Commit the transaction and release the write lock
468+
* - `{ type: 'Rollback' }` - Rollback the transaction and release the write lock
469+
*
470+
* @param token - Transaction token from executePausableTransaction
471+
* @param action - Action to perform
472+
* @returns Promise that resolves with a new token if continuing, or undefined if committed/rolled back
473+
*
474+
* @example
475+
* ```ts
476+
* const token = await db.executePausableTransaction([
477+
* ['INSERT INTO users (name) VALUES ($1)', ['Alice']]
478+
* ]);
479+
*
480+
* const newToken = await db.transactionContinue(token, {
481+
* type: 'Continue',
482+
* statements: [{ query: 'INSERT INTO users (name) VALUES ($1)', values: ['Bob'] }]
483+
* });
484+
*
485+
* await db.transactionContinue(newToken!, { type: 'Commit' });
486+
* ```
487+
*/
488+
async transactionContinue(
489+
token: TransactionToken,
490+
action: TransactionAction
491+
): Promise<TransactionToken | undefined> {
492+
return await invoke<TransactionToken | undefined>('plugin:sqlite|transaction_continue', {
493+
token,
494+
action
495+
})
496+
}
497+
498+
/**
499+
* **transactionRead**
500+
*
501+
* Read data from the database within a pausable transaction context.
502+
* This allows you to see uncommitted writes from the current transaction.
503+
*
504+
* The query executes on the same connection as the transaction, so you can
505+
* read data that hasn't been committed yet.
506+
*
507+
* @param token - Transaction token from executePausableTransaction
508+
* @param query - SELECT query to execute
509+
* @param bindValues - Optional parameter values
510+
* @returns Promise that resolves with query results
511+
*
512+
* @example
513+
* ```ts
514+
* const token = await db.executePausableTransaction([
515+
* ['INSERT INTO users (name) VALUES ($1)', ['Alice']]
516+
* ]);
517+
*
518+
* const users = await db.transactionRead<User[]>(
519+
* token,
520+
* 'SELECT * FROM users WHERE name = $1',
521+
* ['Alice']
522+
* );
523+
*
524+
* await db.transactionContinue(token, { type: 'Commit' });
525+
* ```
526+
*/
527+
async transactionRead<T>(
528+
token: TransactionToken,
529+
query: string,
530+
bindValues?: SqlValue[]
531+
): Promise<T> {
532+
return await invoke<T>('plugin:sqlite|transaction_read', {
533+
token,
534+
query,
535+
values: bindValues ?? []
536+
})
537+
}
538+
368539
/**
369540
* **getMigrationEvents**
370541
*

0 commit comments

Comments
 (0)