Local-first SQLite sync for Python apps.
Project description
tide 🌊
Local-first SQLite sync for Python apps.
tide gives a Python app a local SQLite store, a durable outbox of pending
writes, and bidirectional sync against an HTTP server — so your data is
available offline and converges automatically when the network comes back.
import asyncio
from tide import Tide
async def main():
async with Tide(
db_path="app.db",
endpoint="https://example.com/sync",
) as tide:
notes = tide.collection("notes")
await notes.put("n1", {"title": "Hello", "body": "World"})
print(await notes.get("n1"))
async with tide.sync_forever(interval=2.0):
async for change in notes.watch():
print(change.source, change.id, change.doc)
asyncio.run(main())
No server-side schema, no migrations to manage, no plumbing.
Install
pip install tide
# with the optional reference server:
pip install "tide[server]"
Python 3.10+.
Why tide
JavaScript has a thick local-first stack — Yjs, Automerge, Replicache, ElectricSQL, PowerSync, RxDB, Triplit. Python's local-first story is mostly low-level CRDT bindings with no integrated engine on top.
If you build with Streamlit, Marimo, Gradio, Textual, FastHTML, NiceGUI,
BeeWare/Briefcase, or any AI agent that needs durable local state that
reconciles with a cloud store, tide is the missing piece.
Features
- SQLite-backed local store with WAL mode and a durable outbox of pending writes.
- Last-writer-wins sync over a single HTTP endpoint, ordered by Lamport stamp — two offline clients converge deterministically.
- Reactive subscriptions —
async for change in collection.watch()— fire on both local and remote writes. - Offline-first: every write hits the local DB immediately; sync runs in the background with automatic retry.
- Pluggable transport for WebSocket, gRPC, or fake transports for tests.
- Reference server (
tide.server,[server]extra) — a Starlette + SQLite implementation under 200 lines you can deploy or port to Postgres. - Typed, tested, async-first:
py.typed, ships with pyright-clean source, 36+ tests including crash/retry/replay coverage.
Quickstart
Client
import asyncio
from tide import Tide
async def main():
async with Tide(db_path="app.db", endpoint="http://localhost:8000/sync") as tide:
todos = tide.collection("todos")
await todos.put("t1", {"text": "buy bread", "done": False})
await tide.sync_once() # one-shot push/pull
async with tide.sync_forever(interval=1.0): # background loop
await asyncio.sleep(10)
asyncio.run(main())
Reference server
# server.py
import uvicorn
from tide.server import create_app
app = create_app("server.db")
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000)
pip install "tide[server]" uvicorn
python server.py
Auth
headers accepts a mapping or a callable (sync or async) — useful for
short-lived tokens:
async def auth_headers():
return {"Authorization": f"Bearer {await get_token()}"}
Tide(db_path="app.db", endpoint="...", headers=auth_headers)
How it works
Every local write goes into two places atomically: the canonical document
row and a durable outbox. Each write carries a Lamport stamp —
(counter, node_id) — that totally orders writes across nodes without
relying on wall-clock time. The server accepts a write only if its stamp
beats the stamp it already has for that (collection, id). Plain
last-writer-wins, with deterministic, network-free tie-breaking.
A sync exchange is a single POST:
POST /sync
{ "node_id": "...", "cursor": "...", "ops": [...] }
→ { "cursor": "...", "changes": [...] }
See PROTOCOL.md for the full wire spec, idempotency guarantees, and failure semantics.
Comparison
tide |
automerge-py |
pycrdt¹ |
sqlite-sync |
|
|---|---|---|---|---|
| Turnkey sync engine | ✅ | ❌ (low-level primitives) | ❌ | ✅ (Rust extension) |
| Pure Python client | ✅ | ✅ | ❌ (Rust extension) | ❌ |
| Conflict model | LWW + Lamport | CRDT | CRDT | CRDT (SQLite rows) |
| Reference server | ✅ Starlette | ❌ | ❌ | ❌ |
| Pluggable transport | ✅ | ❌ | — | ❌ |
¹ Successor to y-py, which is no longer actively maintained.
Use automerge-py or pycrdt directly when you need true collaborative
editing (concurrent text/JSON merges). Use tide when you want
offline-first row sync without writing the plumbing yourself.
Non-goals (for v0.x)
- CRDT merges. v0 is row-level LWW. A separate
tide-crdtpackage will add Automerge / Yjs-backed merge semantics for documents that need it. - Schemas and validation. Documents are plain JSON-serializable dicts. Layer Pydantic or msgspec on top.
- Multi-master writes without a server. v0 needs a sync endpoint; P2P is a future addition.
- Encryption at rest and end-to-end encryption. Out of scope; bring your own.
Status
Alpha. The Python API and wire protocol may change in
backward-incompatible ways until 1.0. The store schema is versioned and
migrated forward.
License
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file tide-0.2.1.tar.gz.
File metadata
- Download URL: tide-0.2.1.tar.gz
- Upload date:
- Size: 24.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
b7843f680a06abe3c7e17897cd1ffc826b260afe4e5a9ca71db3a09f6c12fabb
|
|
| MD5 |
8a32f572a7a945b07e10499aefe38614
|
|
| BLAKE2b-256 |
f2d183eca4d51f4a227f60414109edcfacd11ddfe85553eb5137ca626a288694
|
Provenance
The following attestation bundles were made for tide-0.2.1.tar.gz:
Publisher:
publish.yml on gauthierpiarrette/tide
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
tide-0.2.1.tar.gz -
Subject digest:
b7843f680a06abe3c7e17897cd1ffc826b260afe4e5a9ca71db3a09f6c12fabb - Sigstore transparency entry: 1519103144
- Sigstore integration time:
-
Permalink:
gauthierpiarrette/tide@ed829b7f0f74238fb3d9e89186f9bdebbe86d305 -
Branch / Tag:
refs/tags/v0.2.1 - Owner: https://github.com/gauthierpiarrette
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@ed829b7f0f74238fb3d9e89186f9bdebbe86d305 -
Trigger Event:
push
-
Statement type:
File details
Details for the file tide-0.2.1-py3-none-any.whl.
File metadata
- Download URL: tide-0.2.1-py3-none-any.whl
- Upload date:
- Size: 22.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
9c289bd82c31b9a14af4cc3aa41fc363c5317b8ad2a4277a828cdd8f9e2a7643
|
|
| MD5 |
1bcbe7b91b5b5cda140efe9662d1b232
|
|
| BLAKE2b-256 |
d217b69db7f25b530265ddc763638b5ae527c3e54191752830187dc9d0b818f8
|
Provenance
The following attestation bundles were made for tide-0.2.1-py3-none-any.whl:
Publisher:
publish.yml on gauthierpiarrette/tide
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
tide-0.2.1-py3-none-any.whl -
Subject digest:
9c289bd82c31b9a14af4cc3aa41fc363c5317b8ad2a4277a828cdd8f9e2a7643 - Sigstore transparency entry: 1519103265
- Sigstore integration time:
-
Permalink:
gauthierpiarrette/tide@ed829b7f0f74238fb3d9e89186f9bdebbe86d305 -
Branch / Tag:
refs/tags/v0.2.1 - Owner: https://github.com/gauthierpiarrette
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@ed829b7f0f74238fb3d9e89186f9bdebbe86d305 -
Trigger Event:
push
-
Statement type: