Ahtola ships as four NuGet packages that build directly on top of each
other. This guide goes deeper than the top-level README:
which package to install for a given scenario, the full ADO.NET / EF Core API
surface, connection-string keywords, and end-to-end walkthroughs for a
local SQLite file, local concurrent writes (MVCC / BEGIN CONCURRENT),
and Turso Cloud — both a direct connection and a managed embedded
replica.
- Packages and installation
- Which package do I need?
- The SQLite-compatible facade
- Native Ahtola types
- Connection string reference
- Working with a local SQLite file
- Concurrent writes on a local file (MVCC)
- Encryption
- Entity Framework Core
- Turso Cloud: direct connection
- Turso Cloud: managed embedded replica
- Error handling patterns
| Package | NuGet | Role |
|---|---|---|
Devolutions.Ahtola.Core |
nuget.org | Pure-managed engine (pager, b-tree, WAL, VDBE). Rarely referenced directly — it flows in transitively — unless you need engine-level types such as IPageCodec. |
Devolutions.Ahtola.Data.Sqlite |
nuget.org | ADO.NET provider: the Microsoft.Data.Sqlite-compatible facade (Ahtola.Data.Sqlite.SqliteConnection, …) plus the native Ahtola.AhtolaConnection types (local files, MVCC, Turso Cloud direct/replica). Embeds Ahtola.Data. |
Devolutions.Ahtola.Data.Sqlite.Browser |
nuget.org | Blazor/.NET WebAssembly data source with durable OPFS storage. See the browser deployment guide. |
Devolutions.Ahtola.EntityFrameworkCore.Sqlite |
nuget.org | EF Core provider (UseAhtola) — local databases, direct remote Turso Cloud/Hrana connections, and embedded replicas (see Entity Framework Core). |
# ADO.NET only
dotnet add package Devolutions.Ahtola.Data.Sqlite
# + EF Core (9.x on net8.0/net9.0, 10.x on net10.0)
dotnet add package Devolutions.Ahtola.EntityFrameworkCore.Sqlite
# Blazor/.NET WebAssembly OPFS support
dotnet add package Devolutions.Ahtola.Data.Sqlite.BrowserTargets net8.0, net9.0, net10.0 — no net48 / .NET Framework assets, no
native SQLite binary, and no P/Invoke SDK to restore. Every shipped package
(Devolutions.Ahtola.Core, Devolutions.Ahtola.Data.Sqlite — which embeds
Devolutions.Ahtola.Data — Devolutions.Ahtola.Data.Sqlite.Browser, and
Devolutions.Ahtola.EntityFrameworkCore.Sqlite) builds with
IsAotCompatible/IsTrimmable and ships no trim-warning suppression, so a
trimmed or NativeAOT publish reports nothing from Ahtola itself.
The ADO stack (…Core → …Data.Sqlite → optionally …Data.Sqlite.Browser) is
trim-clean end to end: a publish with
-p:SuppressTrimAnalysisWarnings=false -p:TrimmerSingleWarn=false reports zero
IL2xxx/IL3xxx warnings across the whole closure. Adding
Devolutions.Ahtola.EntityFrameworkCore.Sqlite still reports warnings, but they
come from Microsoft.EntityFrameworkCore, which annotates DbContext and the
query pipeline with RequiresUnreferencedCode/RequiresDynamicCode; none of
them originate in Ahtola. An EF Core profile only becomes trim-clean once that
upstream chain is warning-free.
Adding Devolutions.Ahtola.EntityFrameworkCore.Sqlite automatically brings in
Devolutions.Ahtola.Data.Sqlite, which in turn brings in
Devolutions.Ahtola.Core — you rarely need to dotnet add package Devolutions.Ahtola.Core yourself. Add it directly only if you're writing an
IPageCodec implementation or otherwise coding straight against
Ahtola.Core.Storage types.
- Drop-in replacement for
Microsoft.Data.Sqlite→Devolutions.Ahtola.Data.Sqlite,using Ahtola.Data.Sqlite;— see The SQLite-compatible facade. - Native Ahtola API, or you need Turso Cloud (direct connection or embedded
replica) → same package,
using Ahtola;— see Native Ahtola types and the Turso Cloud sections below. - EF Core → also add
Devolutions.Ahtola.EntityFrameworkCore.Sqliteand calloptionsBuilder.UseAhtola(...)— local files, direct remote Turso Cloud/Hrana URLs, and embedded replicas are all supported (see Entity Framework Core). - Blazor/.NET WebAssembly with durable OPFS storage → add
Devolutions.Ahtola.Data.Sqlite.Browser, create anAhtolaBrowserDataSource, and use async APIs. Opt intoAhtolaBrowserSynchronousMode.ReadOnlyMirrorwhen existing repository code needs synchronous reads: after one asynchronous open, provably read-only statements are served from the managed in-memory mirror without touching OPFS. See the browser deployment guide.
Ahtola.Data.Sqlite mirrors Microsoft.Data.Sqlite's public shape closely
enough that most code only needs a using swap:
using Ahtola.Data.Sqlite;
using var connection = new SqliteConnection("Data Source=app.db");
connection.Open();
connection.ExecuteNonQuery("CREATE TABLE t(a INTEGER, b TEXT)");
connection.ExecuteNonQuery("INSERT INTO t VALUES (1, 'hello')");
using var command = connection.CreateCommand();
command.CommandText = "SELECT a, b FROM t";
using var reader = command.ExecuteReader();
while (reader.Read())
Console.WriteLine($"{reader.GetInt32(0)} {reader.GetString(1)}");ExecuteNonQuery(string), ExecuteReader(string), and ExecuteScalar(string)
/ ExecuteScalar<T>(string) are convenience extension methods on
SqliteConnection for one-shot statements without allocating a SqliteCommand
yourself. SqliteFactory.Instance is the DbProviderFactory if you need
provider-agnostic construction (e.g. DbProviderFactories.RegisterFactory).
The same facade also opens direct Turso/Hrana URLs and managed embedded
replicas, so code written against Microsoft.Data.Sqlite can usually keep its
SqliteConnection/SqliteCommand/SqliteBatch shapes:
using var cloud = new SqliteConnection(
"Data Source=turso://my-db.turso.io;Auth Token=" + authToken);
using var replica = new SqliteConnection(
"Data Source=turso://my-db.turso.io;Auth Token=" + authToken
+ ";Replica Path=./replica.db");Local-only APIs (user-defined functions/aggregates, custom collations, hooks,
extensions, backup, and incremental blobs) fail explicitly when the selected
remote mode cannot provide them. Inspect connection.Capabilities before using
an optional surface. A managed embedded replica runs every statement on its
local database, so it accepts user-defined functions, aggregates and
collations, as Turso does. Registrations made before or after Open apply to
the local database, and are applied again when a sync publication reopens it.
A direct remote connection, or a replica opened through a registered native
sync companion, does not.
Managed open of unencrypted SQLite databases created by System.Data.SQLite
/ Microsoft.Data.Sqlite / native sqlite3 is supported (Data Source=path
only; no special flags). Ahtola is byte-compatible with the on-disk format for
normal read/write workloads.
using Ahtola; exposes AhtolaConnection, AhtolaCommand, AhtolaParameter,
AhtolaTransaction, AhtolaBatch/AhtolaBatchCommand, and
AhtolaFactory.Instance. This is the same package
(Devolutions.Ahtola.Data.Sqlite) as the facade above — pick whichever set of
types fits your code, or mix them (both wrap the same engine and honor the
same connection string):
using Ahtola;
using var connection = new AhtolaConnection("Data Source=:memory:");
connection.Open();
connection.ExecuteNonQuery("CREATE TABLE t(a, b)");Use the native types when you prefer the Ahtola-specific API and exception
types. Both facades support direct remote connections, managed embedded
replicas, batches, Sync/SyncAsync, and Capabilities (e.g.
CanCreateBatch, SupportsSync) for feature-testing a connection before use.
Common keywords accepted by both Ahtola.Data.Sqlite.SqliteConnectionStringBuilder
and Ahtola.AhtolaConnectionStringBuilder:
| Keyword | Notes |
|---|---|
Data Source (Filename) |
File path, :memory:, or a Turso/Hrana URL (turso://…, libsql://…, https://…, wss://…) |
Mode |
ReadWriteCreate (default), ReadWrite, ReadOnly, Memory |
Cache |
Private (default) / Shared |
Pooling |
Connection pooling (default true). Encrypted files are pooled per cipher and key: only a connection with the same key reuses a pooled database, and an idle pooled database keeps its key and decrypted page cache in process memory until ClearPool/ClearAllPools or Pooling=False |
Foreign Keys |
PRAGMA foreign_keys |
Recursive Triggers |
PRAGMA recursive_triggers |
Default Timeout / Command Timeout |
Busy timeout in seconds |
Vfs |
Named VFS registration |
Encryption Cipher / Encryption Key |
Raw hexadecimal page-encryption key (see Encryption) |
Encryption Cipher / Encryption Key |
Raw-key encryption (hex AES-GCM or AEGIS keys) |
Local Provider |
Managed (default) or Native. Native requires the optional, non-shipped native companion to have called AhtolaNativeProvider.Register(factory) (typically from a [ModuleInitializer]); nothing is loaded by assembly name, so without a registration the connection fails closed with NotSupportedException. |
Foreign Read Only |
Read another engine's open database without taking main-file locks (Mode=ReadOnly + Pooling=False) |
DateTimeKind, BinaryGUID |
Facade-only ADO.NET conversion behavior. BinaryGUID=False binds Guid parameters as uppercase TEXT, like Microsoft.Data.Sqlite |
Journal Mode, Synchronous, Page Size, Cache Size, BusyTimeout |
System.Data.SQLite keywords, applied as pragmas on open; Journal Mode also picks the mode a new database is created in |
Read Only, FailIfMissing |
System.Data.SQLite spellings of Mode=ReadOnly / Mode=ReadWrite |
Password |
System.Data.SQLite password: legacy RC4 page codec (wxSQLite3 AES-128 files are detected too); see Migrating |
Ignore Unknown Keywords |
Ignore unknown keywords instead of throwing |
Auto Enlist Transaction |
Commands without a Transaction join the connection's pending transaction |
Type Mapping |
Default, or SystemDataSQLite for System.Data.SQLite's declared-type CLR types |
Guid Column Name Heuristic |
Read 16-byte blobs in ID/*ID columns as GUID strings (off by default) |
See Migrating from Microsoft.Data.Sqlite or System.Data.SQLite for every behaviour that differs from those providers and from native SQLite.
WAL checkpoints. Local file connections opened through
SqliteConnectionorAhtolaConnectionfollow Turso's checkpoint policy (core/storage/pager.rscommit_wal,core/storage/wal.rsshould_checkpoint): an ordinary commit is checkpointed into the main database file only once more than 1000 committed frames are waiting in the-wal(PRAGMA wal_autocheckpointchanges the threshold), and closing the database (the last pooled connection,ClearPool/ClearAllPools, orPooling=Falseclose) checkpoints what is left when no other connection holds the WAL, then deletes the empty-waland the-shmwhen no connection in any process still has the file open. Between checkpoints the.dbfile alone does not contain the latest commits, exactly as with SQLite and Turso in WAL mode, so copy or back up the-walwith it (or runPRAGMA wal_checkpoint(TRUNCATE)first). Structural changes (schema rewrites,VACUUM, page-size and journal-mode changes) and embedded replicas still checkpoint immediately.
Companion compatibility. Earlier versions activated
Local Provider=Nativeby loadingTurso.Data.Nativereflectively and invoking itsNativeProviderRegistration.Register. Reflective probing is invisible to the trimmer and to NativeAOT, so it is gone: activation is now explicit only. A companion package built against the old behavior never callsRegisteritself and is therefore never activated —Local Provider=Nativefails closed withNotSupportedExceptioneven when the package is installed. Companions must ship a release that callsAhtolaNativeProvider.Register(...),SqliteNativeProvider.Register(...)andAhtolaReplicaProvider.Register(...)from a[ModuleInitializer], or document an explicit startup call. That companion release is tracked separately from this repository.
Remote keywords accepted by both facades (see the Turso Cloud sections below):
Auth Token, Replica Path, Sync Interval, Read Your Writes, Tls.
Embedded-replica keywords (each also accepts its space-free alias, e.g.
SyncClientName), named as in Turso's TursoConnectionStringBuilder so EF Core
UseAhtola(...) and the SQLite facade can reach every replica option. They
require Replica Path; on a local data source they throw
InvalidOperationException, on a direct remote connection NotSupportedException:
| Keyword | Maps to | Notes |
|---|---|---|
Sync Client Name |
AhtolaReplicaOptions.ClientName |
Prefix of the sync client id recorded at bootstrap (<name>-<guid>); 1–64 of A-Z a-z 0-9 . _ - |
Sync Long Poll Timeout |
LongPollTimeout |
Milliseconds; 0 (default) disables long polling |
Bootstrap If Empty |
BootstrapIfEmpty |
Default true |
Partial Bootstrap Prefix / Partial Bootstrap Query |
PartialBootstrap |
Mutually exclusive; bytes / server-side SQL |
Partial Sync Segment Size / Partial Sync Prefetch |
PartialBootstrap.SegmentSize / .Prefetch |
Require one of the two keywords above |
Remote Encryption Cipher / Remote Encryption Key |
RemoteEncryption |
Turso cipher names (aes256gcm, aegis256, …) and a base64 key; both or neither |
Push Operations Threshold |
PushOperationsThreshold |
0 uses the default |
Pull Bytes Threshold |
PullBytesThreshold |
0 pulls the bootstrap image in one request |
Automatic Sync Mode |
AutomaticSyncMode |
Ahtola-specific: PushAndPull (default) or PullOnly (Turso's behavior) |
Force Logical MVCC Pull |
— | Only False is supported; True fails closed because the managed provider always auto-detects the pull protocol |
Sync Experimental Features |
ExperimentalFeatures |
Turso feature names, passed unchanged to a registered sync companion. A managed replica accepts the names its engine always provides (strict, index_method, vacuum, encryption, attach, generated_columns, without_rowid, multiprocess_wal). custom_types enables the restricted TYPE/DOMAIN subset. views, autovacuum and mvcc_passive_checkpoint fail with NotSupportedException. Unknown names fail with ArgumentException (Turso ignores them). |
Hrana WebSocket keywords, used only by ws:///wss:// data sources (see
Hrana over WebSocket):
| Keyword | Aliases | Default | Notes |
|---|---|---|---|
Ws Keepalive Interval |
WsKeepaliveInterval, WebSocket Keepalive Interval, WebSocketKeepAliveInterval |
30 |
Keep-alive ping interval in seconds; 0 disables |
Ws Keepalive Timeout |
WsKeepaliveTimeout, WebSocket Keepalive Timeout, WebSocketKeepAliveTimeout |
20 |
Pong grace period in seconds (.NET 9+; ignored on net8.0) |
Ws Half Open Timeout |
WsHalfOpenTimeout, WebSocket Half Open Timeout, WebSocketHalfOpenTimeout |
0 |
Seconds of total peer silence, while requests are outstanding, that abort the connection as half-open. 0 disables it. This is the only half-open detection on net8.0; because a Hrana server sends nothing while a statement runs, a non-zero value also caps how long one request may take |
Ws Max Message Bytes |
WsMaxMessageBytes, WebSocket Max Message Bytes, WebSocketMaxMessageBytes |
16777216 |
Hard cap on one reassembled message (8 KiB–512 MiB) |
Ws Connect Attempts |
WsConnectAttempts, WebSocket Connect Attempts, WebSocketConnectAttempts |
3 |
Bounded connection-establishment attempts (1–10); never replays operations |
using Ahtola.Data.Sqlite;
using var connection = new SqliteConnection("Data Source=app.db");
connection.Open();
connection.ExecuteNonQuery(
"CREATE TABLE IF NOT EXISTS Items(Id INTEGER PRIMARY KEY, Name TEXT, Qty INTEGER)");
using (var transaction = connection.BeginTransaction())
{
using var insert = connection.CreateCommand();
insert.Transaction = transaction;
insert.CommandText = "INSERT INTO Items(Name, Qty) VALUES ($name, $qty)";
var name = insert.CreateParameter();
name.ParameterName = "$name";
insert.Parameters.Add(name);
var qty = insert.CreateParameter();
qty.ParameterName = "$qty";
insert.Parameters.Add(qty);
foreach (var (itemName, itemQty) in new[] { ("Widget", 3), ("Gadget", 7) })
{
name.Value = itemName;
qty.Value = itemQty;
insert.ExecuteNonQuery();
}
transaction.Commit();
}
using var reader = connection.ExecuteReader("SELECT Id, Name, Qty FROM Items");
while (reader.Read())
Console.WriteLine($"{reader.GetInt64(0)}: {reader.GetString(1)} x{reader.GetInt32(2)}");Everything here works identically with Ahtola.AhtolaConnection /
AhtolaCommand / AhtolaTransaction if you prefer the native types.
PRAGMA journal_mode=mvcc plus BEGIN CONCURRENT lets multiple in-process
connections write to disjoint rows of the same local file without contending
on the classic single-writer lock:
using Ahtola.Data.Sqlite;
using var a = new SqliteConnection("Data Source=app.db");
using var b = new SqliteConnection("Data Source=app.db");
a.Open();
b.Open();
a.ExecuteNonQuery("PRAGMA journal_mode=mvcc");
a.ExecuteNonQuery("CREATE TABLE IF NOT EXISTS t(v INTEGER)");
// Two writers, two disjoint rows: both commit without contending on a lock.
a.ExecuteNonQuery("BEGIN CONCURRENT");
b.ExecuteNonQuery("BEGIN CONCURRENT");
a.ExecuteNonQuery("INSERT INTO t VALUES (10)");
b.ExecuteNonQuery("INSERT INTO t VALUES (20)");
a.ExecuteNonQuery("COMMIT");
b.ExecuteNonQuery("COMMIT");Notes and limits (see docs/mvcc-port-contract.md
for the full port contract):
PRAGMA journal_mode=mvccgates everything. Without it,BEGIN CONCURRENTfails immediately with "Concurrent transaction mode is only supported when MVCC is enabled" — it never silently falls back to a classic transaction. Once one connection enables MVCC on a database path, peers opened against the same path observejournal_modeasmvcctoo (one shared version store per path, keyed by canonical physical path).- Same-row writes still conflict. Two
BEGIN CONCURRENTtransactions that write the same row, or a stale snapshot that tries to commit past a peer's commit, fail with the ordinary busy exception (message containsdatabase is locked) at whichever statement/COMMITfirst detects the conflict — catch it the same way as any other busy error (see Error handling patterns). This is a must roll back state: executeROLLBACK(or calltransaction.Rollback()if you usedBeginTransaction/BeginTransaction(deferred: true)instead of rawBEGIN CONCURRENTtext) before reusing the connection. - Savepoints work as expected inside a concurrent transaction
(
DbTransaction.Save/Release/Rollback(savepointName), or rawSAVEPOINT/RELEASE/ROLLBACK TOtext), including rolling back version-store inserts made after the savepoint. - Main database only. A concurrent transaction rejects writes to an
ATTACHed or temp database ("only supports mutations on the main database"); attach a second file if you need it, but keep writes tomainwhileBEGIN CONCURRENTis open. REINDEXis rejected while MVCC is enabled.PRAGMA wal_checkpoint(...)runs a dedicated MVCC checkpoint state machine; it reports busy (no truncate) while concurrent transactions are still open, and garbage-collects the version store once they've all finished.- This is process-local, not cross-process or cross-machine. It gives you concurrent writers within one process (e.g. a pooled multi-threaded service or a background job set). It is unrelated to the multi-engine file-sharing support described in the README's Multi-engine files limit, and it is a different mechanism from the Turso Cloud replica sync described next.
Encryption uses Turso-compatible raw keys:
| Layer | Role | Extension point |
|---|---|---|
| Built-in AHTLA page crypto | On-disk AES-GCM or AEGIS pages (AHTLA header) |
AhtolaEncryptionOptions / Encryption Cipher + Encryption Key |
| External page codec | Entirely different page layout | IPageCodec (mutually exclusive with built-in encryption; see samples/PageCodecExamples) |
using Ahtola.Data.Sqlite;
using var connection = new SqliteConnection(
"Data Source=app.db;Local Provider=Managed;"
+ "Encryption Cipher=Aes256Gcm;"
+ "Encryption Key=000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F");
connection.Open();Like Turso's EncryptionKey::from_hex_string, Ahtola accepts only exact
16-byte or 32-byte hexadecimal keys; select a cipher that requires that key
size. It performs no password-based key derivation. The System.Data.SQLite
Password keyword instead selects the legacy page codecs in
Ahtola.Data.Sqlite.Codecs (System.Data.SQLite RC4 and wxSQLite3 AES-128-CBC),
kept to open existing files; SqliteConnection.ChangePageCodec re-keys a file.
SEE/SQLCipher files require a dedicated IPageCodec or export/recreation. Wrong
or missing keys fail with SqliteException code 26 whose message starts with
file is encrypted or is not a database.
using Microsoft.EntityFrameworkCore;
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseAhtola("Data Source=app.db")
.Options;
using var context = new AppDbContext(options);
context.Database.EnsureCreated(); // or context.Database.Migrate();UseAhtola overloads accept a connection string, an already-constructed
Ahtola.Data.Sqlite.SqliteConnection (optionally with contextOwnsConnection),
and an optional Action<SqliteDbContextOptionsBuilder> — the same shape as
UseSqlite, since the provider is layered directly on top of
Microsoft.EntityFrameworkCore.Sqlite.Core (9.x on net8.0/net9.0, 10.x on
net10.0; the pinned range is enforced at load time and throws
NotSupportedException on a mismatched EF Core version).
UseAhtola supports local files, direct remote Turso Cloud/Hrana
connections, and embedded replicas — it classifies the Data Source the
same way the ADO.NET facades do and wires up the matching services:
// Local file or :memory: (native or Local Provider=Managed)
.UseAhtola("Data Source=app.db")
// Direct remote Turso Cloud/Hrana — every read and write is a round trip
.UseAhtola("Data Source=turso://my-db.turso.io;Auth Token=" + authToken)
// Embedded replica — queries run against a local synced copy
.UseAhtola("Data Source=turso://my-db.turso.io;Auth Token=" + authToken + ";Replica Path=replica.db")Migrations, EnsureCreated/EnsureDeleted, querying, SaveChanges
(including INSERT ... RETURNING), and explicit transactions all work over a
direct remote or embedded-replica connection. A direct remote connection
cannot enforce Mode=ReadOnly (there is no local file to guard) and its
EnsureDeleted/Delete is always a no-op — the remote database is
provisioned and owned independently (e.g. via the Turso CLI/API), so
UseAhtola never attempts to drop it.
The managed migration generator supports transactional table rebuilds for
standalone unique/check constraints, dependency-bearing table and column
renames, filtered indexes, and true STORED computed columns. Rebuilds preserve
data, indexes, triggers, foreign keys, defaults, collations, and generated
expressions, including attached-schema tables. Idempotent scripts are emitted
only for operations that can be guarded safely with SQLite SQL; a standalone
script containing a rebuild operation is rejected before SQL is returned
rather than emitting a script that could rerun destructively. Normal
Database.Migrate() execution performs rebuilds inside the migration
transaction.
Because a remote/replica connection cannot register client-side SQL
functions or collations, a few LINQ constructs that translate to those on
local connections are rejected with a NotSupportedException at query
translation time (before any request reaches the server) instead of
failing late with an opaque "no such function" error:
Regex.IsMatch(...)(translates to a client-registeredregexpfunction).- Decimal arithmetic (
+ - * / %, unary-) and decimalSum/Average/Max/Min. OrderBy/OrderByDescendingon adecimalvalue (needs theEF_DECIMALcollation).
Everything else — standard SQL, JSON1 (including primitive collections),
equality/inequality on decimals, and simply storing/reading decimal values —
keeps working normally. No automatic EnableRetryOnFailure-style execution
strategy is registered for remote connections: a transient remote failure
(e.g. SQLITE_BUSY) propagates to the caller as-is rather than being
silently retried, since safely retrying inside a user-managed transaction is
not implemented yet. Use SqliteRemoteExceptionClassifier.IsTransient(...)
if you want to detect and retry transient failures yourself.
AhtolaConnection and Ahtola.Data.Sqlite.SqliteConnection open a connection
straight against Turso Cloud (turso:// / libsql:// normalize to HTTPS) with
no local file at all — every read and write is a remote round trip.
using Ahtola;
using var cloud = new AhtolaConnection(
"Data Source=libsql://my-db.turso.io;Auth Token=" + authToken);
cloud.Open();
using var command = cloud.CreateCommand();
command.CommandText = "SELECT 1";
var result = command.ExecuteScalar();
cloud.Close();The SQLite-compatible facade maps remote failures to SqliteRemoteException;
use SqliteRemoteExceptionClassifier.IsTransient(...) for explicit retry
policy. The native facade exposes AhtolaException /
AhtolaRemoteSqlException. Connection strings redact the bearer token
(Data Source=...;Auth Token=***) — it is never exposed in diagnostics. Never
hardcode the token; read it from a secret manager or environment variable.
To rotate credentials without reopening, set AuthTokenProvider (on
AhtolaConnection, SqliteConnection, or AhtolaReplicaOptions) before
opening — the counterpart of Turso's serverless with_auth_token_fn. It takes
precedence over Auth Token and is awaited before every HTTP request (bootstrap,
pull, lazy page fetch, push, and each Hrana pipeline/cursor request) and before
every Hrana WebSocket (re)connect handshake; a null or blank result sends no
token. The HTTPS-only credential policy applies as if a token were configured.
using var cloud = new AhtolaConnection("Data Source=libsql://my-db.turso.io")
{
AuthTokenProvider = async cancellationToken => await tokenCache.GetAsync(cancellationToken),
};Batch and step replication_index values are treated as opaque, per the
pinned Hrana spec (turso-src/serverless/PROTOCOL.md §7.2.6): the highest
non-negative integer watermark a libSQL server reports is still echoed on later
batches for read-your-writes against replica-backed servers, but any other
value is ignored instead of failing the request.
Expired Hrana streams are retried once automatically only for stateless commands. An active transaction is never replayed: it becomes unusable and commit/rollback preserves the original stream failure instead of masking it with a local "database is closed" error.
Direct remote connections use Hrana HTTP v3. Pipeline operations are sent to
/v3/pipeline, while ExecuteReader/ExecuteReaderAsync use /v3/cursor and
parse the newline-delimited cursor response incrementally; rows are available
to the DbDataReader without buffering the complete response. The cursor's
baton follows the same transaction and Read Your Writes lifetime as pipeline
requests, and stateless cursors are closed explicitly after their terminating
frame.
For an unversioned database URL, a 404 Not Found from the first stateless v3
request selects Hrana v2 for that connection and retries through
/v2/pipeline; readers are buffered on that compatibility path because v2 has
no cursor endpoint. A URL that explicitly ends in /v2/pipeline or a v3
endpoint is pinned to that version and is never downgraded. No fallback is
attempted after a baton has been issued, so an expired or invalid live session
cannot be mistaken for protocol negotiation.
A ws:// or wss:// data source opens a persistent Hrana WebSocket
connection instead of the stateless HTTP pipeline. http, https, libsql
and turso URLs are unaffected and keep using /v3/pipeline + /v3/cursor
exactly as described above; the transport is chosen once, when the connection is
opened, and never silently downgrades from WebSocket to HTTP.
using var cloud = new SqliteConnection(
"Data Source=wss://my-db.turso.io;Auth Token=" + token);
cloud.Open();Target server. This transport implements the authoritative libSQL/sqld Hrana
WebSocket protocol (docs/HRANA_{1,2,3}_SPEC.md in tursodatabase/libsql).
The Turso engine pinned by this repository has no native Hrana WebSocket
server — it maps ws/wss onto its HTTP pipeline endpoint — so point
ws/wss connection strings at a legacy libSQL/sqld deployment (including
Turso Cloud) rather than at the new engine.
Negotiation. The upgrade happens on the URL's own path (there is no /v2 or
/v3 suffix for WebSocket) and offers the JSON subprotocols hrana3, hrana2,
hrana1. An empty/absent Sec-WebSocket-Protocol response is treated as
Hrana 1, per the spec. hrana3-protobuf and any other unknown value are
rejected and the socket is closed: this client speaks only the JSON encoding.
Authentication travels in the first hello message as a JWT (a WebSocket has no
per-message headers).
Features by negotiated version.
| Feature | v1 | v2 | v3 |
|---|---|---|---|
open_stream / close_stream / execute / batch |
yes | yes | yes |
store_sql / close_sql / sequence / describe |
no | yes | yes |
open_cursor / fetch_cursor / close_cursor / get_autocommit |
no | no | yes |
ok / error / not / and / or batch conditions |
yes | yes | yes |
is_autocommit batch condition |
no | no | yes |
ExecuteReader streaming |
buffered execute |
buffered execute |
paged cursor |
Version checks run before anything is written to the socket and before a
stream is opened, so a request the negotiated version cannot serve never leaves
a half-created stream_id behind. The is_autocommit check walks the whole
condition tree, so it also catches the common not(is_autocommit) shape.
Fail-closed boundaries.
- Remote encryption is refused over
ws/wss. The officialhellomessage has no encryption-key field, so thex-turso-encryption-keyvalue the HTTP pipeline sends as a header cannot be conveyed. Use anhttpsURL instead. - Protocol violations terminate the connection. An unknown message
discriminator, a response for an unknown request id, a binary frame on a JSON
subprotocol, an unparsable message, or a message larger than
Ws Max Message Bytescloses the socket and fails every pending request. - Malformed payloads are protocol violations, not data. Every response is
checked against the contract for the request it answers —
resultforexecute/batch/describe,is_autocommitforget_autocommit,entries+doneforfetch_cursor,errorforresponse_error— before the waiting caller sees it. A missing or mistyped mandatory field, an out-of-range integer, a row whose width does not matchcols, or an unknown nested discriminator (cursor entry type, value type) terminates the generation instead of silently becomingfalse,[]or a skipped row. - Nothing is ever replayed. Streams, cursors and stored SQL die with their connection. If the connection is lost while a session (transaction or cursor) is open, the ADO.NET remote session is invalidated and the failure surfaces to the caller; a later operation may open a brand-new connection and stream, but the client never re-sends an in-flight statement.
- No transport downgrade. A
ws/wssconnection never falls back to HTTP. - TLS and credentials follow the HTTP policy.
Auth Tokenrequireswss(or a loopback host), the upgrade never follows redirects, and certificate validation is left to the platform — there is no certificate bypass.
Concurrency. One serialized send path and one continuous receive loop own the
socket, so there is never a concurrent SendAsync or ReceiveAsync; keeping a
receive outstanding is also what lets the runtime process keep-alive pongs.
CloseOutputAsync is itself a send, so it is only issued once the send loop has
been observed to stop — against a wedged peer the close frame is skipped and the
socket is aborted instead. Requests are correlated by request_id, so the server
may answer out of order across streams while per-stream ordering is preserved.
Cancellation. Cancelling a command (or hitting Command Timeout) abandons
only that caller's wait — the socket stays healthy and a late response for the
abandoned id is discarded. Two details make that safe:
- Requests that mint a server-side handle (
open_stream,open_cursor) keep their correlation slot after the caller walks away. If the server answers late, the handle is closed immediately; if it cannot be closed, the connection is retired so nothing leaks for the rest of its life. - The discard list is scoped to the connection and never evicted while the connection lives, because an abandoned request can be answered arbitrarily late and forgetting it first would turn a valid reply into a spurious "unknown request id" abort. If more than 65 536 requests are abandoned unanswered, the connection is retired rather than start forgetting.
Liveness. On .NET 9+ the runtime enforces Ws Keepalive Timeout with real ping/pong,
which a busy server keeps answering. On net8.0 ClientWebSocket has no pong timeout, so
half-open detection is opt-in through Ws Half Open Timeout: a watchdog aborts the
connection when nothing at all has arrived for that budget and a request has been
outstanding that long. It sends no frames of its own. It is off by default on purpose —
without ping/pong, "the server has sent nothing" cannot distinguish a dead socket from one
running a slow statement, so any budget also caps how long one request may take. Set it
above the longest statement the workload issues, or leave it disabled and rely on
Command Timeout.
Disposal. Dispose() and DisposeAsync() converge on one idempotent
disposal. The graceful phase (drain, close_stream, close frame) is bounded by
Ws Close Timeout; once that budget is spent the socket is aborted, and
disposal still waits for both loops to terminate and the socket to be disposed.
A synchronous Dispose() therefore never returns while the socket is live.
Add Replica Path=<file> to get a managed embedded replica: a local
SQLite file that bootstraps from Turso Cloud, serves reads/writes locally (no
network round trip per statement), and pushes/pulls changes on an explicit or
interval-based sync. Local writes are captured into a durable on-disk change
journal (<path>.ahtola-replica-journal alongside the database file) as soon
as they commit, and are replayed to the remote on the next sync — the replica
does not need to be online to accept writes.
using Ahtola;
using var replica = new AhtolaConnection(
"Data Source=libsql://my-db.turso.io;Auth Token=" + authToken + ";Replica Path=./replica.db");
replica.Open();
// Reads and writes hit the local file directly.
replica.ExecuteNonQuery("INSERT INTO events(name) VALUES ('local-write')");
// Explicit sync: pushes the local change journal, then pulls remote changes.
AhtolaSyncResult result = replica.Sync(new AhtolaSyncOptions());
Console.WriteLine(result.Outcome); // UpToDate | RemoteChangesApplied
Console.WriteLine(result.Statistics.LastPush);
Console.WriteLine(result.Statistics.LastPull);
// Async overloads are also available:
await replica.SyncAsync(cancellationToken);Sync Interval=<seconds> (positive integer, connection-string only) starts a
background synchronization loop as soon as the connection opens, instead of
(or in addition to) calling Sync/SyncAsync yourself:
using var replica = new AhtolaConnection(
"Data Source=libsql://my-db.turso.io;Auth Token=" + authToken +
";Replica Path=./replica.db;Sync Interval=30");Each tick runs a full push-then-pull Sync by default. Turso's .NET binding
instead runs a pull-only loop; add Automatic Sync Mode=PullOnly (or set
AhtolaReplicaOptions.AutomaticSyncMode) to match it and push explicitly. The
default is unchanged. Background failures are observable through
AutomaticSyncStatus (Stopped/Waiting/Running/Retrying/Faulted, with
LastAttempt, LastSuccess, LastPullAppliedChanges, LastException,
NextAttempt) and the AutomaticSyncStatusChanged event, raised on a
thread-pool thread in publication order. Transient transport failures are
retried up to three attempts per tick; anything else (for example a push
conflict) moves the loop to Faulted, stops it, and is rethrown by Close.
The explicit operations mirror Turso's TursoSyncDatabase and exist on both
AhtolaConnection and SqliteConnection (sync and async):
AhtolaSyncResult pulled = replica.Pull(); // pull + apply only; pending local changes stay journaled
AhtolaSyncResult pushed = replica.Push(); // push every change pending at the call; no pull
replica.Checkpoint(); // fold the local WAL into the database file
AhtolaSyncStatistics stats = replica.GetSyncStatistics();
Console.WriteLine(stats.CdcOperations); // local changes still waiting to be pushed
Console.WriteLine(stats.MainWalSize); // current -wal size; RevertWalSize for the recovery WALPush sends several batches when Push Operations Threshold caps them and
reports the pushed count in Statistics.CdcOperations. Checkpoint never
discards unpushed changes, and fails closed while a checkpoint-recovery bundle,
unknown push outcome, or unresolved conflict is pending, or while a partial
replica is still lazily materializing pages (Sync resolves the first two;
Pull fails closed on them too). GetSyncStatistics is a pure local read;
LastPull, LastPush and the pull-updates byte counters are tracked per replica
file within the current process and reset when every connection to it closes.
Bootstrap is a validated raw-page snapshot. For protocol-2 databases,
incremental pull is Turso's MVCC logical stream: Ahtola validates the complete
lml3 response, replays the transaction atomically, filters this client's own
transactions, and only then advances the opaque server revision. If retained
logical history is unavailable, a complete Pages + ReplaceBase response is
installed atomically. Pending local row changes are preserved across logical
pulls; unsafe residual deletes or schema changes fail closed until they have
been pushed. Protocol-1 databases keep the page-incremental path.
The pure-managed provider supports AhtolaPartialBootstrapOptions.Prefix(...)
and AhtolaPartialBootstrapOptions.QueryPages(...) as the initial page
selector. It publishes the selected complete 4 KiB pages, records the missing
ranges in an integrity-protected durable sidecar, and fetches a missing page
from the pinned bootstrap revision before the pager can observe its bytes.
Concurrent faults are coalesced, SegmentSize controls the fetch segment, and
Prefetch opts into fetching the rest of that segment. The bootstrap marker,
metadata, and page-state sidecar are durable before the sparse database becomes
visible. A physical partial replica has one process-exclusive materializer, and
write-ahead mutation intents make an interrupted local page write recoverable
without treating sparse zeroes as data. Before an ordinary sync advances the
revision, Ahtola pushes tracked local changes, completes the pinned image, and
transitions back to the normal full-file publication path.
QueryPages(...) sends the query as Turso's server_query_selector
(PullUpdatesReqProtoBody tag 7) on the single bootstrap request only, never
together with a page selector and never chunked — the server, not the client,
decides which pages the query touches, so PullBytesThreshold is rejected with
it. The returned page set may be unordered and non-contiguous, db_size still
describes the whole database, and page 1 (the SQLite header page) is mandatory;
duplicate, out-of-range, wrong-sized, or header-less responses fail closed
without publishing anything. After bootstrap the query is never persisted or
resent: missing pages fault by page id against the pinned revision.
Two caveats. The remote must implement query selection. Turso's vendored
local dev server ignores server_query_selector by design, so a query
bootstrap against it silently degrades to a full-database response; only a
server that honours tag 7 produces a genuinely partial image. Sidecar size
scales with scatter. The page-state sidecar stores materialized pages as a
run list, so a worst-case scattered selection (for example every other page)
degenerates to one (start, count) pair per page. That is bounded and durable
but noticeably larger than a prefix image's single run; a bitmap-backed sidecar
would bound it better and is not implemented.
Embedded replicas support DbBatch through both facades. Enum parameters bind
as their underlying SQLite integer value. Extra parameters that are not
referenced by the SQL are ignored; every referenced slot still requires a
value.
Server-side rejection of a replayed local change surfaces as
Ahtola.AhtolaReplicaConflictException: ConflictKind
(RowWrite/SchemaChange/Unknown), RemoteErrorCode, and
LocalChangeSequence for programmatic handling. Synchronization never
automatically rebases or merges. The journal is retained on conflict, and the
application can inspect it and explicitly choose
PullAndRebaseEligible or DiscardUnresolvedChanges through
ResolveReplicaConflictAsync.
Each embedded replica keeps its own local change journal, so "concurrent writes" here means multiple independent replicas (processes/machines) writing locally and periodically reconciling through the server — not in-process MVCC:
- Writes against the same replica connection are ordinary local SQLite
transactions (
BeginTransaction(), orPRAGMA journal_mode=mvcc+BEGIN CONCURRENTif you also want process-local concurrent writers against that one replica file, exactly as in the local-file MVCC section). - Writes made between two different replicas (or a replica and the
primary) are only reconciled when each side calls
Sync/SyncAsync(or itsSync Intervalbackground loop). Synchronization never rebases or merges automatically: if the server rejects a replayed change, catchAhtolaReplicaConflictException, inspect the durable conflict, and select an explicit resolution.
try
{
connection.ExecuteNonQuery("INSERT INTO t VALUES (1)");
}
catch (Exception ex) when (ex.Message.Contains("database is locked"))
{
// Local busy/lock conflict — classic single-writer contention, or a
// same-row MVCC conflict. Retry with backoff, or roll back an open
// transaction before retrying.
}
catch (Ahtola.AhtolaReplicaConflictException ex)
{
Console.WriteLine($"{ex.ConflictKind}: {ex.Message} (remote code {ex.RemoteErrorCode})");
// Journal is retained; decide whether to discard or replay the change.
}
catch (Ahtola.Data.Sqlite.SqliteRemoteException ex)
{
Console.WriteLine(ex.Classification);
// Retry only when your operation is safe to repeat.
}- Local busy/lock conflicts (classic single-writer contention, another
process's lock, or a same-row MVCC conflict) surface as
SqliteExceptionwithSqliteErrorCode5 (database is locked), soDbException-based retry loops work;Default Timeout/Command Timeouton the connection string (andPRAGMA busy_timeout) control how long a statement waits before giving up. - The SQLite-compatible facade maps Turso/Hrana failures to
SqliteRemoteExceptionwith transient/permanent classification and optional HTTP status. The native facade exposesAhtolaException/AhtolaRemoteSqlException. - Embedded-replica sync conflicts are
Ahtola.AhtolaReplicaConflictException(see above) and exposeConflictKind,RemoteErrorCode, andLocalChangeSequencefor programmatic handling.
See the top-level README's "Important limits" section for what Ahtola does not yet implement, and docs/powershell-module.md if you'd rather drive Ahtola from PowerShell instead of C#.