diff --git a/README.md b/README.md index 46c2282..3295125 100644 --- a/README.md +++ b/README.md @@ -4,85 +4,43 @@ SQLite database interface for Tauri applications using [sqlx](https://github.com/launchbadge/sqlx) and -[sqlx-sqlite-conn-mgr](https://github.com/silvermine/sqlx-sqlite-conn-mgr). - -This plugin provides a SQLite-focused database interface with optimized connection -pooling, write serialization, and proper resource management. +[sqlx-sqlite-conn-mgr](crates/sqlx-sqlite-conn-mgr). [ci-badge]: https://github.com/silvermine/tauri-plugin-sqlite/actions/workflows/ci.yml/badge.svg [ci-url]: https://github.com/silvermine/tauri-plugin-sqlite/actions/workflows/ci.yml ## Features - * **Optimized Connection Pooling**: Separate read and write pools for concurrent reads, - even while writing - * **Write Serialization**: Exclusive write access through connection manager - * **Migration Support**: Uses SQLx's database migration system (coming soon) - * **Custom Configuration**: Configure read pool size and idle timeouts + * **Optimized Connection Pooling**: Separate read and write pools for concurrent reads + even while writing (configurable pool size and idle timeouts) + * **Write Serialization**: Exclusive write connection + + > Wait! Why? From [SQLite docs](https://sqlite.org/whentouse.html): + > "_SQLite ... will only allow one writer at any instant in time._" + * **WAL Mode**: Enabled automatically on first write operation * **Type Safety**: Full TypeScript bindings + * **Migration Support**: SQLx's migration framework (coming soon) * **Resource Management**: Proper cleanup on application exit (coming soon) -## Crates - -### sqlx-sqlite-conn-mgr - -A pure Rust module with no dependencies on Tauri or its plugin architecture. It -provides connection management for SQLite databases using SQLx. It's designed to be -published as a standalone crate in the future with minimal changes. - -See [`crates/sqlx-sqlite-conn-mgr/README.md`](crates/sqlx-sqlite-conn-mgr/README.md) -for more details. - -### Tauri Plugin - -The main plugin provides a Tauri integration layer that exposes SQLite functionality -to Tauri applications. It uses the `sqlx-sqlite-conn-mgr` module internally. - -## Getting Started - -### Installation - -1. Install NPM dependencies: - - ```bash - npm install - ``` - -2. Build the TypeScript bindings: +## Architecture - ```bash - npm run build - ``` +| Operation Type | Method | Pool Used | Concurrency | +| -------------------- | --------------- | ---------------- | ------------------- | +| SELECT (multiple) | `fetchAll()` | Read pool | Multiple concurrent | +| SELECT (single) | `fetchOne()` | Read pool | Multiple concurrent | +| INSERT/UPDATE/DELETE | `execute()` | Write connection | Serialized | +| DDL (CREATE, etc.) | `execute()` | Write connection | Serialized | -3. Build the Rust plugin: +See [`crates/sqlx-sqlite-conn-mgr/README.md`](crates/sqlx-sqlite-conn-mgr/README.md) for +connection manager internals. - ```bash - cargo build - ``` +## Installation -### Tests - -Run Rust tests: - -```bash -cargo test -``` - -### Linting and Standards Checks - -```bash -npm run standards -``` - -## Install - -_This plugin requires a Rust version of at least **1.77.2**_ +_Requires Rust **1.77.2** or later_ ### Rust -Add the plugin to your `Cargo.toml`: - -`src-tauri/Cargo.toml` +`src-tauri/Cargo.toml`: ```toml [dependencies] @@ -91,19 +49,43 @@ tauri-plugin-sqlite = { git = "https://github.com/silvermine/tauri-plugin-sqlite ### JavaScript/TypeScript -Install the JavaScript bindings: - ```sh npm install @silvermine/tauri-plugin-sqlite ``` +### Permissions + +Add to `src-tauri/capabilities/default.json`: + +```json +{ + "permissions": ["sqlite:default"] +} +``` + +Or specify individual permissions: + +```json +{ + "permissions": [ + "sqlite:allow-load", + "sqlite:allow-select", + "sqlite:allow-select-one", + "sqlite:allow-execute-write", + "sqlite:allow-close", + "sqlite:allow-close-all", + "sqlite:allow-remove" + ] +} +``` + ## Usage -### Basic Setup +### Setup -Register the plugin in your Rust application: +Register the plugin in your Tauri application: -`src-tauri/src/lib.rs` +`src-tauri/src/lib.rs`: ```rust fn main() { @@ -114,411 +96,169 @@ fn main() { } ``` -### Connecting to a Database +### Connecting ```typescript import Database from '@silvermine/tauri-plugin-sqlite' -// Connect to a database (path is relative to app config directory) +// Path is relative to app config directory (no sqlite: prefix needed) const db = await Database.load('mydb.db') -``` -> **Note:** Database paths are relative to the app's config directory. Unlike -> `tauri-plugin-sql`, no `sqlite:` prefix is needed. +// With custom configuration +const db = await Database.load('mydb.db', { + maxReadConnections: 10, // default: 6 + idleTimeoutSecs: 60 // default: 30 +}) + +// Lazy initialization (connects on first query) +const db = Database.get('mydb.db') +``` -### Parameter Binding and Types +### Parameter Binding -All query methods (`execute`, `fetchAll`, `fetchOne`) support parameter binding using -the `$1`, `$2`, etc. syntax. Values must be of type `SqlValue`: +All query methods use `$1`, `$2`, etc. syntax with `SqlValue` types: ```typescript type SqlValue = string | number | boolean | null | Uint8Array ``` -Supported SQLite types: +| SQLite Type | TypeScript Type | Notes | +| ----------- | --------------- | ----- | +| TEXT | `string` | Also for DATE, TIME, DATETIME | +| INTEGER | `number` | Integers preserved up to i64 range | +| REAL | `number` | Floating point | +| BOOLEAN | `boolean` | | +| NULL | `null` | | +| BLOB | `Uint8Array` | Binary data | - * **TEXT** - `string` values (also used for DATE, TIME, DATETIME) - * **INTEGER** - `number` values (integers, preserved up to i64 range) - * **REAL** - `number` values (floating point) - * **BOOLEAN** - `boolean` values - * **NULL** - `null` value - * **BLOB** - `Uint8Array` for binary data +> **Note:** JavaScript safely represents integers up to ±2^53 - 1. The plugin binds +> integers as SQLite's INTEGER type (i64), maintaining full precision within that range. -> **Note:** JavaScript's `number` type can safely represent integers up to -> ±2^53 - 1 (±9,007,199,254,740,991). The plugin preserves integer precision by -> binding integers as SQLite's INTEGER type (i64). For values within the i64 -> range (-9,223,372,036,854,775,808 to 9,223,372,036,854,775,807), full precision -> is maintained. Values outside this range may lose precision. +### Write Operations -```typescript -// Example with different types -await db.execute( - 'INSERT INTO data (text, int, real, bool, blob) VALUES ($1, $2, $3, $4, $5)', - ['hello', 42, 3.14, true, new Uint8Array([1, 2, 3])] -) -``` - -### Executing Write Operations - -Use `execute()` for INSERT, UPDATE, DELETE, or any query that modifies data: +Use `execute()` for INSERT, UPDATE, DELETE, CREATE, etc.: ```typescript -// CREATE TABLE await db.execute( 'CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT, email TEXT)' ) -// INSERT const result = await db.execute( 'INSERT INTO users (name, email) VALUES ($1, $2)', ['Alice', 'alice@example.com'] ) -console.log(`Inserted ${result.rowsAffected} rows`) -console.log(`Last insert ID: ${result.lastInsertId}`) - -// UPDATE -const updateResult = await db.execute( - 'UPDATE users SET email = $1 WHERE name = $2', - ['alice.new@example.com', 'Alice'] -) -console.log(`Updated ${updateResult.rowsAffected} rows`) - -// DELETE -const deleteResult = await db.execute( - 'DELETE FROM users WHERE id = $1', - [1] -) +console.log(result.rowsAffected, result.lastInsertId) ``` -### Handling Errors - -Handle database errors gracefully using structured error responses: +### Read Operations ```typescript -import type { SqliteError } from '@silvermine/tauri-plugin-sqlite'; - -try { - await db.execute( - 'INSERT INTO users (id, name) VALUES ($1, $2)', - [1, 'Alice'] - ); -} catch (err) { - const error = err as SqliteError; - - // Check error code for specific handling - if (error.code.startsWith('SQLITE_CONSTRAINT')) { - console.error('Constraint violation:', error.message); - } else if (error.code === 'DATABASE_NOT_LOADED') { - console.error('Database not initialized'); - } else { - console.error('Database error:', error.code, error.message); - } -} -``` +type User = { id: number; name: string; email: string } -Common error codes include: - - * `SQLITE_CONSTRAINT` - Constraint violation (unique, foreign key, etc.) - * `SQLITE_NOTFOUND` - Table or column not found - * `DATABASE_NOT_LOADED` - Database hasn't been loaded yet - * `INVALID_PATH` - Invalid database path - * `IO_ERROR` - File system error - * `MIGRATION_ERROR` - Migration failed - * `MULTIPLE_ROWS_RETURNED` - `fetchOne()` query returned multiple rows - -### Executing SELECT Queries - -Use `fetchAll()` or `fetchOne()` for all read operations: - -```typescript -type User = {id: number, name: string, email: string} - -// SELECT all rows -const allUsers = await db.fetchAll( - 'SELECT * FROM users' -) - -// SELECT with parameters -const filtered = await db.fetchAll( - 'SELECT * FROM users WHERE name = $1 AND email LIKE $2', - ['Alice', '%@example.com'] +// Multiple rows +const users = await db.fetchAll( + 'SELECT * FROM users WHERE email LIKE $1', + ['%@example.com'] ) -// SELECT expecting single result (returns undefined if not found) +// Single row (returns undefined if not found, throws if multiple rows) const user = await db.fetchOne( 'SELECT * FROM users WHERE id = $1', [42] ) - -if (user) { - console.log(`Found user: ${user.name}`) -} ``` -> **Note:** `fetchOne()` validates that the query returns exactly 0 or 1 rows. If your -> query returns multiple rows, it will throw a `MULTIPLE_ROWS_RETURNED` error. This helps -> catch bugs where a query unexpectedly returns multiple results. Use `fetchAll()` if you -> expect multiple rows. - -### Using Transactions +### Transactions -Execute multiple database operations atomically using `executeTransaction()`. All -statements either succeed together or fail together, maintaining data consistency: +Execute multiple statements atomically: ```typescript -// Execute multiple inserts atomically -const results = await db.executeTransaction([ - ['INSERT INTO users (name, email) VALUES ($1, $2)', ['Alice', 'alice@example.com']], - ['INSERT INTO audit_log (action, user) VALUES ($1, $2)', ['user_created', 'Alice']] -]); -console.log(`User ID: ${results[0].lastInsertId}`); -console.log(`Log rows affected: ${results[1].rowsAffected}`); - -// Bank transfer example - all operations succeed or all fail const results = await db.executeTransaction([ ['UPDATE accounts SET balance = balance - $1 WHERE id = $2', [100, 1]], ['UPDATE accounts SET balance = balance + $1 WHERE id = $2', [100, 2]], ['INSERT INTO transfers (from_id, to_id, amount) VALUES ($1, $2, $3)', [1, 2, 100]] -]); -console.log(`Transfer ID: ${results[2].lastInsertId}`); +]) ``` -**How it works:** - - * Automatically executes `BEGIN IMMEDIATE` before running statements - * Executes all statements in order - * Commits with `COMMIT` if all statements succeed - * Rolls back with `ROLLBACK` if any statement fails - * The write connection is held for the entire transaction, ensuring atomicity - * Errors are thrown after rollback, preserving the original error message +Transactions use `BEGIN IMMEDIATE`, commit on success, and rollback on any failure. -### Closing Connections +### Error Handling ```typescript -// Close a specific database -await db.close() - -// Close all database connections -await Database.closeAll() -``` - -### Removing a Database - -Permanently delete a database and all its files (including WAL and SHM files): +import type { SqliteError } from '@silvermine/tauri-plugin-sqlite' -```typescript -// ⚠️ Warning: This permanently deletes the database file(s)! -await db.remove() +try { + await db.execute('INSERT INTO users (id) VALUES ($1)', [1]) +} catch (err) { + const error = err as SqliteError + console.error(error.code, error.message) +} ``` -## Migrations - -> **Note:** Database migration support is a planned feature and will be added in a -> future release. It will be based on SQLx's migration framework. +Common error codes: -## Query Parameter Binding + * `SQLITE_CONSTRAINT` - Constraint violation (unique, foreign key, etc.) + * `SQLITE_NOTFOUND` - Table or column not found + * `DATABASE_NOT_LOADED` - Database hasn't been loaded yet + * `INVALID_PATH` - Invalid database path + * `IO_ERROR` - File system error + * `MIGRATION_ERROR` - Migration failed + * `MULTIPLE_ROWS_RETURNED` - `fetchOne()` returned multiple rows -SQLite uses the `$1`, `$2`, etc. syntax for parameter binding: +### Closing and Removing ```typescript -type User = {id: number, name: string, email: string, role: string, created_at: number} - -// Multiple parameters -const result = await db.execute( - 'INSERT INTO users (name, email, role) VALUES ($1, $2, $3)', - ['Bob', 'bob@example.com', 'admin'] -) - -// Parameters in WHERE clause -const filtered = await db.fetchAll( - 'SELECT * FROM users WHERE role = $1 AND created_at > $2', - ['admin', 1609459200] -) +await db.close() // Close this connection +await Database.closeAll() // Close all connections +await db.remove() // Close and DELETE database file(s) - irreversible! ``` -> **Note:** Use `execute()` and `executeTransaction()` for write operations. -> For SELECT queries, use `fetchAll()` or `fetchOne()`. - -### Architecture - -The plugin uses `sqlx-sqlite-conn-mgr` for optimized connection management: - - * **Read Pool**: Multiple concurrent read-only connections (configurable, default: 6) - * **Write Connection**: Single exclusive write connection - * **WAL Mode**: Enabled automatically on first write operation - * **Connection Caching**: Databases are cached by path - * **Idle Timeout**: Connections close after inactivity (configurable, default: 30s) - -### Read vs Write Operations - -| Operation Type | Method | Pool Used | Concurrency | -| -------------------- | --------------- | ---------------- | ------------------ | -| SELECT (multiple) | `fetchAll()` | Read pool | Multiple concurrent| -| SELECT (single) | `fetchOne()` | Read pool | Multiple concurrent| -| INSERT/UPDATE/DELETE | `execute()` | Write connection | Serialized | -| CREATE TABLE | `execute()` | Write connection | Serialized | -| CREATE INDEX | `execute()` | Write connection | Serialized | - ## API Reference -### Database Class - -#### Static Methods - -##### `Database.load(path: string, customConfig?: CustomConfig): Promise` - -Connect to a database and return a Database instance. - - * `path`: Relative path to database file (from app config directory) - * `customConfig`: Optional connection pool configuration - * `Returns`: Promise resolving to Database instance - -```typescript -const db = await Database.load('mydb.db', { - maxReadConnections: 10, // defaults to 6 if no config is provided - idleTimeoutSecs: 60 // defaults to 30 if no config is provided -}) -``` - -##### `Database.get(path: string): Database` - -Get a Database instance without connecting (lazy initialization). - -```typescript -const db = Database.get('mydb.db') -// Connection happens on first query -``` - -##### `Database.closeAll(): Promise` - -Close all database connections. - -```typescript -await Database.closeAll() -``` - -#### Instance Methods - -##### `execute(query: string, bindValues?: unknown[]): Promise` - -Execute a write query (INSERT, UPDATE, DELETE, CREATE, etc.). - -```typescript -const result = await db.execute( - 'INSERT INTO users (name) VALUES ($1)', - ['Alice'] -) -console.log(result.rowsAffected, result.lastInsertId) -``` - -##### `fetchAll(query: string, bindValues?: unknown[]): Promise` - -Execute a SELECT query and return all matching rows. - -```typescript -const users = await db.fetchAll( - 'SELECT * FROM users WHERE role = $1', - ['admin'] -) -``` - -##### `fetchOne(query: string, bindValues?: unknown[]): Promise` - -Execute a SELECT query expecting zero or one result. Returns `undefined` if no rows match. - -```typescript -const user = await db.fetchOne( - 'SELECT * FROM users WHERE id = $1', - [42] -) - -if (user) { - console.log(user.name) -} else { - console.log('User not found') -} -``` - -##### `close(): Promise` - -Close this database connection. Returns `true` if the database was loaded and closed, -`false` if it wasn't loaded. +### Static Methods -```typescript -await db.close() -``` +| Method | Description | +| ------ | ----------- | +| `Database.load(path, config?)` | Connect and return Database instance (or existing) | +| `Database.get(path)` | Get instance without connecting (lazy init) | +| `Database.closeAll()` | Close all database connections | -##### `remove(): Promise` +### Instance Methods -Close the connection and permanently delete database file(s). Returns `true` if -the database was loaded and removed, `false` if it wasn't loaded. +| Method | Description | +| ------ | ----------- | +| `execute(query, values?)` | Execute write query, returns `{ rowsAffected, lastInsertId }` | +| `executeTransaction(statements)` | Execute statements atomically | +| `fetchAll(query, values?)` | Execute SELECT, return all rows | +| `fetchOne(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 | -> ⚠️ **Warning:** This cannot be undone! - -```typescript -await db.remove() -``` - -### TypeScript Interfaces +### Types ```typescript interface WriteQueryResult { - rowsAffected: number // Number of rows modified - lastInsertId: number // ROWID of last inserted row (not set for WITHOUT ROWID tables, returns 0) + rowsAffected: number + lastInsertId: number // 0 for WITHOUT ROWID tables } interface CustomConfig { - maxReadConnections?: number - idleTimeoutSecs?: number + maxReadConnections?: number // default: 6 + idleTimeoutSecs?: number // default: 30 } -``` - -## Thread Safety - -All operations are async and thread-safe. The connection manager ensures: - - * ✓ Multiple concurrent readers - * ✓ Only one writer at a time - * ✓ No write conflicts - * ✓ Automatic WAL mode for writers - -## Permissions -By default, the plugin has restrictive permissions. Add permissions in -`src-tauri/capabilities/default.json`: - -```json -{ - "permissions": [ - "sqlite:allow-load", - "sqlite:allow-select", - "sqlite:allow-select-one", - "sqlite:allow-execute-write", - "sqlite:allow-close", - "sqlite:allow-close-all", - "sqlite:allow-remove" - ] +interface SqliteError { + code: string + message: string } ``` -Or use the `default` permission set: +## Tracing and Logging -```json -{ - "permissions": ["sqlite:default"] -} -``` - -### Tracing and Logging - -This plugin and its connection manager crate use the -[`tracing`](https://crates.io/crates/tracing) ecosystem for internal logging. They are -configured with the `release_max_level_off` feature so that **all log statements are -compiled out of release builds**. This guarantees that logging from this plugin will never -reach production binaries unless you explicitly change that configuration. +The plugin uses [`tracing`](https://crates.io/crates/tracing) with +`release_max_level_off`, so **all logs are compiled out of release builds**. -To see logs during development, initialize a `tracing-subscriber` in your Tauri -application crate and keep it behind a `debug_assertions` guard, for example: +To see logs during development: ```toml [dependencies] @@ -530,10 +270,8 @@ tracing-subscriber = { version = "0.3.20", features = ["fmt", "env-filter"] } #[cfg(debug_assertions)] fn init_tracing() { use tracing_subscriber::{fmt, EnvFilter}; - let filter = EnvFilter::try_from_default_env() .unwrap_or_else(|_| EnvFilter::new("trace")); - fmt().with_env_filter(filter).compact().init(); } @@ -542,7 +280,6 @@ fn init_tracing() {} fn main() { init_tracing(); - tauri::Builder::default() .plugin(tauri_plugin_sqlite::init()) .run(tauri::generate_context!()) @@ -550,25 +287,17 @@ fn main() { } ``` -With this setup, `tauri dev` shows all plugin and app logs, while `tauri build` produces -a release binary that contains no logging from this plugin or your app-level `tracing` -calls. - -## Development Standards - -This project follows the -[Silvermine standardization](https://github.com/silvermine/standardization) -guidelines. Key standards include: - - * **EditorConfig**: Consistent editor settings across the team - * **Markdownlint**: Markdown linting for documentation - * **Commitlint**: Conventional commit message format - * **Code Style**: 3-space indentation, LF line endings +## Development -### Running Standards Checks +This project follows +[Silvermine standardization](https://github.com/silvermine/standardization) guidelines. ```bash -npm run standards +npm install # Install dependencies +npm run build # Build TypeScript bindings +cargo build # Build Rust plugin +cargo test # Run tests +npm run standards # Lint and standards checks ``` ## License @@ -577,5 +306,4 @@ MIT ## Contributing -Contributions are welcome! Please follow the established coding standards and commit -message conventions. +Contributions welcome! Follow the established coding standards and commit conventions. diff --git a/crates/sqlx-sqlite-conn-mgr/README.md b/crates/sqlx-sqlite-conn-mgr/README.md index 6b9130e..83e8c89 100644 --- a/crates/sqlx-sqlite-conn-mgr/README.md +++ b/crates/sqlx-sqlite-conn-mgr/README.md @@ -1,40 +1,25 @@ -# SQLx Connection Manager +# SQLx SQLite Connection Manager -A minimal wrapper around SQLx that enforces pragmatic SQLite connection -policies for mobile and desktop applications. Although this crate resides -in the `tauri-plugin-sqlite` repository, it is not dependent on Tauri -and could be used in any Rust project that needs SQLx connection -management. +A minimal wrapper around SQLx that enforces pragmatic SQLite connection policies +for mobile and desktop applications. Not dependent on Tauri — usable in any Rust +project needing SQLx connection management. ## Features - * **Maintains one read connection pool and one write connection per database**: - Prevents violation of access policies and/or a glut of open file handles and - (mostly) idle threads - * **Connection pooling**: - * Read-only pool for concurrent reads (default: 6 connections, configurable) - * **Lazy write pool**: Single write connection pool (max_connections=1) initialized on - first use - * **Exclusive write access**: WriteGuard ensures serialized writes - * **WAL mode**: Automatically enabled on first `acquire_writer()` call (setting - journal mode to WAL is safe and idempotent) - * See [WAL documentation](https://www.sqlite.org/wal.html) for details - * **30-second idle timeout**: Both read and write connections close after - 30 seconds of inactivity - * **No perpetual connection caching**: Zero minimum connections (min_connections=0) to - avoid idle thread overhead - -## Design Philosophy - -This library follows a minimal code philosophy: - - * Uses SQLx's `SqlitePoolOptions` for all pool configuration - * Uses SQLx's `SqliteConnectOptions` for open flags and configuration - * Wrapper with minimal logic - delegates to SQLx wherever possible + * **Single instance per database path**: Prevents duplicate pools and idle threads + * **Read pool**: Concurrent read-only connections (default: 6, configurable) + * **Write connection**: Single exclusive writer via `WriteGuard` -## Usage + > Wait! Why? From [SQLite docs](https://sqlite.org/whentouse.html): + > "_SQLite ... will only allow one writer at any instant in time._" + * **WAL mode**: Enabled on first `acquire_writer()` call + * **Idle timeout**: Connections close after 30s inactivity (configurable) + * **No perpetual caching**: Zero minimum connections (prevents idle thread sprawl) + +Delegates to SQLx's `SqlitePoolOptions` and `SqliteConnectOptions` wherever +possible — minimal wrapper logic. -### Basic Example +## Usage ```rust use sqlx_sqlite_conn_mgr::SqliteDatabase; @@ -43,29 +28,26 @@ use std::sync::Arc; #[tokio::main] async fn main() -> Result<(), sqlx_sqlite_conn_mgr::Error> { - // Connect to database (creates if missing, returns Arc) - // (See below for how to customize the configuration) + // Connect (creates if missing, returns Arc) let db = SqliteDatabase::connect("example.db", None).await?; - // Multiple connects to the same path return the same instance + // Multiple connects to same path return same instance let db2 = SqliteDatabase::connect("example.db", None).await?; assert!(Arc::ptr_eq(&db, &db2)); - // Use read_pool() for read queries (supports concurrent reads) + // Read queries use the pool (concurrent) let rows = query("SELECT * FROM users") .fetch_all(db.read_pool()?) .await?; - // Optionally acquire writer for write queries (exclusive access) - // WAL mode is enabled automatically on first call + // Write queries acquire exclusive access (WAL enabled on first call) let mut writer = db.acquire_writer().await?; query("INSERT INTO users (name) VALUES (?)") .bind("Alice") .execute(&mut *writer) .await?; - // Writer is automatically returned when dropped + // Writer released on drop - // Close the database when done db.close().await?; Ok(()) } @@ -73,172 +55,78 @@ async fn main() -> Result<(), sqlx_sqlite_conn_mgr::Error> { ### Custom Configuration -Only customize the configuration when the defaults don't meet your requirements: - ```rust use sqlx_sqlite_conn_mgr::{SqliteDatabase, SqliteDatabaseConfig}; use std::time::Duration; -#[tokio::main] -async fn main() -> Result<(), sqlx_sqlite_conn_mgr::Error> { - // Only create custom configuration when defaults aren't suitable - let custom_config = SqliteDatabaseConfig { - max_read_connections: 10, - idle_timeout: Duration::from_secs(60), - }; - - // Pass custom configuration to connect() - let db = SqliteDatabase::connect("example.db", Some(custom_config)).await?; - - // Use the database as normal... - db.close().await?; - Ok(()) -} +let config = SqliteDatabaseConfig { + max_read_connections: 10, // default: 6 + idle_timeout: Duration::from_secs(60), // default: 30s +}; +let db = SqliteDatabase::connect("example.db", Some(config)).await?; ``` -## API Overview +## API Reference ### `SqliteDatabase` - * `connect(path, custom_config)` - Connect to a database (creates if missing, - returns cached `Arc` if already open). Pass `None` for - `custom_config` to use defaults (recommended for most use cases), or - `Some(SqliteDatabaseConfig)` when you need to customize the configuration - * `read_pool()` - Get reference to the read-only connection pool for read - operations (returns `Result`) - * `acquire_writer()` - Acquire exclusive write access (returns - `Result`, enables WAL on first call) - * `close()` - Close the database and remove from cache (operations after - close return `DatabaseClosed` error) - * `close_and_remove()` - Close and delete all database files (.db, .db-wal, - .db-shm) - -### `SqliteDatabaseConfig` - -Configuration for connection pool behavior: - - * `max_read_connections: u32` - Maximum number of concurrent read connections - (default: 6) - * `idle_timeout: Duration` - How long idle connections remain open before - being closed (default: 30 seconds) +| Method | Description | +| ------ | ----------- | +| `connect(path, config)` | Connect/create database, returns cached `Arc` if already open | +| `read_pool()` | Get read-only pool reference | +| `acquire_writer()` | Acquire exclusive `WriteGuard` (enables WAL on first call) | +| `close()` | Close and remove from cache | +| `close_and_remove()` | Close and delete database files (.db, .db-wal, .db-shm) | ### `WriteGuard` -RAII guard that provides exclusive write access. Automatically returns the -connection when dropped. Derefs to `SqliteConnection` for use with SQLx -queries. - -## Guarantees - -1. **Single Instance Per Database**: Only one `SqliteDatabase` instance exists per - database file path in the process. Calling `connect()` multiple times on the - same path returns a reference to the same cached instance. +RAII guard for exclusive write access. Derefs to `SqliteConnection`. Connection +returned to pool on drop. -2. **Read-Only Pool**: The read pool opens connections with `read_only(true)`, - preventing any write operations through the pool and ensuring data integrity. +## Design Details -3. **WAL Mode**: WAL (Write-Ahead Logging) mode is automatically enabled - on the first call to `acquire_writer()` per `SqliteDatabase` instance. - The operation is idempotent and safe to call across multiple sessions, - allowing concurrent reads during writes. +### Read-Only Pool - **Synchronous Mode: NORMAL vs FULL** +The read pool opens connections with `read_only(true)`, preventing write +operations and ensuring data integrity. - When WAL mode is enabled, this library sets `PRAGMA synchronous = NORMAL` - instead of `FULL` for the following reasons: +### WAL Mode and Synchronous Setting - * **Performance**: `NORMAL` provides significantly better write performance - (up to 2-3x faster) by reducing the number of fsync operations. With `FULL`, - SQLite syncs after every checkpoint; with `NORMAL`, it syncs only the WAL file. +WAL mode is enabled on first `acquire_writer()` call (idempotent, safe across +sessions). This library sets `PRAGMA synchronous = NORMAL` instead of `FULL`: - * **Safety in WAL mode**: `NORMAL` is safe in WAL mode because: - * WAL transactions are atomic and durable at the WAL file level - * The database file itself can be checkpointed asynchronously - * A crash may corrupt the database file, but the WAL file remains intact - and will be used to recover on next open - * This is different from rollback journal mode where `NORMAL` could cause - corruption + * **Performance**: 2-3x faster writes — syncs only the WAL file, not after + every checkpoint + * **Safety in WAL mode**: WAL transactions are atomic at the WAL file level; + crashes recover from intact WAL on next open (unlike rollback journal mode + where `NORMAL` could cause corruption) + * **Mobile/Desktop context**: `NORMAL` provides the best balance; `FULL` is + for unreliable storage or power-loss-mid-fsync scenarios - * **Mobile/Desktop Context**: For typical desktop and mobile applications, - `NORMAL` provides the best balance of performance and safety. `FULL` is - primarily needed for scenarios with unreliable storage hardware or when - power loss can occur mid-fsync operation. +See [SQLite WAL Performance Considerations][wal-perf] for details. - See [SQLite WAL Performance Considerations][wal-perf] for more details. +[wal-perf]: https://www.sqlite.org/wal.html#performance_considerations - [wal-perf]: https://www.sqlite.org/wal.html#performance_considerations +### Exclusive Writes -4. **Exclusive Writes**: The write pool has `max_connections=1`, ensuring - only one writer can exist at a time. Other callers to - `acquire_writer()` will block (asynchronously) until the current writer - is released via `WriteGuard` drop. +The write pool has `max_connections=1`. Callers to `acquire_writer()` block +asynchronously until the current `WriteGuard` is dropped. -5. **Connection Management**: - * Read pool: 6 concurrent connections by default (configurable via `custom_config`) - * Write pool: max 1 connection - * Minimum connections: 0 (no perpetual caching) - * Idle timeout: 30 seconds by default (configurable via `custom_config`) - * Only customize `SqliteDatabaseConfig` when defaults don't meet your needs +## Tracing -## Tracing and Logging - -This crate uses the [`tracing`](https://crates.io/crates/tracing) ecosystem for internal -instrumentation. It is built with the `release_max_level_off` feature so that all -`tracing` log statements are compiled out of release builds. To see its logs during -development, the host application must install a `tracing-subscriber` and enable the -desired log level; no extra configuration is required in this crate. - -## Error Handling - -```rust -use sqlx_sqlite_conn_mgr::SqliteDatabase; - -match SqliteDatabase::connect("example.db").await { - Ok(db) => { - // Successfully connected (either new or existing instance) - }, - Err(e) => { - eprintln!("Error connecting to database: {}", e); - } -} -``` +Uses [`tracing`](https://crates.io/crates/tracing) with `release_max_level_off` — +all logs compiled out of release builds. Install a `tracing-subscriber` in your +app to see logs during development. ## Development -### Building - -```bash -cargo build -``` - -### Running Tests - -```bash -cargo test -``` - -### Linting - -This project follows the [Silvermine Rust coding standards][standards]. +Follows [Silvermine Rust coding standards][standards]. [standards]: https://github.com/silvermine/silvermine-info/blob/master/coding-standards/rust.md -To run linting checks: - -```bash -cargo lint-clippy && cargo lint-fmt -``` - -### Documentation - -Generate and view the documentation: - ```bash -cargo doc --open +cargo build # Build +cargo test # Test +cargo lint-clippy && cargo lint-fmt # Lint +cargo doc --open # Documentation ``` - -## Future - -In the future we may publish `sqlx-sqlite-conn-mgr` as a standalone crate, -allowing it to be used in any Rust project that needs SQLx connection -management.