feat: bound planning sync history (#381)
This commit is contained in:
1 parent
51278bc86f
commit
4e13f5482b
16 files changed
+390
-57
No files matched your search
@@ -137,6 +137,63 @@ def test_batch_rejects_stale_same_item_intent_without_blocking_other_items(tmp_p
|
||||
assert duplicate["records"] == replay["records"]
|
||||
|
||||
|
||||
def test_later_receipts_are_bounded_per_account_and_existing_schema_migrates(tmp_path):
|
||||
path = tmp_path / "later.sqlite3"
|
||||
with sqlite3.connect(path) as connection:
|
||||
connection.execute(
|
||||
"CREATE TABLE later_operations (login TEXT NOT NULL, operation_id TEXT NOT NULL, "
|
||||
"PRIMARY KEY (login, operation_id))"
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO later_operations(login, operation_id) VALUES ('timmy', 'legacy')"
|
||||
)
|
||||
|
||||
store = LaterStore(path, operation_limit=2, clock=lambda: 1_000)
|
||||
for index in range(4):
|
||||
store.apply(
|
||||
"timmy", f"op-{index}", "defer", f"issue:r:{index}:",
|
||||
wake_at="2026-08-10T09:00:00.000Z",
|
||||
)
|
||||
store.apply(
|
||||
"alexander", "other", "defer", "issue:r:99:",
|
||||
wake_at="2026-08-10T09:00:00.000Z",
|
||||
)
|
||||
|
||||
with sqlite3.connect(path) as connection:
|
||||
columns = {row[1] for row in connection.execute("PRAGMA table_info(later_operations)")}
|
||||
timmy = connection.execute(
|
||||
"SELECT operation_id FROM later_operations WHERE login = 'timmy' ORDER BY rowid"
|
||||
).fetchall()
|
||||
alexander = connection.execute(
|
||||
"SELECT operation_id FROM later_operations WHERE login = 'alexander'"
|
||||
).fetchall()
|
||||
|
||||
assert "created_at" in columns
|
||||
assert timmy == [("op-2",), ("op-3",)]
|
||||
assert alexander == [("other",)]
|
||||
|
||||
|
||||
def test_later_receipt_age_pruning_is_account_scoped(tmp_path):
|
||||
clock = [1_000.0]
|
||||
path = tmp_path / "later.sqlite3"
|
||||
store = LaterStore(path, operation_retention_seconds=60, clock=lambda: clock[0])
|
||||
store.apply("timmy", "old", "restore", "issue:r:1:")
|
||||
store.apply("alexander", "other-old", "restore", "issue:r:2:")
|
||||
clock[0] += 61
|
||||
store.apply("timmy", "fresh", "restore", "issue:r:3:")
|
||||
|
||||
with sqlite3.connect(path) as connection:
|
||||
timmy = connection.execute(
|
||||
"SELECT operation_id FROM later_operations WHERE login = 'timmy'"
|
||||
).fetchall()
|
||||
alexander = connection.execute(
|
||||
"SELECT operation_id FROM later_operations WHERE login = 'alexander'"
|
||||
).fetchall()
|
||||
|
||||
assert timmy == [("fresh",)]
|
||||
assert alexander == [("other-old",)]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_authenticated_later_api_uses_confirmed_account_and_csrf(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
|
||||
|
||||
@@ -97,6 +97,29 @@ sync.enqueue('defer','issue:r:2:','2026-08-10T09:00:00.000Z');
|
||||
assert result["pending"] == []
|
||||
|
||||
|
||||
def test_later_queue_expires_ancient_edits_before_replay():
|
||||
script = f"""
|
||||
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
|
||||
const values=new Map();const statuses=[];const requests=[];
|
||||
const storage={{get length(){{return values.size}},key:i=>[...values.keys()][i]||null,
|
||||
getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||
const sync=createLaterSync({{storage,getLogin:()=> 'timmy',createOperationId:()=> 'old-later',
|
||||
now:()=>1_800_000_000_000,maxOfflineMs:1000,
|
||||
fetchJson:async(_url,options={{}})=>{{requests.push(options.method||'GET');return {{revision:4,records:{{}}}}}},
|
||||
onRemoteRecords:()=>{{}},onStatus:(state,detail={{}})=>statuses.push([state,detail.count||0])}});
|
||||
(async()=>{{await sync.flush();sync.enqueue('restore','issue:r:2:');
|
||||
const key=[...values.keys()].find(value=>value.includes('.operation.'));
|
||||
const record=JSON.parse(values.get(key));record.queued_at=1_799_999_000_000;values.set(key,JSON.stringify(record));
|
||||
await sync.flush();process.stdout.write(JSON.stringify({{requests,statuses,pending:sync.pending()}}));}})();
|
||||
"""
|
||||
result = run_node(script)
|
||||
|
||||
assert result["requests"] == ["GET", "GET"]
|
||||
assert ["expired", 1] in result["statuses"]
|
||||
assert result["statuses"][-1] == ["expired", 1]
|
||||
assert result["pending"] == []
|
||||
|
||||
|
||||
def test_stale_receipt_adopts_server_truth_and_reports_another_device_conflict():
|
||||
script = f"""
|
||||
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
|
||||
@@ -317,10 +340,12 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
|
||||
assert "laterSync.flush();" in html
|
||||
assert "Later saved to account." in html
|
||||
assert "Another device changed this Later item" in html
|
||||
assert "Today edit expired after 30 days offline" in html
|
||||
assert "Later edit expired after 30 days offline" in html
|
||||
|
||||
|
||||
def test_later_sync_ships_atomically_in_the_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v55" in source
|
||||
assert "stackchain-dashboard-shell-v56" in source
|
||||
assert "BASE + 'static/later-sync.js'" in source
|
||||
@@ -137,4 +137,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
|
||||
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
|
||||
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
|
||||
assert ".markdown-content a { min-height:44px;" in css
|
||||
assert "stackchain-dashboard-shell-v55" in worker
|
||||
assert "stackchain-dashboard-shell-v56" in worker
|
||||
@@ -35,4 +35,4 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
|
||||
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
|
||||
|
||||
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
|
||||
assert "stackchain-dashboard-shell-v55" in worker
|
||||
assert "stackchain-dashboard-shell-v56" in worker
|
||||
@@ -57,3 +57,12 @@ def test_readme_documents_offline_unread_update_conversations_and_boundaries():
|
||||
assert "previously opened unread update" in text
|
||||
assert "replies enter the account-bound durable outbox" in text
|
||||
assert "mark read, ownership, deferral, and older-message loading remain disabled" in text
|
||||
|
||||
|
||||
def test_readme_documents_planning_sync_retention_contract():
|
||||
text = " ".join(README.read_text().split())
|
||||
|
||||
assert "30 days" in text
|
||||
assert "4,096 operation receipts per account" in text
|
||||
assert "expired edit" in text
|
||||
assert "account plan is kept" in text
|
||||
@@ -108,7 +108,7 @@ async function dispatchNotificationClick(route) {{
|
||||
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v55" in source
|
||||
assert "stackchain-dashboard-shell-v56" in source
|
||||
assert "BASE + 'static/create-issue-sheet.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
@@ -116,14 +116,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v55" in source
|
||||
assert "stackchain-dashboard-shell-v56" in source
|
||||
assert "BASE + 'static/later-picker.js'" in source
|
||||
|
||||
|
||||
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v55" in source
|
||||
assert "stackchain-dashboard-shell-v56" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/install-app.js'" in source
|
||||
@@ -132,21 +132,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||
def test_today_convergence_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v55" in source
|
||||
assert "stackchain-dashboard-shell-v56" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v55" in source
|
||||
assert "stackchain-dashboard-shell-v56" in source
|
||||
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
||||
|
||||
|
||||
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v55" in source
|
||||
assert "stackchain-dashboard-shell-v56" in source
|
||||
assert "BASE + 'static/update-ownership.js'" in source
|
||||
|
||||
|
||||
|
||||
@@ -107,6 +107,96 @@ def test_batch_applies_ordered_operations_once_and_rejects_only_capacity_conflic
|
||||
assert replay["duplicate_operation_ids"] == ["two", "three"]
|
||||
|
||||
|
||||
def test_operation_receipts_are_bounded_per_account_and_existing_schema_migrates(tmp_path):
|
||||
path = tmp_path / "today.sqlite3"
|
||||
with sqlite3.connect(path) as connection:
|
||||
connection.execute(
|
||||
"CREATE TABLE today_operations (login TEXT NOT NULL, operation_id TEXT NOT NULL, "
|
||||
"PRIMARY KEY (login, operation_id))"
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO today_operations(login, operation_id) VALUES ('timmy', 'legacy')"
|
||||
)
|
||||
|
||||
store = TodayStore(path, limit=20, operation_limit=3, clock=lambda: 1_000)
|
||||
for index in range(5):
|
||||
store.apply("timmy", f"op-{index}", "add", f"issue:r:{index}:")
|
||||
store.apply("alexander", "other", "add", "issue:r:99:")
|
||||
|
||||
with sqlite3.connect(path) as connection:
|
||||
columns = {row[1] for row in connection.execute("PRAGMA table_info(today_operations)")}
|
||||
timmy = connection.execute(
|
||||
"SELECT operation_id FROM today_operations WHERE login = 'timmy' ORDER BY rowid"
|
||||
).fetchall()
|
||||
alexander = connection.execute(
|
||||
"SELECT operation_id FROM today_operations WHERE login = 'alexander'"
|
||||
).fetchall()
|
||||
|
||||
assert "created_at" in columns
|
||||
assert timmy == [("op-2",), ("op-3",), ("op-4",)]
|
||||
assert alexander == [("other",)]
|
||||
|
||||
|
||||
def test_pruned_stale_today_operation_cannot_reorder_a_newer_plan(tmp_path):
|
||||
store = TodayStore(tmp_path / "today.sqlite3", limit=5, operation_limit=1)
|
||||
store.apply_batch("timmy", [
|
||||
{"operation_id": "seed-1", "action": "add", "item_id": "issue:r:1:", "base_revision": 0},
|
||||
{"operation_id": "seed-2", "action": "add", "item_id": "issue:r:2:", "base_revision": 0},
|
||||
])
|
||||
store.apply_batch("timmy", [{
|
||||
"operation_id": "offline-move", "action": "move", "item_id": "issue:r:2:",
|
||||
"direction": "up", "base_revision": 2,
|
||||
}])
|
||||
newer = store.apply_batch("timmy", [{
|
||||
"operation_id": "newer-move", "action": "move", "item_id": "issue:r:1:",
|
||||
"direction": "up", "base_revision": 3,
|
||||
}])
|
||||
|
||||
replay = store.apply_batch("timmy", [{
|
||||
"operation_id": "offline-move", "action": "move", "item_id": "issue:r:2:",
|
||||
"direction": "up", "base_revision": 2,
|
||||
}])
|
||||
|
||||
assert replay["ids"] == newer["ids"] == ["issue:r:1:", "issue:r:2:"]
|
||||
assert replay["revision"] == 4
|
||||
assert replay["accepted_operation_ids"] == []
|
||||
assert replay["rejected_operations"] == [
|
||||
{"operation_id": "offline-move", "reason": "stale_intent"}
|
||||
]
|
||||
|
||||
|
||||
def test_today_receipt_age_pruning_is_account_scoped(tmp_path):
|
||||
clock = [1_000.0]
|
||||
path = tmp_path / "today.sqlite3"
|
||||
store = TodayStore(
|
||||
path, limit=5, operation_retention_seconds=60, clock=lambda: clock[0]
|
||||
)
|
||||
store.apply("timmy", "old", "add", "issue:r:1:")
|
||||
store.apply("alexander", "other-old", "add", "issue:r:2:")
|
||||
clock[0] += 61
|
||||
store.apply("timmy", "fresh", "remove", "issue:r:1:")
|
||||
|
||||
with sqlite3.connect(path) as connection:
|
||||
timmy = connection.execute(
|
||||
"SELECT operation_id FROM today_operations WHERE login = 'timmy'"
|
||||
).fetchall()
|
||||
alexander = connection.execute(
|
||||
"SELECT operation_id FROM today_operations WHERE login = 'alexander'"
|
||||
).fetchall()
|
||||
|
||||
assert timmy == [("fresh",)]
|
||||
assert alexander == [("other-old",)]
|
||||
|
||||
|
||||
def test_today_api_model_preserves_client_base_revision():
|
||||
operation = main.TodayOperation(
|
||||
operation_id="offline", action="move", item_id="issue:r:2:",
|
||||
direction="up", base_revision=7,
|
||||
)
|
||||
|
||||
assert operation.model_dump()["base_revision"] == 7
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_authenticated_today_api_uses_confirmed_account_and_csrf(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
|
||||
|
||||
@@ -74,8 +74,8 @@ sync.enqueue('add', 'issue:r:1:');
|
||||
assert result == {
|
||||
"sharesInflight": True,
|
||||
"patches": [
|
||||
{"operation_id": "op-1", "action": "add", "item_id": "issue:r:1:", "direction": None},
|
||||
{"operation_id": "op-2", "action": "add", "item_id": "issue:r:2:", "direction": None},
|
||||
{"operation_id": "op-1", "action": "add", "item_id": "issue:r:1:", "direction": None, "base_revision": 0},
|
||||
{"operation_id": "op-2", "action": "add", "item_id": "issue:r:2:", "direction": None, "base_revision": 0},
|
||||
],
|
||||
"pending": [],
|
||||
"ids": ["issue:r:1:", "issue:r:2:"],
|
||||
@@ -86,7 +86,7 @@ sync.enqueue('add', 'issue:r:1:');
|
||||
def test_inflight_today_drain_ships_in_a_new_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v55" in source
|
||||
assert "stackchain-dashboard-shell-v56" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
@@ -137,12 +137,41 @@ sync.enqueue('add', 'issue:r:2:');
|
||||
"action": "add",
|
||||
"item_id": "issue:r:2:",
|
||||
"direction": None,
|
||||
"base_revision": 0,
|
||||
}]}
|
||||
assert result["adopted"] == ["issue:r:9:", "issue:r:2:"]
|
||||
assert result["status"] == "saved"
|
||||
assert result["pending"] == []
|
||||
|
||||
|
||||
def test_today_operations_capture_server_revision_and_expire_before_replay():
|
||||
script = f"""
|
||||
const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
|
||||
const values = new Map(); const statuses=[]; const requests=[];
|
||||
const storage={{get length(){{return values.size}},key:i=>[...values.keys()][i]||null,
|
||||
getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||
const sync=createTodaySync({{storage,getLogin:()=> 'timmy',createOperationId:()=> 'old-op',
|
||||
now:()=>1_800_000_000_000,maxOfflineMs:1000,
|
||||
fetchJson:async(_url,options={{}})=>{{requests.push(options.method||'GET');return {{revision:7,ids:['server']}}}},
|
||||
onRemoteIds:()=>{{}},onStatus:(state,detail={{}})=>statuses.push([state,detail.count||0])}});
|
||||
(async()=>{{await sync.flush();sync.enqueue('move','issue:r:2:','up');
|
||||
const pendingBefore=sync.pending();
|
||||
const key=[...values.keys()].find(value=>value.includes('.operation.'));
|
||||
const record=JSON.parse(values.get(key));record.queued_at=1_799_999_000_000;values.set(key,JSON.stringify(record));
|
||||
await sync.flush();process.stdout.write(JSON.stringify({{pendingBefore,requests,statuses,pendingAfter:sync.pending()}}));}})();
|
||||
"""
|
||||
result = json.loads(subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout)
|
||||
|
||||
assert result["pendingBefore"] == [{
|
||||
"operation_id": "old-op", "action": "move", "item_id": "issue:r:2:",
|
||||
"direction": "up", "base_revision": 7,
|
||||
}]
|
||||
assert result["requests"] == ["GET", "GET"]
|
||||
assert ["expired", 1] in result["statuses"]
|
||||
assert result["statuses"][-1] == ["expired", 1]
|
||||
assert result["pendingAfter"] == []
|
||||
|
||||
|
||||
def test_pending_today_edits_are_sent_as_one_batch_without_a_preflight_get():
|
||||
script = f"""
|
||||
const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
|
||||
@@ -184,6 +213,7 @@ sync.enqueue('move', 'issue:r:2:', 'up');
|
||||
"action": "move",
|
||||
"item_id": "issue:r:2:",
|
||||
"direction": "up",
|
||||
"base_revision": 0,
|
||||
}],
|
||||
"status": "retrying",
|
||||
}
|
||||
@@ -221,7 +251,7 @@ sync.enqueue('add','issue:r:2:');
|
||||
assert result["scheduled"] == [2000]
|
||||
assert result["attempts"] == 2
|
||||
assert result["patches"] == [{
|
||||
"operation_id": "stable-op", "action": "add", "item_id": "issue:r:2:", "direction": None,
|
||||
"operation_id": "stable-op", "action": "add", "item_id": "issue:r:2:", "direction": None, "base_revision": 0,
|
||||
}]
|
||||
assert result["pending"] == []
|
||||
assert ["retrying", 2000] in result["statuses"]
|
||||
@@ -254,6 +284,7 @@ process.stdout.write(JSON.stringify({{first, duplicate, pending:sync.pending(),
|
||||
"action": "remove",
|
||||
"item_id": "issue:r:1:",
|
||||
"direction": None,
|
||||
"base_revision": 0,
|
||||
}],
|
||||
"statuses": ["pending", "pending"],
|
||||
}
|
||||
@@ -280,8 +311,8 @@ process.stdout.write(JSON.stringify({{first, second, pending:sync.pending()}}));
|
||||
"first": True,
|
||||
"second": False,
|
||||
"pending": [
|
||||
{"operation_id": "migration-1", "action": "add", "item_id": "issue:r:1:", "direction": None},
|
||||
{"operation_id": "migration-2", "action": "add", "item_id": "issue:r:2:", "direction": None},
|
||||
{"operation_id": "migration-1", "action": "add", "item_id": "issue:r:1:", "direction": None, "base_revision": 0},
|
||||
{"operation_id": "migration-2", "action": "add", "item_id": "issue:r:2:", "direction": None, "base_revision": 0},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -346,8 +377,8 @@ sync.enqueue('remove', '5');
|
||||
assert result == {
|
||||
"result": False,
|
||||
"patches": [
|
||||
{"operation_id": "op-1", "action": "add", "item_id": "6", "direction": None},
|
||||
{"operation_id": "op-2", "action": "remove", "item_id": "5", "direction": None},
|
||||
{"operation_id": "op-1", "action": "add", "item_id": "6", "direction": None, "base_revision": 0},
|
||||
{"operation_id": "op-2", "action": "remove", "item_id": "5", "direction": None, "base_revision": 0},
|
||||
],
|
||||
"pending": [],
|
||||
"ids": ["1", "2", "3", "4"],
|
||||
|
||||
Reference in new issue
Block a user