feat: sync unfiled drafts across devices (Closes #851)
CI / lint (pull_request) Successful in 1m52s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-14 21:39:22 +00:00
1 parent 871e35c10b
commit 3dd0ba2024
16 files changed
+759 -6

No files matched your search

+1
View File
@@ -112,6 +112,7 @@ def test_request_limits_are_route_specific_and_cover_api_mutations():
== 64 * 1024
)
assert main.request_body_limit("GET", "/api/v1/context") is None
assert main.request_body_limit("PUT", "/api/v1/unfiled-drafts") == 17 * 1024 * 1024
assert (
main.request_body_limit(
"POST", "/api/v1/repos/stackchain/project/issues/17/attachments"
+1
View File
@@ -797,6 +797,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/widgets.js",
"/dashboard/static/drafts.js",
"/dashboard/static/unfiled-captures.js",
"/dashboard/static/unfiled-draft-sync.js",
"/dashboard/static/shared-image-capture.js",
"/dashboard/static/draft-filing-session.js",
"/dashboard/static/draft-capacity-dialog.js",
+41
View File
@@ -104,6 +104,47 @@ process.stdout.write(JSON.stringify({{listed:captures.list()[0],names:resumed.at
assert output["stored"] == output["notes"]
def test_unfiled_captures_export_and_import_ordered_evidence_between_devices():
script = f"""
const createUnfiledCaptures=require({json.dumps(str(UNFILED))});
function device() {{
const values=new Map(), blobs=new Map();
return {{
captures:createUnfiledCaptures({{
storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}},
attachmentStore:{{put:async(id,v)=>blobs.set(id,v),get:async id=>blobs.get(id),delete:async id=>blobs.delete(id)}},
getCaptureLogin:()=>'timmy',getCurrentLogin:()=>'timmy',createId:()=>'phone-draft',now:()=>42,
}}), blobs,
}};
}}
(async()=>{{
const phone=device(), desktop=device();
const image=(name,note)=>({{filename:name,contentType:'image/png',note,blob:new Blob([name],{{type:'image/png'}})}});
await phone.captures.save({{title:'Field failure',body:'Steps',attachments:[image('one.png','First'),image('two.png','Second')],blockers:[{{repository:'o/api',number:7,title:'API'}}]}});
const exported=await phone.captures.exportOwned('timmy');
const imported=await desktop.captures.mergeRemote(exported,'timmy');
const resumed=await desktop.captures.resume('phone-draft','timmy');
process.stdout.write(JSON.stringify({{
exported:exported[0], imported,
names:resumed.attachments.map(item=>item.filename),
notes:resumed.attachments.map(item=>item.note),
bytes:await Promise.all(resumed.attachments.map(async item=>Buffer.from(await item.blob.arrayBuffer()).toString())),
blockers:resumed.blockers,
}}));
}})().catch(error=>{{console.error(error);process.exit(1)}});
"""
output = run_node(script)
assert output["exported"]["id"] == "phone-draft"
assert output["exported"]["saved_at"] == 42
assert [item["filename"] for item in output["exported"]["evidence"]] == ["one.png", "two.png"]
assert output["imported"] == 1
assert output["names"] == ["one.png", "two.png"]
assert output["notes"] == ["First", "Second"]
assert output["bytes"] == ["one.png", "two.png"]
assert output["blockers"] == [{"repository": "o/api", "number": 7, "title": "API"}]
def test_unfiled_capture_restores_selected_blockers():
script = f"""
const createUnfiledCaptures=require({json.dumps(str(UNFILED))});
+21
View File
@@ -0,0 +1,21 @@
from pathlib import Path
from src.frontend_bundle import FEATURE_SOURCES, build_frontend
ROOT = Path(__file__).parents[1]
def test_cross_device_unfiled_draft_sync_is_shipped_and_wired_to_user_flow():
html = (ROOT / "frontend" / "index.html").read_text()
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
assert '<script src="static/unfiled-draft-sync.js"></script>' in html
assert "static/unfiled-draft-sync.js" in FEATURE_SOURCES["today-timer"]
assert "createUnfiledDraftSync(" in dashboard
assert "unfiledCaptures, fetchReviewJson" in dashboard
assert "Saved on this device · sync pending" in (ROOT / "frontend" / "unfiled-draft-sync.js").read_text()
built = build_frontend(ROOT / "frontend")
assert "unfiled-draft-sync.js" not in built.dashboard_html
assert b"stackchain.unfiled-draft-sync.v1" in built.feature_bundles["today-timer"].runtime_bytes
+92
View File
@@ -0,0 +1,92 @@
import httpx
import pytest
from src import main
from src.unfiled_draft_store import UnfiledDraftConflict, UnfiledDraftStore
def draft(draft_id="phone-capture", *, title="Broken checkout", evidence=None):
return {
"id": draft_id,
"title": title,
"body": "Steps from the field",
"saved_at": 1_723_600_000_000,
"blockers": [{"repository": "stackchain/api", "number": 7, "title": "API rollout"}],
"evidence": (evidence if evidence is not None else [
{
"filename": "checkout.png",
"content_type": "image/png",
"note": "Error after tapping Pay",
"data": "cG5nLWJ5dGVz",
}
]),
}
def test_unfiled_drafts_are_revisioned_ordered_and_account_scoped(tmp_path):
store = UnfiledDraftStore(tmp_path / "unfiled.sqlite3")
created = store.replace(" Timmy ", 0, [draft(), draft("second", title="Second")])
assert created == {"revision": 1, "drafts": [draft(), draft("second", title="Second")]}
assert store.get("timmy") == created
assert store.get("alexander") == {"revision": 0, "drafts": []}
with pytest.raises(UnfiledDraftConflict) as conflict:
store.replace("timmy", 0, [draft(title="Stale overwrite")])
assert conflict.value.snapshot == created
def test_unfiled_drafts_bound_collection_and_decoded_evidence(tmp_path):
store = UnfiledDraftStore(tmp_path / "unfiled.sqlite3", limit=2, max_total_bytes=12)
with pytest.raises(ValueError, match="limited to 2"):
store.replace("timmy", 0, [draft("one", evidence=[]), draft("two", evidence=[]), draft("three", evidence=[])])
with pytest.raises(ValueError, match="evidence is too large"):
store.replace("timmy", 0, [draft(evidence=[{
"filename": "large.png", "content_type": "image/png", "data": "eHh4eHh4eHh4eHh4eHh4eHh4eHg=",
}])])
with pytest.raises(ValueError, match="valid base64"):
store.replace("timmy", 0, [draft(evidence=[{
"filename": "bad.png", "content_type": "image/png", "data": "not base64!",
}])])
@pytest.mark.anyio
async def test_unfiled_draft_api_is_account_scoped_csrf_protected_and_conflict_safe(monkeypatch, tmp_path):
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
monkeypatch.setenv(
"STACKCHAIN_DASHBOARD_SESSION_SECRET",
"a-separate-session-signing-secret-with-enough-entropy",
)
monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login.sqlite3"))
monkeypatch.setenv("STACKCHAIN_UNFILED_DRAFT_DB", str(tmp_path / "drafts.sqlite3"))
identity = {"login": "Timmy"}
async def user():
return {"id": 1, "login": identity["login"]}
monkeypatch.setattr(main, "current_user", user)
transport = httpx.ASGITransport(app=main.app)
payload = {"revision": 0, "drafts": [draft(evidence=[])]}
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
await client.post("/api/v1/session", json={"access_token": "correct horse battery staple"})
forbidden = await client.put("/api/v1/unfiled-drafts", json=payload)
headers = {"Origin": "https://test", "X-CSRF-Token": client.cookies["stackchain_csrf"]}
saved = await client.put("/api/v1/unfiled-drafts", json=payload, headers=headers)
stale = await client.put("/api/v1/unfiled-drafts", json=payload, headers=headers)
fetched = await client.get("/api/v1/unfiled-drafts")
identity["login"] = "Alexander"
isolated = await client.get("/api/v1/unfiled-drafts")
assert forbidden.status_code == 403
assert saved.status_code == 200
expected = draft(evidence=[])
expected.pop("evidence")
assert saved.json() == {"revision": 1, "drafts": [expected]}
assert stale.status_code == 409
assert stale.json()["detail"]["snapshot"] == saved.json()
assert fetched.json() == saved.json()
assert fetched.headers["cache-control"] == "no-store"
assert isolated.json() == {"revision": 0, "drafts": []}
+104
View File
@@ -0,0 +1,104 @@
import json
import subprocess
from pathlib import Path
SYNC = Path(__file__).parents[1] / "frontend" / "unfiled-draft-sync.js"
def run_node(script: str):
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
return json.loads(result.stdout)
def test_unfiled_draft_sync_moves_capture_between_devices_and_propagates_delete():
script = f"""
const createSync=require({json.dumps(str(SYNC))});
let remote={{revision:0,drafts:[]}};
function api(url,init={{}}) {{
if (!init.method) return Promise.resolve(JSON.parse(JSON.stringify(remote)));
const body=JSON.parse(init.body);
if (body.revision!==remote.revision) {{
const error=new Error('conflict');error.status=409;error.payload={{detail:{{snapshot:remote}}}};return Promise.reject(error);
}}
remote={{revision:remote.revision+1,drafts:body.drafts}};
return Promise.resolve(JSON.parse(JSON.stringify(remote)));
}}
function device(initial=[]) {{
let drafts=initial.map(item=>({{...item}})); const values=new Map(); const states=[];
const captures={{
exportOwned:async()=>drafts.map(item=>({{...item}})),
mergeRemote:async(items)=>{{let count=0;for(const item of items)if(!drafts.some(x=>x.id===item.id)){{drafts.push({{...item}});count++}}return count}},
discard:async id=>{{drafts=drafts.filter(item=>item.id!==id);return true}},
}};
const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}};
const sync=createSync({{captures,fetchJson:api,storage,onState:state=>states.push(state)}});
return {{sync,states,drafts:()=>drafts}};
}}
(async()=>{{
const phone=device([{{id:'field',title:'Field failure',body:'Steps',saved_at:42,evidence:[{{filename:'proof.png',content_type:'image/png',data:'cHJvb2Y='}}]}}]);
await phone.sync.sync('timmy');
const desktop=device();
await desktop.sync.sync('timmy');
await desktop.sync.remove('field','timmy');
await phone.sync.sync('timmy');
process.stdout.write(JSON.stringify({{
remote,desktop:desktop.drafts(),phone:phone.drafts(),
phoneStates:phone.states.map(x=>x.status),desktopStates:desktop.states.map(x=>x.status),
}}));
}})().catch(error=>{{console.error(error);process.exit(1)}});
"""
output = run_node(script)
assert output["remote"]["revision"] == 2
assert output["remote"]["drafts"] == []
assert output["desktop"] == []
assert output["phone"] == []
assert output["phoneStates"][-1] == "ready"
assert output["desktopStates"][-1] == "ready"
def test_unfiled_draft_sync_surfaces_revision_conflict_without_losing_local_draft():
script = f"""
const createSync=require({json.dumps(str(SYNC))});
let calls=0;const states=[];let drafts=[{{id:'local',title:'Local',body:'',saved_at:1}}];
const conflict={{revision:2,drafts:[{{id:'remote',title:'Remote',body:'',saved_at:2}}]}};
const sync=createSync({{
captures:{{exportOwned:async()=>drafts,mergeRemote:async()=>0,discard:async()=>true}},
storage:{{getItem:()=>null,setItem:()=>{{}}}},
fetchJson:async(_url,init)=>{{calls++;if(!init)return {{revision:1,drafts:[]}};const error=new Error('conflict');error.status=409;error.payload={{detail:{{snapshot:conflict}}}};throw error}},
onState:state=>states.push(state),
}});
(async()=>{{let message='';try{{await sync.sync('timmy')}}catch(error){{message=error.message}}process.stdout.write(JSON.stringify({{calls,states,message,drafts}}))}})();
"""
output = run_node(script)
assert output["calls"] == 2
assert output["states"][-1]["status"] == "conflict"
assert output["states"][-1]["message"] == "Drafts changed on another device. Sync again to combine them."
assert output["drafts"][0]["id"] == "local"
def test_unfiled_draft_sync_coalesces_overlapping_reconnects_without_self_conflict():
script = f"""
const createSync=require({json.dumps(str(SYNC))});
let remote={{revision:0,drafts:[]}}, gets=0, puts=0, release;
const gate=new Promise(resolve=>release=resolve);
const api=async(_url,init)=>{{
if(!init){{gets++;if(gets===1)await gate;return JSON.parse(JSON.stringify(remote))}}
puts++;const body=JSON.parse(init.body);
if(body.revision!==remote.revision){{const error=new Error('conflict');error.status=409;error.payload={{detail:{{snapshot:remote}}}};throw error}}
remote={{revision:remote.revision+1,drafts:body.drafts}};return remote;
}};
const draft={{id:'local',title:'Local',body:'',saved_at:1}};
const sync=createSync({{
captures:{{exportOwned:async()=>[draft],mergeRemote:async()=>0,discard:async()=>true}},
storage:{{getItem:()=>null,setItem:()=>{{}}}},fetchJson:api,
}});
(async()=>{{const first=sync.sync('timmy'),second=sync.sync('timmy');release();const results=await Promise.all([first,second]);process.stdout.write(JSON.stringify({{gets,puts,results,remote}}))}})();
"""
output = run_node(script)
assert output["puts"] == 1
assert output["remote"]["drafts"][0]["id"] == "local"
assert output["results"] == [True, True]