SQLite Pools and Transactions
std.db.sqliteis included in Nagi 0.2.0, and is absent from 0.1.11. SF05 removes Db/db_*; see migration before upgrading.
Docs index · SQLite and migration · SQL preflight checks
Rust embedding code that exhaustively matches standard-module/resource/operation metadata enums must handle the new Query/literal variants.
On this page
What this API provides
Pool manages SQLite connections and Tx reserves one connection. SQL structure uses opaque Query; actual values use typed owned Parameters. SF05 removes the legacy Db/db_* entry points, public runtime Db/Sql, and dynamic string factories. Old Nagi entry points produce concrete migration diagnostics, with no fallback to a parallel legacy API.
Run the example
Prerequisites
- A Rust toolchain (
rustcandcargo) and a C toolchain that can build Rust crates with native dependencies. - No SQLite command-line tool is needed. SQLite is bundled with the Nagi runtime.
See how to obtain the source and build the compiler. Use the 0.2.0 source and compiler. With an installed 0.2.0 compiler, replace ./target/release/nagic below with nagic and omit the compiler build.
If cargo build fails while compiling a native dependency, check that an OS C compiler and linker are available as well as Rust. If nagic is not found, invoke the release binary below or add target/release to PATH.
Build the compiler and run the sample
From the repository root, where Cargo.toml is located, build the compiler, check the sample, and then run it:
cargo build --release --locked -p nagic
./target/release/nagic check examples/sqlite_pool.nagi
./target/release/nagic run examples/sqlite_pool.nagi
A successful check exits without diagnostics. run writes the following to standard output:
7
closed
On Windows, use the release executable from the same repository root. If it is on PATH, you can use nagic.exe directly.
cargo build --release --locked -p nagic
./target/release/nagic.exe check examples/sqlite_pool.nagi
./target/release/nagic.exe run examples/sqlite_pool.nagi
The sample opens a one-connection :memory: database, creates a table, saves and reads back 7, then closes the pool. query returns None for zero rows and Some with the first row otherwise. On success, main prints 7 and closed. How nagic run formats a failure is not an API guarantee.
The sample uses std.result.map_error to explicitly convert different error types into the application's AppFailure. options and bind_f64 return the ordinary Error type; database operations return sqlite.Failure. Failure does not implicitly convert to Error.
import std.db.sqlite as sqlite
import std.result as result
class Total:
amount: i64
enum AppFailure:
Configuration(cause: Error)
Database(cause: sqlite.Failure)
def configuration_error(cause: Error) -> AppFailure:
return AppFailure.Configuration(cause)
def database_error(cause: sqlite.Failure) -> AppFailure:
return AppFailure.Database(cause)
async def write_and_read(pool: view[sqlite.Pool]) -> Result[i64, sqlite.Failure]:
tx = try await sqlite.begin(pool, sqlite.BeginMode.DEFERRED)
try await sqlite.exec(tx, sqlite.literal("CREATE TABLE IF NOT EXISTS amounts(amount INTEGER NOT NULL)"), sqlite.parameters())
values = sqlite.bind_i64(sqlite.parameters(), 7)
try await sqlite.exec(tx, sqlite.literal("INSERT INTO amounts VALUES (?)"), values)
try await sqlite.commit(tx)
tx = try await sqlite.begin(pool, sqlite.BeginMode.DEFERRED)
row = try await sqlite.query[Total](tx, sqlite.literal("SELECT amount FROM amounts"), sqlite.parameters())
try await sqlite.rollback(tx)
match row:
case Some(total):
return ok(total.amount)
case None:
return ok(0)
async def main() -> Result[unit, AppFailure]:
config = try result.map_error(sqlite.options(1, 2, 1000, 0), configuration_error)
pool = try result.map_error(await sqlite.open(":memory:", config), database_error)
work = await write_and_read(view(pool))
ending = await sqlite.close(pool, 1000)
# Observe close even when the transaction failed; preserve the work error first.
amount = try result.map_error(work, database_error)
try result.map_error(ending, database_error)
print(amount)
return ok(print("closed"))
Even if write_and_read fails, main awaits close before returning the work error. If both work and close fail, the sample gives priority to the work error and does not return the close error. A production application should record both errors in logs or its own error type.
If a step fails
| Symptom | Check |
|---|---|
cargo build fails in a C/native dependency |
Confirm the repository root and that a Rust toolchain and C compiler/linker are available |
nagic: command not found |
Invoke target/release/nagic directly (Windows: target/release/nagic.exe) or check PATH |
check reports an unsupported module or API |
Confirm that the compiler was built from this worktree. The API is absent from installed Nagi 0.1.11 |
check rejects a row type |
Ensure T in query[T] / all[T] is a class supported by the generated row reader |
run returns a SQLite Failure |
Inspect its kind and message. Because open is lazy, a path/native connection failure may first appear in begin |
Types and ownership
| Type | Role and main constraints |
|---|---|
Options |
Holds connection count, per-worker queue capacity, acquisition timeout, and SQLite busy timeout. Supply every constructor argument |
Query |
Opaque Copy value from a direct literal; storage/share/Debug allowed, no Eq/JSON |
Pool |
Manages acquisition and close. clone_pool creates a handle that shares close and failure state |
Tx |
A transaction session on one connection. Non-Copy, same-task only, and cannot be stored in a field or shared value |
Parameters |
An owned list of SQL bind values. Each builder consumes it and returns the next Parameters value |
BeginMode |
DEFERRED, IMMEDIATE, EXCLUSIVE |
Failure |
A DB failure with kind, outcome, retired, and a borrowed message field |
FailureKind |
Failure classification |
Outcome |
The transaction result that was observable when the failure was made |
Pool is non-Copy. Call sqlite.clone_pool(pool) to create another handle; all clones share close and failure state. Tx is non-Copy, non-Clone, non-shared, and bound to the same task. It cannot be saved in a class field. It can be used in same-task locals, Option / Result values, and owned function delegation. Standard SQLite operations borrow it as view[Tx]. When your own function accepts view[Tx], pass view(tx) explicitly at the call site.
commit(tx) and rollback(tx) consume the Tx as soon as their Future is created. You cannot use that handle after awaiting the operation. To continue with another transaction, call begin again instead of reusing the old handle.
Options and paths
All four arguments to options(connections, queue_capacity, acquire_ms, busy_ms) are required. There are no numeric defaults.
| Argument | Condition and meaning |
|---|---|
connections |
Positive and within usize and Tokio Semaphore permit limits |
queue_capacity |
Positive and within the same permit limit. This is the command inbox capacity per worker; it does not bound all callers waiting for Pool slots or total heap use |
acquire_ms |
Non-negative and convertible to native Duration / deadline. One budget for waiting for a logical slot and registering a native slot |
busy_ms |
Non-negative, within the native time range and SQLite's i32 millisecond range. How long SQLite waits for a database lock |
Capacity overflow, negative values, and time values outside the native range return Error from options. A timeout of 0ms means “do not wait,” not “always fail”: it can succeed if the condition already holds.
open(path, options) rejects an empty path and file: URIs. :memory: works only with one connection because each connection would otherwise have a separate database. Other paths must be ordinary filesystem paths; relative paths use the process's current working directory. open owns the path when its Future is created, but creates native connections lazily when begin is called. Therefore, a filesystem or native open error may first appear as a WORKER failure from the first begin. URI interpretation, shared-memory URIs, and automatic WAL setup are not provided.
sqlite.literal("SELECT id FROM items WHERE id = ?") accepts a direct literal by canonical identity. Variables, views, concatenation, formatting, and returned strings are rejected at their source positions; unrelated user functions with the same name remain available. Query is Copy and supports local/field storage and return values, but exposes no SQL text, JSON, equality, or direct construction. query/all/exec require Query. Ordinary check contains no SQL parser; runtime or explicit SQL preflight validates SQL shape.
Typed parameters and SQL shape
Start with parameters() and pass each builder's result to the next builder. The resulting order is the order of anonymous SQLite ? placeholders.
| Builder | Added value | Condition |
|---|---|---|
bind_i64(parameters, value) |
i64 |
Integer |
bind_f64(parameters, value) |
f64 |
Finite values only; NaN/Infinity returns Error |
bind_text(parameters, value) |
str |
Passed as owned text |
bind_bytes(parameters, value) |
bytes |
Passed as owned bytes |
bind_null(parameters) |
Plain SQLite NULL | Not a typed NULL |
New API SQL must contain one statement at a time and use anonymous ? placeholders only. Named :name / @name and numbered ?1 placeholders are unsupported. Runtime checks bind counts, value types, and NULL decoding. Ordinary check does not execute SQL against database values. Bind values with Parameters rather than concatenating them into SQL text.
query, all, and exec
| Operation | Accepted SQL | Return value |
|---|---|---|
query[T] |
One statement SQLite classifies as readonly and that returns columns. SQLite metadata decides this; the checker does not rely on the SELECT keyword alone |
Result[Option[T], Failure]. None for zero rows, otherwise Some with the first row |
all[T] |
Same readonly row-producing SQL as query |
Result[List[T], Failure] with every row |
exec |
One statement with no result columns. Ordinary table/index/view/trigger DDL is supported | Result[i64, Failure] with that statement's change count |
A write with RETURNING returns columns, so it is neither readonly for query / all nor column-free for exec. Do not put BEGIN / COMMIT / ROLLBACK (including their variants), SAVEPOINT, PRAGMA, ATTACH, or DETACH in user SQL. The module operations own transaction boundaries. Virtual tables, extensions, and raw connections are not part of the public API.
T must be a class with fields supported by the generated FromRow adapter: scalar i8/i16/i32/i64/u8/u16/u32/f32/f64/bool/str/bytes fields, their Option form, and supported owned[...] wrappers. Field names must match returned column names. Use T? for a field that accepts SQL NULL. Enums, resources, arbitrary generics, and unsupported field types are rejected by check. Runtime Result handles actual value types, NULL decoding, and integer-range errors. Handwritten Rust FromRow implementations do not bypass the new API's checker.
Public types and operation signatures
The module exposes nine types and 19 operations. view[T] borrows a value, Future[T] is asynchronous work to await, and Result[T, E] contains either a value or an error. try propagates Err from the current function. See views, async, and error handling for the language rules.
| Type | Copy / field storage / shared / debug | Meaning |
|---|---|---|
Query |
Copy / storable, shared, Debug; no Eq/JSON | Fixed structure from a direct literal |
Pool |
non-Copy / storable / shared / Debug | clone_pool makes a handle to the same pool |
Tx |
non-Copy / not storable / not shared / no Debug | Affine transaction handle bound to the same task |
Parameters |
non-Copy / storable / not shared / no Debug | Builders consume and extend owned bind values |
Options |
non-Copy / storable / not shared / Debug | Validated configuration from options |
BeginMode |
Copy and equality / storable, shared, Debug | DEFERRED / IMMEDIATE / EXCLUSIVE |
Failure |
non-Copy / storable, shared, Debug | Error and transaction/retirement metadata |
FailureKind |
Copy and equality / storable, shared, Debug | 13 classifications below |
Outcome |
Copy and equality / storable, shared, Debug | Five observable states below |
| Operation | Signature | Use and return |
|---|---|---|
literal |
(sql: str) -> Query |
Checker accepts a direct string literal only |
options |
(connections: i64, queue_capacity: i64, acquire_ms: i64, busy_ms: i64) -> Result[Options, Error] |
Set all values and validate their ranges |
open |
(path: view[str], options: Options) -> Future[Result[Pool, Failure]] |
Own the path and create a Pool; native connections are lazy |
clone_pool |
(pool: view[Pool]) -> Pool |
Create a handle sharing close/failure state |
begin |
(pool: view[Pool], mode: BeginMode) -> Future[Result[Tx, Failure]] |
Acquire a logical slot and start a transaction |
parameters |
() -> Parameters |
Create an empty bind list |
bind_i64 |
(parameters: Parameters, value: i64) -> Parameters |
Add an integer |
bind_f64 |
(parameters: Parameters, value: f64) -> Result[Parameters, Error] |
Add a finite float |
bind_text |
(parameters: Parameters, value: str) -> Parameters |
Add owned text |
bind_bytes |
(parameters: Parameters, value: bytes) -> Parameters |
Add owned bytes |
bind_null |
(parameters: Parameters) -> Parameters |
Add plain SQLite NULL |
query[T] |
[T](tx: view[Tx], sql: Query, parameters: Parameters) -> Future[Result[Option[T], Failure]] |
First row of readonly row-producing SQL |
all[T] |
[T](tx: view[Tx], sql: Query, parameters: Parameters) -> Future[Result[List[T], Failure]] |
All rows of readonly row-producing SQL |
exec |
(tx: view[Tx], sql: Query, parameters: Parameters) -> Future[Result[i64, Failure]] |
One statement with no result columns |
commit |
(tx: Tx) -> Future[Result[unit, Failure]] |
End the transaction with commit |
rollback |
(tx: Tx) -> Future[Result[unit, Failure]] |
End the transaction with rollback |
close |
(pool: view[Pool], timeout_ms: i64) -> Future[Result[unit, Failure]] |
Start closing and wait for worker joins |
copy_primary_error |
(problem: view[Failure]) -> Option[Error] |
Explicitly copy the primary cause |
copy_cleanup_error |
(problem: view[Failure]) -> Option[Error] |
Explicitly copy the cleanup cause |
After awaiting an async operation, handle Result with try or match. options and bind_f64 return Error on failure; other database operations return Failure.
Building Parameters
bind_i64 and the other builders move the incoming Parameters and return an extended value. Pass that result to the next builder. query, all, and exec also consume Parameters, so it cannot be reused.
def make_values() -> sqlite.Parameters:
values = sqlite.parameters()
values = sqlite.bind_i64(values, 4)
values = sqlite.bind_text(values, "Nagi")
return values
The values bind in order to anonymous ? placeholders. To use values in another SQL statement after a query, build a new Parameters for that statement or build them again. copy cannot clone Parameters.
Read multiple rows
all[T] takes a row class and returns all rows. For the Total class above, reading amounts looks like this:
async def read_all(tx: view[sqlite.Tx]) -> Result[List[Total], sqlite.Failure]:
return await sqlite.all[Total](tx, sqlite.literal("SELECT amount FROM amounts ORDER BY amount"), sqlite.parameters())
Choose query for the first row and all to iterate through every row. Use exec for DDL or writes that return no columns. Runtime rejects a mismatch between the operation and SQL shape, multiple statements, and named or numbered placeholders.
Common mistakes and fixes
| Mistake | Stage and reason | Fix |
|---|---|---|
sqlite.query[i64](tx, ...) |
Rejected by check. The new API needs a class handled by generated FromRow |
Define a class with an amount: i64 field and use query[Amount] |
sqlite.exec(tx, sqlite.literal("DELETE FROM items WHERE id = ?1"), params) |
Runtime SQL failure: the new API accepts anonymous ? only |
Use ? and build Parameters in that order |
Reuse values after sqlite.all[Row](tx, sql, values) |
all consumes Parameters |
Build separate Parameters for each SQL statement |
Use tx after try await sqlite.commit(tx) |
commit consumes Tx when its Future is created |
Call begin for a new transaction |
Save Tx in a class field or move it to a child task |
Rejected by check; Tx cannot cross storage, sharing, or task boundaries |
Finish it in the same task, or call begin inside the child task |
Pass a local tx to def read(tx: sqlite.Tx) |
Moves Tx into an owned parameter, leaving the caller without it | Take view[sqlite.Tx] and call read(view(tx)) |
An application helper can borrow a Tx and use it in the same task:
async def read_total(tx: view[sqlite.Tx]) -> Result[Total?, sqlite.Failure]:
return await sqlite.query[Total](tx, sqlite.literal("SELECT amount FROM amounts"), sqlite.parameters())
async def call_read_total(tx: sqlite.Tx) -> Result[Total?, sqlite.Failure]:
return await read_total(view(tx))
The SQLite authorizer also rejects BEGIN / COMMIT / ROLLBACK in SQL, SAVEPOINT, PRAGMA, ATTACH, and DETACH. Use the module operations for transaction boundaries.
Transaction and concurrency boundaries
Pool's logical slot wait uses Tokio's FIFO semaphore. The FIFO statement applies only to this logical permit wait. It does not promise FIFO completion for native worker startup, native slot registration, or SQLite BEGIN.
acquire_ms uses one budget from logical permit wait through native record registration. Connection startup and ready/BEGIN SQL busy waits after registration are outside that budget. busy_ms controls SQLite busy waiting. There is no separate interrupt deadline for SQL execution or commit.
One Tx reserves one connection. Pool handles may be shared, but a Tx cannot be moved or captured by a child task or used by another task. If a child needs a transaction, call begin with a Pool inside that child. An outer TaskFailure (failure of the child itself) is separate from Result[..., sqlite.Failure] returned by the child's DB work.
An ordinary SQL, bind, or decode error can leave a Tx usable when native SQLite confirms that the transaction is still active. The failing statement may still have made earlier changes, so an error does not promise no changes. If SQLite automatically rolls the transaction back, later operations are rejected with ABORTED / ROLLED_BACK. Cancellation does not undo accepted SQL or external side effects. Do not infer that retry or rollback is safe; design idempotency and explicit rollback for the application.
Read a Failure
Read the failure category separately from the transaction outcome. outcome reports what could be observed; it does not mean that retrying is safe.
FailureKind |
Meaning |
|---|---|
INVALID |
Invalid path/options or timeout setting |
CLOSED |
Pool is closing or closed |
ACQUIRE_TIMEOUT |
No logical/native slot was available within the acquisition budget |
BUSY |
SQLite reported a busy or locked database |
SQL |
SQL preparation or execution failed |
BIND |
Placeholder/value binding failed |
DECODE |
A SQLite row could not be converted into a class field |
ABORTED |
The native SQLite transaction had already ended |
CLEANUP |
Transaction or connection cleanup failed |
WORKER |
Native connection startup or worker operation failed |
REPLY_LOST |
No result reached the caller; the outcome is unknown |
CLOSE_TIMEOUT |
All workers were not joined before the close deadline |
ALLOCATION |
A fallible ledger/idle reservation failed |
ALLOCATION reports explicit fallible reservations. The API does not promise recovery from every OOM or allocator abort.
Outcome |
Meaning |
|---|---|
NOT_APPLICABLE |
There is no active Tx related to this failure, or one has not started |
ACTIVE |
Native SQLite confirmed that the Tx was active when the failure was made |
COMMITTED |
Commit completion was confirmed |
ROLLED_BACK |
Explicit or automatic rollback was confirmed |
UNKNOWN |
Completion could not be determined |
retired reports whether retirement of the connection was confirmed for this Failure. false only means retirement was not confirmed from this failure; it does not prove the worker or Pool is healthy. In particular, do not infer that REPLY_LOST / UNKNOWN means rollback completed, the connection can be reused, or retry is safe. Debug output is limited to kind / outcome / retired, but the public message string is not a stable protocol.
Failure.message is a view[str] borrowing from the Failure owner. To keep it longer than the Failure, use copy(problem.message) to make an owned string. Use copy_primary_error(problem) and copy_cleanup_error(problem) to explicitly copy native causes into Option[Error]. They return None when that cause is absent; otherwise they copy its kind and message. A failure can carry distinct primary and cleanup information, such as when commit completed but cleanup failed.
Close the Pool
close(pool, timeout_ms) starts a closing state shared by every clone, stops new begin calls, and waits for active Tx cleanup, native connection close, and actual worker joins. A timeout of zero can still succeed if shutdown is already complete. Timeout or cancellation after closing starts does not clear the closing state. Active transactions are not killed; a later close can wait for completion.
Dropping the last Pool handle only requests shutdown. It does not confirm async cleanup or worker joins. If the application needs to know that shutdown completed, an owner must await close. Successful close confirms native close and actual worker-thread join. There is no guarantee that close returns if SQL blocks indefinitely or the process is killed or aborts on allocation failure.
SQL preflight checks
Ordinary check validates the Query literal boundary, types, ownership, and row fields without starting SQLite. Opt-in checks with an explicit schema prepare direct Query constructors, validating SQL shape, schema, and required columns. Direct Parameters builder chains provide known bind counts; Query structure or Parameters counts hidden by variables/functions are reported for runtime checks. Actual value types, NULL, numeric ranges, and database data remain runtime checks. See SQL preflight.
Choosing an API and related pages
- For schema-based offline checks, see SQL preflight checks. For Nagi
Result,try, nullable values,view, and ownership, see error handling, Option and nullable types, ownership, views, and async.