sqlite-ursula
SQLite databases replicated through Ursula streams, for the stdlib sqlite3 module of CPython 3.11 or later. The Python peer of the npm package @tonbo/sqlite-ursula.
import sqlite_ursula
db = sqlite_ursula.open("https://streams.example.com/acme/notes")
db.execute("CREATE TABLE IF NOT EXISTS notes(id INTEGER PRIMARY KEY, body TEXT)")
db.execute("INSERT INTO notes(body) VALUES (?)", ("hello",))
db.commit() # committed on this host
db.synced() # this database's commits are in the stream, and this process still owns it
send(reply)
The one rule: before anything outside the process acts on what you wrote or read (a response, a queue acknowledgement, a call to another service), call db.synced(), await db.asynced(), or wrap the work in with gate(db) or async with agate(db).
A commit is local. The extension appends it to a log it owns and replicates the log to the stream in the background, on threads of its own. synced() returns once the stream holds every commit the process made on that database before the call, and a round trip that started after the call found this process still the owner. Scripts call it before they exit.
Hosts
The extension loads into the sqlite3 module's own SQLite and becomes its default VFS, so every sqlite3.connect(path) of a bound database in the process replicates, a framework's included. That takes a Python whose sqlite3 loads extensions and runs a SQLite the extension supports. The extension's refusal of an older one names the version it needs.
| Python | hosts the extension |
|---|---|
uv-managed CPython (uv python install), Homebrew, conda-forge |
yes |
python:3.x-alpine (Alpine 3.23 or newer) |
yes, on x64 |
python:3.x-slim, Debian and Ubuntu apt, RHEL and UBI, Amazon Linux, Colab |
no: SQLite older than the extension supports |
the python.org macOS installer, Apple's /usr/bin/python3, pyenv's default build |
no: built without loadable extensions |
| Windows | no: use WSL |
On a Python that cannot host it, open() raises UrsulaSetupError with kind host_unsupported, whose fix says what to do for that reason: a Python that can host it, WSL on Windows, or SQLITE_URSULA_VFS where the install carries no library. python -m sqlite_ursula runs the same check and prints what it found.
Docker
Switch the base image in one line:
-FROM python:3.13-slim
+FROM python:3.13-alpine
recipes/Dockerfile is a complete example. To keep a Debian base, on arm64, or with the langgraph extra, which does not install on Alpine, run a uv-managed CPython instead, as recipes/Dockerfile.uv does.
On macOS, use uv or Homebrew.
open
open(url, *, token, dir, read_only, recreate, max_pending_bytes, timeout, busy_timeout, **connect_kwargs) returns an UrsulaConnection, a sqlite3.Connection subclass, so every library that takes one takes it.
- The first open of a stream in the process binds its cache in
dir(by default<user cache>/sqlite-ursula/0.8), catches it up and claims the stream: the last open that reaches the stream owns it. Later opens in the process return another connection on the same binding, with no network. Concurrent opens from any thread share one. tokenis the bearer token requests carry, or a token provider for tokens that expire. One token per process:set_token(token)replaces it, andset_token(None)goes back toURSULA_VFS_TOKEN_FILE.- The open gives up after
timeoutseconds (30 by default) withUrsulaUnavailableError, whose kind is the last condition it met. Ctrl-C cancels it too. busy_timeoutissqlite3.connect'stimeout, 5 seconds by default, since a binding's connections share its files. The other keywords go tosqlite3.connect.check_same_threadisFalseunless given.autocommit=False(Python 3.12 or newer) is refused withinvalid_argument. In that mode the connection is always in a transaction: CPython's ownROLLBACKafter a refusedCOMMITwould hide the refusal, and a connection kept open would hold a read snapshot that stops checkpoints. Use the default transaction control,isolation_level=Noneorautocommit=True.read_only=Trueclaims nothing and never fences the owner, and writes fail withSQLITE_READONLY.db.close()closes the connection. Once a binding's last connection closes, replication drains in the background and the binding retires. Keepdbopen while a framework usesdb.path.
UrsulaConnection adds name (the stream's path, such as /acme/notes), path (the local database file, which frameworks open by path), synced(timeout=None), asynced(timeout=None) (asyncio), status() and on_reset(callback) / off_reset(callback).
db = sqlite_ursula.open(url)
other = sqlite3.connect(db.path) # a framework's own connection: it replicates too
synced() covers every connection of the process on this database (db.path), a framework's included, and no other: gate each database an output depends on. A negative or non-numeric timeout is invalid_argument. It raises TimeoutError after timeout seconds while the commits keep replicating, and UrsulaResetError on a reset. Network failures never raise from it: it waits. Ctrl-C interrupts it.
import_sqlite(url, file, *, token, timeout) makes a plain SQLite file with 4096-byte pages the first snapshot of a new stream, then open() it as any other. Its token is a string: the import blocks its thread, so nothing could refresh it meanwhile.
Token providers
For short-lived tokens, pass a provider as token instead of running a refresher: a synchronous callable that takes the keywords previous and timeout and returns a BearerToken(token, expires_at). expires_at is seconds since the epoch, as time.time() and a JWT's exp count them, or a timezone-aware datetime.
import json
import time
import urllib.request
import sqlite_ursula
from sqlite_ursula import BearerToken
def token(*, previous: str | None, timeout: float) -> BearerToken:
with urllib.request.urlopen("https://auth.example.com/token", timeout=timeout) as r:
answer = json.load(r)
return BearerToken(answer["access_token"], time.time() + answer["expires_in"])
db = sqlite_ursula.open("https://ursula.example.com/acme/notes", token=token)
- The package calls the provider before an open's first request when it holds no valid token, again after 70 to 80% of each token's lifetime, and again at once when a request was refused with
401. Concurrent opens share one call, and a later open gets a valid token held at once. - Each call runs on a daemon thread of the package,
sqlite-ursula-token, never on your event loop:aopen()takes the same provider and waits for its token on the loop. So the provider is synchronous: an async function isinvalid_argumentat the open. - Each call is handed
previous, the token the provider last handed out, exactly as it handed it out, when the package will not take it again: the current one at a scheduled refresh, or the one the server refused after a401. It isNonebefore the first call and after the provider stopped, except that a token an open's bind was refused under, or one a database still bound and blocked was refused under, stayspreviousacross a stop, until the provider hands out another one or a string token orset_token()stops the provider. Whenpreviousis set, hand out a different token, and refill a cache that holds it. The extension takes an equal token as no change, so an answer equal toprevious, compared without surrounding whitespace, counts as a failed call. A failed call is retried with backoff, and by the expiry ofpreviousat the latest, so a cache that hands its token back until it expires is asked again as it turns over. From that expiry on only the failures since it count, so the backoff starts again at 1 s. When the provider hands back the token a database's replication was refused under, the package takes it again once each database that reported the refusal is closed and retired, with every commit acknowledged, or no longer blocked. A retry already scheduled then runs at once. A token that never changes belongs in a stringtokenor inURSULA_VFS_TOKEN_FILE, not in a provider. timeoutis the 30 s the package waits for the answer. A call that has not returned by then counts as failed, and a call cut off by a stop (a string token,set_token(), the last database retired) has its answer discarded, or never reaches the provider when it had not started. Either is left to finish on its thread, and the next call runs on a thread of its own.- An exception whose
actionis"fix"fails for good, and the package calls the provider again only for the next open. An open waiting for the token raises it when it is anUrsulaError, such asUrsulaUnavailableError("unauthorized: the key was revoked", "unauthorized", "fix"), or the package's class it derives from, with the same message, kind and action and it as the__cause__, when it is a subclass of your own. Any other such exception, say a library's own, is the__cause__of anUrsulaUnavailableErrorunauthorized(actionfix). Other exceptions are retried with backoff, from 1 s up to 30 s, while the token held keeps serving. An open whosetimeoutpasses while it waits raisesUrsulaUnavailableErrorunavailable(actionretry), whose__cause__is the provider's last error, or none when no call failed. A token that is not printable ASCII without spaces, a naivedatetime, or anexpires_atthat is not seconds since the epoch fails the open withinvalid_argument, and so does a provider that does not take the keywordspreviousandtimeout, before any call. - The token is one per process, whichever provider minted it. An open with another provider only names the one the next call runs, and shares the token held and the call in flight. An open with a string token, or
set_token(), stops the provider. So does the retirement of the last database the process has bound, closed with every commit acknowledged, while no open with a provider is under way. A forked child starts with no provider. - No token is logged, and the
reprof aBearerTokenleaves it out. The package keeps a provider's tokens out of the locals of the frames its errors pass through, which error reporters may record.
asyncio
aopen() takes the arguments open() takes and returns the same UrsulaConnection, and db.asynced(timeout=None) is synced(). Both wait on the event loop instead of blocking it, and neither uses the loop's default executor.
db = await sqlite_ursula.aopen(url)
db.execute("INSERT INTO notes(body) VALUES (?)", ("hello",))
db.commit()
await db.asynced()
send(reply)
Cancelling asynced(), as asyncio.timeout around it does, ends the wait while the commits keep replicating. Cancelling aopen() cancels the bind, so it never goes on to claim the stream. Statements still run on the calling thread, as they do with sqlite3.
Gates
gate(*dbs, timeout=None) and agate(*dbs, timeout=None) wrap a block of work: leaving the block waits for a synced() call made after it ended, on every database given, before the result or the block's error goes on.
with sqlite_ursula.gate(db):
reply = handle(request)
send(reply)
async with sqlite_ursula.agate(db, other):
reply = await handle(request)
await send(reply)
- A block that only read waits too, since it may have read another connection's pending commit.
- An
UrsulaErrorthe block raises passes at once, as the block raised it, also when a library wrapped it (as__cause__, as SQLAlchemy's.orig, or in anExceptionGroup). So does a rawsqlite3error of a framework's own connection that the extension classifies, also wrapped. With one database, a bare raw error that the extension names a refused write or a reset raises as thatUrsulaError, which is asqlite3.OperationalErrortoo. - Every other error waits as a result does, then passes. Ctrl-C and cancellations pass at once.
- The wait covers every database at once. A reset during it raises the
UrsulaResetErrorinstead of the outcome, and a failure on one database ends the wait on the others. timeoutbounds the wait, not the block, and raisesTimeoutError.
Effects inside the block, such as a call to another service, are not gated: call db.synced() before them.
LangGraph
sqlite_ursula.langgraph holds LangGraph's SQLite checkpointers and stores on a replicated database. Install it with the langgraph extra, which pins langgraph-checkpoint-sqlite to the releases CI tests (3.0.3 up to 3.2):
pip install 'sqlite-ursula[langgraph]'
import sqlite_ursula
from sqlite_ursula.langgraph import UrsulaSaver
db = sqlite_ursula.open(url)
with UrsulaSaver(db) as saver:
graph = builder.compile(checkpointer=saver)
reply = graph.invoke(inputs, {"configurable": {"thread_id": "1"}})
send(reply) # every checkpoint of the run is in the stream
| class | LangGraph's | closed by |
|---|---|---|
UrsulaSaver(db) |
SqliteSaver |
close() or with |
await AsyncUrsulaSaver.open(db) |
AsyncSqliteSaver |
aclose() or async with |
UrsulaStore(db) |
SqliteStore |
close() or with |
await AsyncUrsulaStore.open(db) |
AsyncSqliteStore |
aclose() or async with |
- Each class opens a connection of its own on
db's binding, the async ones through aiosqlite, and closing it closes that connection. LangGraph writes checkpoints from threads of its own while the graph's nodes run, so the application keepsdbfor its own statements. The savers' connections keep SQLite's default transaction control, which makes each checkpoint, each task's writes and each deleted thread one transaction, whateverdb's is. The stores' connections open no transactions of their own, sinceSqliteStoreruns its ownBEGINandCOMMIT. Each connection is bound asdbis: read-only whendbis, withdb's busy timeout, and its errors are the package's. - Every write returns once
db.synced()(orasynced()) returned after it: a checkpoint, a task's writes, a deleted thread, a store put or delete, a store get or search that refreshes TTLs (refresh_on_read), a TTL sweep. LangGraph waits for a run's checkpoint writes beforeinvokereturns, so the return is gated in every durability mode. Withdurability="sync", each step also waits for the one before it, so what a node does rests on acknowledged checkpoints. - Reads do not wait, and neither does the setup that creates the tables, which LangGraph runs before the first read: the next write's wait covers it.
AsyncUrsulaStorewaits inaputandadelete, not in the task that runs its batches, so a read never waits behind another call's write. Itsabatchandbatch, which take operations, are LangGraph's own: calldb.asynced()after them. stream()andastream()yield a step's chunks before that step's checkpoint is acknowledged, and only the end of the iteration is gated. Calldb.synced()orawait db.asynced()before acting on a chunk.- A write refused with
backlog_fullcommitted nothing: it waits forsynced()and runs again. That is safe because each of these writes is one transaction. AnAsyncUrsulaStoreread batched with a refused write runs again at once. LangGraph's setup commits its statements one by one, and a store's migrations are not safe to run twice, so there each refused statement waits and runs again by itself. In the async adapters that wait blocks aiosqlite's thread until the stream acknowledges the commits pending before it, even when the caller of the setup is cancelled. - A reset raises the
UrsulaResetError, from a write or a read. Keepdbopen while the graph runs. sqlite-vec, whichlanggraph-checkpoint-sqliterequires, has no wheel for musl, so the extra does not install on Alpine. Use a Debian base with a uv-managed CPython, asrecipes/Dockerfile.uvdoes.
Errors
Four classes with kind and action (retry, reopen, stop, fix), whose cause (__cause__) is the SQLite error they replace. Branch on action. The extension says which failure is which (ursula_classify).
| class | raised by | kinds |
|---|---|---|
UrsulaWriteRefusedError |
a write or COMMIT, nothing committed, and a gate for a framework connection's refusal | backlog_full (retry after synced()), unsupported, too_large |
UrsulaResetError |
every statement after a reset, synced(), asynced(), a gate, on_reset callbacks |
fenced (stop), stale, rewound, deleted, local_damage, stream_state, internal |
UrsulaUnavailableError |
open() or aopen() past its timeout |
the last condition: unreachable, unavailable, rate_limited, unauthorized, payment_required, forbidden, or cancelled |
UrsulaSetupError |
open(), aopen(), import_sqlite(), the calls the package guards in a forked child (Forks) |
host_unsupported, forked, invalid_url, invalid_argument, not_found, in_use, bound_elsewhere, local_setup, and the rest of the extension's |
UrsulaWriteRefusedError and UrsulaResetError are also sqlite3.OperationalError, as the error they replace was, so existing except sqlite3.OperationalError clauses keep catching them, and sqlite_errorcode and sqlite_errorname are SQLite's. A transaction helper's ROLLBACK after one, or its COMMIT in a finally block, which finds no transaction since SQLite rolled it back, does not hide it.
A reset rejects the database's pending synced() calls first, then runs its on_reset callbacks once, on a thread of the package's.
Forks
Open databases after a fork, never before: the extension's replication does not survive fork(). In a child forked after its parent opened a database, open(), aopen(), import_sqlite() and set_token() raise forked, and so do the statements, transactions, synced(), asynced(), status() and on_reset() of an inherited UrsulaConnection, whose close() does nothing. Its other sqlite3.Connection methods (backup, blobopen, serialize and the like) and a framework's own connections on db.path are not the package's to guard: the extension fails their I/O on a bound file in the child. Importing the package before the fork is fine. Use gunicorn's post_fork hook, Celery's worker_process_init signal, or multiprocessing's spawn or forkserver start method.
The extension
The package loads the extension its wheel carries, or the one SQLITE_URSULA_VFS names, built from clients/sqlite-vfs. Wheels carry it for Linux x64 and arm64 with glibc 2.28 or newer, Linux x64 with musl, and macOS 11 or newer on arm64. Elsewhere the installer picks the wheel without a library, and open() raises host_unsupported unless SQLITE_URSULA_VFS names one. The design is docs/architecture/sqlite-vfs.md.
Metadata
Release files for sqlite-ursula 0.11.3
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Built distributions (wheels)
| File | Actions Reset | |||
|---|---|---|---|---|
| sqlite_ursula-0.11.3-py3-none-musllinux_1_2_x86_64.whl | Python 3 | none | Linux musl 1.2+ x86-64 | Details for sqlite_ursula-0.11.3-py3-none-musllinux_1_2_x86_64.whl |
| sqlite_ursula-0.11.3-py3-none-manylinux_2_28_x86_64.whl | Python 3 | none | Linux glibc 2.28+ x86-64 | Details for sqlite_ursula-0.11.3-py3-none-manylinux_2_28_x86_64.whl |
| sqlite_ursula-0.11.3-py3-none-manylinux_2_28_aarch64.whl | Python 3 | none | Linux glibc 2.28+ ARM64 | Details for sqlite_ursula-0.11.3-py3-none-manylinux_2_28_aarch64.whl |
| sqlite_ursula-0.11.3-py3-none-macosx_11_0_arm64.whl | Python 3 | none | macOS 11.0+ ARM64 | Details for sqlite_ursula-0.11.3-py3-none-macosx_11_0_arm64.whl |
| sqlite_ursula-0.11.3-py3-none-any.whl | Python 3 | none | any | Details for sqlite_ursula-0.11.3-py3-none-any.whl |
Total release size: 9.5 MB
Release files / sqlite_ursula-0.11.3-py3-none-musllinux_1_2_x86_64.whl
| Download URL | sqlite_ursula-0.11.3-py3-none-musllinux_1_2_x86_64.whl |
|---|---|
| Size | 2.6 MB |
| Tags | Linux musl 1.2+ x86-64 Python 3 |
|
SHA-256 checksum How to use checksums |
ec79accfd922b03086f31c5ef2accf532a0a33e0c7ff1acbc27e07e41ca29b0f
|
|
BLAKE2b-256 checksum How to use checksums |
810c6a84cc29301fdece283e46b204edf6c87a8b2cc0abb01a98c7307676afbe
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 1, 2026.
Transparency logRelease files / sqlite_ursula-0.11.3-py3-none-manylinux_2_28_x86_64.whl
| Download URL | sqlite_ursula-0.11.3-py3-none-manylinux_2_28_x86_64.whl |
|---|---|
| Size | 2.4 MB |
| Tags | Linux glibc 2.28+ x86-64 Python 3 |
|
SHA-256 checksum How to use checksums |
1ea3cf1b73239046fdc2313722280517163f59bbebb2c4163293fd268b10f8f5
|
|
BLAKE2b-256 checksum How to use checksums |
71171692769f8fb22f98cf9c5be0d56d2ec251cbb378459330f91c87f2a67715
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 1, 2026.
Transparency logRelease files / sqlite_ursula-0.11.3-py3-none-manylinux_2_28_aarch64.whl
| Download URL | sqlite_ursula-0.11.3-py3-none-manylinux_2_28_aarch64.whl |
|---|---|
| Size | 2.4 MB |
| Tags | Linux glibc 2.28+ ARM64 Python 3 |
|
SHA-256 checksum How to use checksums |
8b9cc29f0e81f2e037e73dd7f2bdcf5064864d4ca89ec783e1a459be8c9034ff
|
|
BLAKE2b-256 checksum How to use checksums |
e653cd1d4d8cafefc67c5b4348c1b5b4357848fb5704724975f524a97f7d8cbc
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 1, 2026.
Transparency logRelease files / sqlite_ursula-0.11.3-py3-none-macosx_11_0_arm64.whl
| Download URL | sqlite_ursula-0.11.3-py3-none-macosx_11_0_arm64.whl |
|---|---|
| Size | 2.1 MB |
| Tags | Python 3 macOS 11.0+ ARM64 |
|
SHA-256 checksum How to use checksums |
e095fefce9f171a64e8088ce80e55e35efad2f4ba464083b1e61a7f9da76a17f
|
|
BLAKE2b-256 checksum How to use checksums |
8ddbbfe3274a29bfb94d560ea3e6b31398ef259fcb67f7f1f9c4f354139a7bec
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 1, 2026.
Transparency logRelease files / sqlite_ursula-0.11.3-py3-none-any.whl
| Download URL | sqlite_ursula-0.11.3-py3-none-any.whl |
|---|---|
| Size | 59.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
eb47a29126a6b40589b7a5a70f50cb67f05028fea8d5725b04c4feb814be8f05
|
|
BLAKE2b-256 checksum How to use checksums |
8eaa1f78d1fbc9211caba1ff7a1987bbde8561351edb97b070a9f9ca5caf98c7
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 1, 2026.
Transparency logSecurity for sqlite-ursula 0.11.3
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 5 files in this release have been uploaded with provenance metadata.
Attestation for sqlite_ursula-0.11.3-py3-none-musllinux_1_2_x86_64.whl
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 1, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.