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())
That's it. No server-side schema, no migrations to manage, no plumbing.
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.
What you get in v0.1
- SQLite-backed local store with a durable outbox of pending writes.
- Last-writer-wins sync over a single HTTP endpoint, ordered by a Lamport clock so two offline clients can converge deterministically.
- Reactive subscriptions (
async for change in collection.watch()) that fire on both local and remote writes. - Offline-first: every write hits the local DB immediately; sync runs in the background.
- Pluggable transport if you want WebSocket / gRPC / a fake for tests.
- Reference server (
tide.server, optional[server]extra) — a Starlette + SQLite implementation in under 200 lines you can deploy or port to Postgres.
Install
pip install tide
# with the optional reference server:
pip install "tide[server]"
Python 3.10+.
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})
# One-shot push/pull:
await tide.sync_once()
# Or run a background sync loop:
async with tide.sync_forever(interval=1.0):
await asyncio.sleep(10)
asyncio.run(main())
Run the 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
Authentication
headers accepts a plain mapping or a callable (sync or async) — useful for
short-lived tokens:
async def auth_headers():
token = await get_token_from_somewhere()
return {"Authorization": f"Bearer {token}"}
Tide(db_path="app.db", endpoint="...", headers=auth_headers)
Watching changes
async with Tide(...) as tide:
notes = tide.collection("notes")
async for change in notes.watch():
# change.source is "local" or "remote"
# change.kind is "put" or "delete"
# change.doc is the new document (or None for delete)
print(change)
watch() is cancellation-safe — break out of the loop and the subscription
unregisters automatically.
Protocol
tide speaks a tiny JSON-over-HTTP protocol — one endpoint, one
deterministic conflict rule. See PROTOCOL.md for the full
spec, including idempotency guarantees and failure semantics.
How it works
Every local write goes into two places atomically: the canonical document row and a durable outbox. The outbox is what the sync loop pushes to the server.
Each write carries a Lamport stamp — a (counter, node_id) pair. The
counter monotonically advances both on local writes and when observing
remote writes, so any two writes have a total order all nodes agree on. The
server accepts a write only if its stamp beats the stamp it already has for
that (collection, id). This is plain last-writer-wins, but with
deterministic, network-free tie-breaking — no clock drift surprises.
A sync exchange is a single POST:
POST /sync
{ "node_id": "...", "cursor": "...", "ops": [...] }
→ { "cursor": "...", "changes": [...] }
cursor is the server's monotonic sequence number; the client persists the
largest seq it has seen and replays from there next round.
Non-goals (for v0.1)
- 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.
- Browser/WASM clients. Pure Python today.
These are explicitly deferred so v0 stays small and obvious to reason about.
Comparison
tide |
automerge-py |
pycrdt / y-py |
sqlite-sync |
|
|---|---|---|---|---|
| Turnkey sync engine | ✅ | ⚠️ low-level | ❌ | ✅ (Rust ext) |
| Pure Python client | ✅ | ✅ | ⚠️ Rust ext | ❌ |
| Conflict model | LWW + Lamport | CRDT | CRDT | CRDT (SQLite rows) |
| Reference server | ✅ Starlette | ❌ | ❌ | ❌ |
| Pluggable transport | ✅ | ⚠️ | n/a | ❌ |
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.
Stability
0.1.x is alpha. The Python API and wire protocol may change in
backward-incompatible ways until 1.0. The store schema is versioned and
will be migrated forward.
License
MIT
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.0.tar.gz.
File metadata
- Download URL: tide-0.2.0.tar.gz
- Upload date:
- Size: 21.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
832e99394247b59240bc810c1d8d77ebd8829c96ccba83ce3d1a8c2de76e554e
|
|
| MD5 |
256e3cc0a4c5a103129dac2b666f6838
|
|
| BLAKE2b-256 |
6a394030550455e3f375b961a37f0c2f06b1fa9811d1a8e861de1c3c19b1b7ad
|
Provenance
The following attestation bundles were made for tide-0.2.0.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.0.tar.gz -
Subject digest:
832e99394247b59240bc810c1d8d77ebd8829c96ccba83ce3d1a8c2de76e554e - Sigstore transparency entry: 1518933887
- Sigstore integration time:
-
Permalink:
gauthierpiarrette/tide@d00bc6fb03dc79f8f751c0667dfb23d1fb479aeb -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/gauthierpiarrette
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@d00bc6fb03dc79f8f751c0667dfb23d1fb479aeb -
Trigger Event:
push
-
Statement type:
File details
Details for the file tide-0.2.0-py3-none-any.whl.
File metadata
- Download URL: tide-0.2.0-py3-none-any.whl
- Upload date:
- Size: 20.0 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 |
76fd2f83d831f74c738318c20c5c949f4ecdda8ace80aa58818978ea9037ddeb
|
|
| MD5 |
b2c9d2880877536f03ad611f6f28acee
|
|
| BLAKE2b-256 |
d24da4602cb420abf4c575499df9e28bac07fa846b5dcd4784307f8aaf2aadb9
|
Provenance
The following attestation bundles were made for tide-0.2.0-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.0-py3-none-any.whl -
Subject digest:
76fd2f83d831f74c738318c20c5c949f4ecdda8ace80aa58818978ea9037ddeb - Sigstore transparency entry: 1518933943
- Sigstore integration time:
-
Permalink:
gauthierpiarrette/tide@d00bc6fb03dc79f8f751c0667dfb23d1fb479aeb -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/gauthierpiarrette
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@d00bc6fb03dc79f8f751c0667dfb23d1fb479aeb -
Trigger Event:
push
-
Statement type: