Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ tokio = { version = "1.48.0", features = ["rt", "sync", "time"] }
indexmap = { version = "2.12.1", features = ["serde"] }
base64 = "0.22.1"
tracing = { version = "0.1.41", default-features = false, features = ["std", "release_max_level_off"] }
uuid = { version = "1.11.0", features = ["v4"] }

# SQLx for types and queries (time feature enables datetime type decoding)
sqlx = { version = "0.8.6", features = ["sqlite", "json", "time", "runtime-tokio"] }
Expand Down
57 changes: 55 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ const user = await db.fetchOne<User>(

### Transactions

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

```typescript
const results = await db.executeTransaction([
Expand All @@ -250,6 +250,49 @@ const results = await db.executeTransaction([

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

#### Interruptible Transactions

**Use interruptible transactions when you need to read data mid-transaction to
decide how to proceed.** For example, inserting a record, reading back its
generated ID or other computed values, then using that data in subsequent writes.

```typescript
// Begin transaction with initial insert
const tx = await db.executeInterruptibleTransaction([
['INSERT INTO orders (user_id, total) VALUES ($1, $2)', [userId, 0]]
])

// Read the uncommitted data to get the generated order ID
const orders = await tx.read<Array<{ id: number }>>(
'SELECT id FROM orders WHERE user_id = $1 ORDER BY id DESC LIMIT 1',
[userId]
)
const orderId = orders[0].id

// Continue transaction with the order ID
const tx2 = await tx.continue([
['INSERT INTO order_items (order_id, product_id) VALUES ($1, $2)', [orderId, productId]],
['UPDATE orders SET total = $1 WHERE id = $2', [itemTotal, orderId]]
])

// Commit the transaction
await tx2.commit()
```

**Important:**

* Only one interruptible transaction can be active per database at a time
* The write lock is held for the entire duration - keep transactions short
* Uncommitted writes are visible only within the transaction's `read()` method
* Always commit or rollback - abandoned transactions will rollback automatically
on app exit

To rollback instead of committing:

```typescript
await tx.rollback()
```

### Error Handling

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

### InterruptibleTransaction Methods

| Method | Description |
| ------ | ----------- |
| `read<T>(query, values?)` | Read uncommitted data within this transaction |
| `continue(statements)` | Execute additional statements, returns new `InterruptibleTransaction` |
| `commit()` | Commit transaction and release write lock |
| `rollback()` | Rollback transaction and release write lock |

### Types

```typescript
Expand Down
2 changes: 1 addition & 1 deletion api-iife.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

75 changes: 75 additions & 0 deletions guest-js/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,23 @@ beforeEach(() => {
if (cmd === 'plugin:sqlite|load') return (args as { db: string }).db
if (cmd === 'plugin:sqlite|execute') return [1, 1]
if (cmd === 'plugin:sqlite|execute_transaction') return []
if (cmd === 'plugin:sqlite|execute_interruptible_transaction') {
return { dbPath: (args as { db: string }).db, transactionId: 'test-tx-id' }
}
if (cmd === 'plugin:sqlite|transaction_continue') {
const action = (args as { action: { type: string } }).action
if (action.type === 'Continue') {
return { dbPath: 'test.db', transactionId: 'test-tx-id' }
}
return undefined
}
if (cmd === 'plugin:sqlite|transaction_read') return []
if (cmd === 'plugin:sqlite|fetch_all') return []
if (cmd === 'plugin:sqlite|fetch_one') return null
if (cmd === 'plugin:sqlite|close') return true
if (cmd === 'plugin:sqlite|close_all') return undefined
if (cmd === 'plugin:sqlite|remove') return true
if (cmd === 'plugin:sqlite|get_migration_events') return []
return undefined
})
})
Expand Down Expand Up @@ -92,6 +104,69 @@ describe('Database commands', () => {
expect(events).toEqual(mockEvents)
})

it('getMigrationEvents - empty array', async () => {
const events = await Database.get('test.db').getMigrationEvents()
expect(lastCmd).toBe('plugin:sqlite|get_migration_events')
expect(lastArgs.db).toBe('test.db')
expect(events).toEqual([])
})

it('executeInterruptibleTransaction', async () => {
const tx = await Database.get('t.db').executeInterruptibleTransaction([
['INSERT INTO users (name) VALUES ($1)', ['Alice']]
])
expect(lastCmd).toBe('plugin:sqlite|execute_interruptible_transaction')
expect(lastArgs.db).toBe('t.db')
expect(lastArgs.initialStatements).toEqual([
{ query: 'INSERT INTO users (name) VALUES ($1)', values: ['Alice'] }
])
expect(tx).toBeInstanceOf(Object)
})

it('InterruptibleTransaction.continue()', async () => {
const tx = await Database.get('test.db').executeInterruptibleTransaction([
['INSERT INTO users (name) VALUES ($1)', ['Alice']]
])
const tx2 = await tx.continue([
['INSERT INTO users (name) VALUES ($1)', ['Bob']]
])
expect(lastCmd).toBe('plugin:sqlite|transaction_continue')
expect(lastArgs.token).toEqual({ dbPath: 'test.db', transactionId: 'test-tx-id' })
expect((lastArgs.action as { type: string }).type).toBe('Continue')
expect(tx2).toBeInstanceOf(Object)
})

it('InterruptibleTransaction.commit()', async () => {
const tx = await Database.get('test.db').executeInterruptibleTransaction([
['INSERT INTO users (name) VALUES ($1)', ['Alice']]
])
await tx.commit()
expect(lastCmd).toBe('plugin:sqlite|transaction_continue')
expect(lastArgs.token).toEqual({ dbPath: 'test.db', transactionId: 'test-tx-id' })
expect((lastArgs.action as { type: string }).type).toBe('Commit')
})

it('InterruptibleTransaction.rollback()', async () => {
const tx = await Database.get('test.db').executeInterruptibleTransaction([
['INSERT INTO users (name) VALUES ($1)', ['Alice']]
])
await tx.rollback()
expect(lastCmd).toBe('plugin:sqlite|transaction_continue')
expect(lastArgs.token).toEqual({ dbPath: 'test.db', transactionId: 'test-tx-id' })
expect((lastArgs.action as { type: string }).type).toBe('Rollback')
})

it('InterruptibleTransaction.read()', async () => {
const tx = await Database.get('test.db').executeInterruptibleTransaction([
['INSERT INTO users (name) VALUES ($1)', ['Alice']]
])
await tx.read('SELECT * FROM users WHERE name = $1', ['Alice'])
expect(lastCmd).toBe('plugin:sqlite|transaction_read')
expect(lastArgs.token).toEqual({ dbPath: 'test.db', transactionId: 'test-tx-id' })
expect(lastArgs.query).toBe('SELECT * FROM users WHERE name = $1')
expect(lastArgs.values).toEqual(['Alice'])
})

it('handles errors from backend', async () => {
mockIPC(() => {
throw new Error('Database error')
Expand Down
183 changes: 183 additions & 0 deletions guest-js/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,122 @@ export interface SqliteError {
message: string
}

/**
* **InterruptibleTransaction**
*
* Represents an active interruptible transaction that can be continued, committed, or rolled back.
* Provides methods to read uncommitted data and execute additional statements.
*/
export class InterruptibleTransaction {
constructor(
private readonly dbPath: string,
private readonly transactionId: string
) {}

/**
* **read**
*
* Read data from the database within this transaction context.
* This allows you to see uncommitted writes from the current transaction.
*
* The query executes on the same connection as the transaction, so you can
* read data that hasn't been committed yet.
*
* @param query - SELECT query to execute
* @param bindValues - Optional parameter values
* @returns Promise that resolves with query results
*
* @example
* ```ts
* const tx = await db.executeInterruptibleTransaction([
* ['INSERT INTO users (name) VALUES ($1)', ['Alice']]
* ]);
*
* const users = await tx.read<User[]>(
* 'SELECT * FROM users WHERE name = $1',
* ['Alice']
* );
* ```
*/
async read<T>(query: string, bindValues?: SqlValue[]): Promise<T> {
return await invoke<T>('plugin:sqlite|transaction_read', {
token: { dbPath: this.dbPath, transactionId: this.transactionId },
query,
values: bindValues ?? []
})
}

/**
* **continue**
*
* Execute additional statements within this transaction and return a new transaction handle.
*
* @param statements - Array of [query, values?] tuples to execute
* @returns Promise that resolves with a new transaction handle
*
* @example
* ```ts
* const tx = await db.executeInterruptibleTransaction([...]);
* const tx2 = await tx.continue([
* ['INSERT INTO users (name) VALUES ($1)', ['Bob']]
* ]);
* await tx2.commit();
* ```
*/
async continue(statements: Array<[string, SqlValue[]?]>): Promise<InterruptibleTransaction> {
const token = await invoke<{ dbPath: string; transactionId: string }>(
'plugin:sqlite|transaction_continue',
{
token: { dbPath: this.dbPath, transactionId: this.transactionId },
action: {
type: 'Continue',
statements: statements.map(([query, values]) => ({
query,
values: values ?? []
}))
}
}
)
return new InterruptibleTransaction(token.dbPath, token.transactionId)
}

/**
* **commit**
*
* Commit this transaction and release the write lock.
*
* @example
* ```ts
* const tx = await db.executeInterruptibleTransaction([...]);
* await tx.commit();
* ```
*/
async commit(): Promise<void> {
await invoke<void>('plugin:sqlite|transaction_continue', {
token: { dbPath: this.dbPath, transactionId: this.transactionId },
action: { type: 'Commit' }
})
}

/**
* **rollback**
*
* Rollback this transaction and release the write lock.
*
* @example
* ```ts
* const tx = await db.executeInterruptibleTransaction([...]);
* await tx.rollback();
* ```
*/
async rollback(): Promise<void> {
await invoke<void>('plugin:sqlite|transaction_continue', {
token: { dbPath: this.dbPath, transactionId: this.transactionId },
action: { type: 'Rollback' }
})
}
}

/**
* Custom configuration for SQLite database connection
*/
Expand Down Expand Up @@ -196,6 +312,10 @@ export default class Database {
* Executes multiple write statements atomically within a transaction.
* All statements either succeed together or fail together.
*
* **Use this method** when you have a batch of writes to execute and don't need to
* read data mid-transaction. For transactions that require reading uncommitted data
* to decide how to proceed, use `executeInterruptibleTransaction()` instead.
*
* The function automatically:
* - Begins a transaction (BEGIN)
* - Executes all statements in order
Expand Down Expand Up @@ -365,6 +485,69 @@ export default class Database {
return success
}

/**
* **executeInterruptibleTransaction**
*
* Begins an interruptible transaction for cases where you need to **read data mid-transaction
* to decide how to proceed**. For example, inserting a record and then reading its
* generated ID or computed values before continuing with related writes.
*
* The transaction remains open, holding a write lock on the database, until you
* call `commit()` or `rollback()` on the returned transaction handle.
*
* **Use this method when:**
* - You need to read back generated IDs (e.g., AUTOINCREMENT columns)
* - You need to see computed values (e.g., triggers, default values)
* - Your next writes depend on data from earlier writes in the same transaction
*
* **Use `executeTransaction()` instead when:**
* - You just need to execute a batch of writes atomically
* - You know all the data upfront and don't need to read mid-transaction
*
* **Important:** Only one transaction can be active per database at a time. The
* writer connection is held for the entire duration - keep transactions short.
*
* @param initialStatements - Array of [query, values?] tuples to execute initially
* @returns Promise that resolves with an InterruptibleTransaction handle
*
* @example
* ```ts
* // Insert an order and read back its ID
* const tx = await db.executeInterruptibleTransaction([
* ['INSERT INTO orders (user_id, total) VALUES ($1, $2)', [userId, 0]]
* ]);
*
* // Read the generated order ID
* const orders = await tx.read<Array<{ id: number }>>(
* 'SELECT id FROM orders WHERE user_id = $1 ORDER BY id DESC LIMIT 1',
* [userId]
* );
* const orderId = orders[0].id;
*
* // Use the ID in subsequent writes
* const tx2 = await tx.continue([
* ['INSERT INTO order_items (order_id, product_id) VALUES ($1, $2)', [orderId, productId]]
* ]);
*
* await tx2.commit();
* ```
*/
async executeInterruptibleTransaction(
initialStatements: Array<[string, SqlValue[]?]>
): Promise<InterruptibleTransaction> {
const token = await invoke<{ dbPath: string; transactionId: string }>(
'plugin:sqlite|execute_interruptible_transaction',
{
db: this.path,
initialStatements: initialStatements.map(([query, values]) => ({
query,
values: values ?? []
}))
}
)
return new InterruptibleTransaction(token.dbPath, token.transactionId)
}

/**
* **getMigrationEvents**
*
Expand Down
Loading