From 4e13f5482b5998a65e8cd24a8889f5b1741b13bf Mon Sep 17 00:00:00 2001 From: timmy Date: Sun, 9 Aug 2026 07:43:53 +0000 Subject: [PATCH] feat: bound planning sync history (#381) --- README.md | 6 +- frontend/dashboard.js | 8 +- frontend/later-sync.js | 35 ++++++--- frontend/service-worker.js | 2 +- frontend/today-sync.js | 38 ++++++++-- src/later_store.py | 50 ++++++++++--- src/main.py | 1 + src/today_store.py | 61 ++++++++++++--- tests/test_later_store.py | 57 ++++++++++++++ tests/test_later_sync.py | 27 ++++++- tests/test_markdown_renderer.py | 2 +- tests/test_mobile_composer_integration.py | 2 +- tests/test_readme.py | 9 +++ tests/test_service_worker.py | 12 +-- tests/test_today_store.py | 90 +++++++++++++++++++++++ tests/test_today_sync.py | 47 ++++++++++-- 16 files changed, 390 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index 78248bf..58c4bec 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,11 @@ another worker replays a confirmed result instead of posting duplicate content. five-item Today plan syncs across the operator's devices. Server revisions prevent delayed responses from replacing a newer plan; same-account browser tabs exchange fresh snapshots, and reconnecting or returning to the dashboard refreshes server truth after replaying queued -offline operations. After a healthy, fully paginated +offline operations. Planning edits can remain offline for up to 30 days. After that, the +expired edit is discarded visibly and the account plan is kept rather than replaying stale +intent. The server retains no more than 4,096 operation receipts per account and removes +receipts older than the same 30-day window; client base revisions keep a pruned replay from +changing a newer Today or Later plan. After a healthy, fully paginated My Work refresh proves that an item is complete or otherwise no longer eligible, Stackchain queues an idempotent retirement before removing it locally; partial and degraded refreshes leave the plan unchanged, and offline retirements replay after reconnect. When diff --git a/frontend/dashboard.js b/frontend/dashboard.js index d82d6b7..eaada24 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -155,8 +155,9 @@ status.textContent = state === 'saved' ? 'Today saved to account.' : (state === 'retrying' ? `Today saved on this device · retrying in ${Math.ceil(detail.delayMs / 1000)}s.` : (state === 'pending' ? 'Today saved on this device · sync pending.' : - (state === 'full' ? 'Another device filled Today · showing its saved plan.' : - 'Today sync unavailable · changes stay on this device.'))); + (state === 'full' ? 'Another device filled Today · showing its saved plan.' : + (state === 'expired' ? `Today edit expired after 30 days offline${detail.count > 1 ? 's' : ''} · account plan kept.` : + 'Today sync unavailable · changes stay on this device.')))); }, }); todaySync.startLifecycle({ window, document }); @@ -189,7 +190,8 @@ (state === 'conflict' ? `Another device changed this Later item${detail.count > 1 ? 's' : ''} · account plan kept.` : (state === 'retrying' ? `Later saved on this device · retrying in ${Math.ceil(detail.delayMs / 1000)}s.` : (state === 'pending' ? 'Later saved on this device · sync pending.' : - 'Later sync unavailable · changes stay on this device.'))); + (state === 'expired' ? `Later edit expired after 30 days offline${detail.count > 1 ? 's' : ''} · account plan kept.` : + 'Later sync unavailable · changes stay on this device.')))); }, }); laterSync.startLifecycle({ window, document }); diff --git a/frontend/later-sync.js b/frontend/later-sync.js index 5fe9156..b45eb4d 100644 --- a/frontend/later-sync.js +++ b/frontend/later-sync.js @@ -1,5 +1,6 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStatus, createOperationId, createChannel, coordinator, - setTimer = globalThis.setTimeout, clearTimer = globalThis.clearTimeout, retryBaseMs = 1000, retryMaxMs = 30000 }) { + setTimer = globalThis.setTimeout, clearTimer = globalThis.clearTimeout, retryBaseMs = 1000, retryMaxMs = 30000, + now = Date.now, maxOfflineMs = 30 * 24 * 60 * 60 * 1000 }) { const prefix = 'stackchain.later-sync.v1.'; const migrationPrefix = 'stackchain.later-sync-migrated.v1.'; const snapshotPrefix = 'stackchain.later-sync-snapshot.v1.'; @@ -8,6 +9,7 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat let channelKey = ''; let retryTimer = null; let retryAttempt = 0; + let expiredCount = 0; const knownOperationKeys = new Set(); function cancelRetry() { @@ -105,16 +107,27 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat const records = [...keys].map(recordKey => { const record = JSON.parse(storage.getItem(recordKey) || 'null'); return record ? { ...record, recordKey } : null; - }).filter(record => record?.operation) - .sort((left, right) => Number(left.queued_at || 0) - Number(right.queued_at || 0) || - left.operation.operation_id.localeCompare(right.operation.operation_id)); - const latestByItem = new Map(); - records.forEach(record => latestByItem.set(record.operation.item_id, record)); - records.filter(record => latestByItem.get(record.operation.item_id) !== record).forEach(record => { + }).filter(record => record?.operation); + const expired = records.filter(record => Number(record.queued_at) >= 1_000_000_000_000 && + now() - Number(record.queued_at) > maxOfflineMs); + expired.forEach(record => { storage.removeItem(record.recordKey); knownOperationKeys.delete(record.recordKey); }); - return records.filter(record => latestByItem.get(record.operation.item_id) === record) + if (expired.length) { + expiredCount += expired.length; + onStatus?.('expired', { count: expiredCount }); + } + const activeRecords = records.filter(record => !expired.includes(record)) + .sort((left, right) => Number(left.queued_at || 0) - Number(right.queued_at || 0) || + left.operation.operation_id.localeCompare(right.operation.operation_id)); + const latestByItem = new Map(); + activeRecords.forEach(record => latestByItem.set(record.operation.item_id, record)); + activeRecords.filter(record => latestByItem.get(record.operation.item_id) !== record).forEach(record => { + storage.removeItem(record.recordKey); + knownOperationKeys.delete(record.recordKey); + }); + return activeRecords.filter(record => latestByItem.get(record.operation.item_id) === record) .map(record => ({ ...record.operation, base_revision: Number.isInteger(record.operation.base_revision) @@ -161,7 +174,7 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat const recordKey = storageKey + '.operation.' + encodeURIComponent(operation.operation_id); let saved = false; try { - storage.setItem(recordKey, JSON.stringify({ operation, queued_at: Date.now() })); + storage.setItem(recordKey, JSON.stringify({ operation, queued_at: now() })); knownOperationKeys.add(recordKey); saved = true; coordinator?.notify('later'); @@ -190,6 +203,7 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat const ownerKey = key(); if (!ownerKey) return false; ensureChannel(); + expiredCount = 0; try { let operations = pending(); let plan; @@ -222,7 +236,8 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat } adopt(plan); onStatus?.(pending().length ? 'pending' : - (conflictCount ? 'conflict' : 'saved'), conflictCount ? { count: conflictCount } : {}); + (conflictCount ? 'conflict' : expiredCount ? 'expired' : 'saved'), + conflictCount ? { count: conflictCount } : expiredCount ? { count: expiredCount } : {}); retryAttempt = 0; cancelRetry(); return true; diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 4ecfba6..3a9a044 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -1,6 +1,6 @@ const BASE = new URL('./', self.location.href).pathname; importScripts(BASE + 'static/background-issue-sync.js'); -const CACHE = 'stackchain-dashboard-shell-v55'; +const CACHE = 'stackchain-dashboard-shell-v56'; const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000; const SHELL = [ diff --git a/frontend/today-sync.js b/frontend/today-sync.js index 72009cd..864ffcd 100644 --- a/frontend/today-sync.js +++ b/frontend/today-sync.js @@ -1,5 +1,6 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus, createOperationId, createChannel, coordinator, - setTimer = globalThis.setTimeout, clearTimer = globalThis.clearTimeout, retryBaseMs = 1000, retryMaxMs = 30000 }) { + setTimer = globalThis.setTimeout, clearTimer = globalThis.clearTimeout, retryBaseMs = 1000, retryMaxMs = 30000, + now = Date.now, maxOfflineMs = 30 * 24 * 60 * 60 * 1000 }) { const prefix = 'stackchain.today-sync.v1.'; const migrationPrefix = 'stackchain.today-sync-migrated.v1.'; const snapshotPrefix = 'stackchain.today-sync-snapshot.v1.'; @@ -8,6 +9,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus, let channelKey = ''; let retryTimer = null; let retryAttempt = 0; + let expiredCount = 0; const knownOperationKeys = new Set(); function cancelRetry() { @@ -97,11 +99,28 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus, const candidate = storage.key?.(index); if (candidate?.startsWith(recordPrefix)) keys.add(candidate); } - return [...keys].map(recordKey => JSON.parse(storage.getItem(recordKey) || 'null')) - .filter(record => record?.operation) + const records = [...keys].map(recordKey => { + const record = JSON.parse(storage.getItem(recordKey) || 'null'); + return record ? { ...record, recordKey } : null; + }).filter(record => record?.operation); + const expired = records.filter(record => Number(record.queued_at) >= 1_000_000_000_000 && + now() - Number(record.queued_at) > maxOfflineMs); + expired.forEach(record => { + storage.removeItem(record.recordKey); + knownOperationKeys.delete(record.recordKey); + }); + if (expired.length) { + expiredCount += expired.length; + onStatus?.('expired', { count: expiredCount }); + } + return records.filter(record => !expired.includes(record)) .sort((left, right) => Number(left.queued_at || 0) - Number(right.queued_at || 0) || left.operation.operation_id.localeCompare(right.operation.operation_id)) - .map(record => record.operation) + .map(record => ({ + ...record.operation, + base_revision: Number.isInteger(record.operation.base_revision) + ? record.operation.base_revision : Math.max(0, savedRevision()), + })) .filter(operation => operation && typeof operation.operation_id === 'string' && ['add', 'remove', 'move'].includes(operation.action) && typeof operation.item_id === 'string'); } catch (_error) { @@ -136,13 +155,16 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus, onStatus?.('pending'); return true; } - const operation = { operation_id: operationId(), action, item_id: itemId, direction }; + const operation = { + operation_id: operationId(), action, item_id: itemId, direction, + base_revision: Math.max(0, savedRevision()), + }; const storageKey = key(); if (!storageKey || !storage) return false; const recordKey = storageKey + '.operation.' + encodeURIComponent(operation.operation_id); let saved = false; try { - storage.setItem(recordKey, JSON.stringify({ operation, queued_at: Date.now() })); + storage.setItem(recordKey, JSON.stringify({ operation, queued_at: now() })); knownOperationKeys.add(recordKey); saved = true; coordinator?.notify('today'); @@ -169,6 +191,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus, const ownerKey = key(); if (!ownerKey) return false; ensureChannel(); + expiredCount = 0; try { let operations = pending(); let plan; @@ -199,7 +222,8 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus, operations = pending(); } adopt(plan); - onStatus?.(pending().length ? 'pending' : hadConflict ? 'full' : 'saved'); + onStatus?.(pending().length ? 'pending' : hadConflict ? 'full' : + expiredCount ? 'expired' : 'saved', expiredCount ? { count: expiredCount } : {}); retryAttempt = 0; cancelRetry(); return !hadConflict; diff --git a/src/later_store.py b/src/later_store.py index 6a6ca80..2db64d5 100644 --- a/src/later_store.py +++ b/src/later_store.py @@ -2,14 +2,26 @@ import json import sqlite3 +import time from datetime import datetime from pathlib import Path class LaterStore: - def __init__(self, path: str | Path, *, timeout: float = 1.0): + def __init__( + self, + path: str | Path, + *, + timeout: float = 1.0, + operation_limit: int = 4096, + operation_retention_seconds: float = 30 * 24 * 60 * 60, + clock=time.time, + ): self.path = Path(path) self.timeout = timeout + self.operation_limit = operation_limit + self.operation_retention_seconds = operation_retention_seconds + self.clock = clock def _connect(self) -> sqlite3.Connection: self.path.parent.mkdir(parents=True, exist_ok=True) @@ -29,6 +41,7 @@ class LaterStore: CREATE TABLE IF NOT EXISTS later_operations ( login TEXT NOT NULL, operation_id TEXT NOT NULL, + created_at REAL NOT NULL, PRIMARY KEY (login, operation_id) ) """ @@ -43,8 +56,33 @@ class LaterStore: ) """ ) + columns = {row[1] for row in connection.execute("PRAGMA table_info(later_operations)")} + if "created_at" not in columns: + connection.execute("ALTER TABLE later_operations ADD COLUMN created_at REAL") + connection.execute( + "UPDATE later_operations SET created_at = ? WHERE created_at IS NULL", + (self.clock(),), + ) + connection.commit() return connection + def _record_operation(self, connection: sqlite3.Connection, login: str, operation_id: str) -> None: + now = self.clock() + connection.execute( + "INSERT INTO later_operations(login, operation_id, created_at) VALUES (?, ?, ?)", + (login, operation_id, now), + ) + connection.execute( + "DELETE FROM later_operations WHERE login = ? AND created_at < ?", + (login, now - self.operation_retention_seconds), + ) + connection.execute( + "DELETE FROM later_operations WHERE login = ? AND rowid NOT IN " + "(SELECT rowid FROM later_operations WHERE login = ? " + "ORDER BY created_at DESC, rowid DESC LIMIT ?)", + (login, login, self.operation_limit), + ) + @staticmethod def _normalize_login(login: str) -> str: normalized = login.strip().lower() @@ -148,10 +186,7 @@ class LaterStore: if not isinstance(base_revision, int) or isinstance(base_revision, bool) or base_revision < 0: raise ValueError("base_revision must be a non-negative integer") if batch_start_item_revisions.get(item_id, 0) > base_revision: - connection.execute( - "INSERT INTO later_operations(login, operation_id) VALUES (?, ?)", - (login, operation_id), - ) + self._record_operation(connection, login, operation_id) rejected.append({ "operation_id": operation_id, "reason": "stale_intent", @@ -173,10 +208,7 @@ class LaterStore: "ON CONFLICT(login, item_id) DO UPDATE SET revision = excluded.revision", (login, item_id, revision), ) - connection.execute( - "INSERT INTO later_operations(login, operation_id) VALUES (?, ?)", - (login, operation_id), - ) + self._record_operation(connection, login, operation_id) accepted.append(operation_id) serialized = json.dumps(records, separators=(",", ":"), sort_keys=True) diff --git a/src/main.py b/src/main.py index df5eaf5..f308091 100644 --- a/src/main.py +++ b/src/main.py @@ -207,6 +207,7 @@ class TodayOperation(BaseModel): action: Literal["add", "remove", "move"] item_id: str = Field(min_length=1, max_length=500) direction: Literal["up", "down"] | None = None + base_revision: int | None = Field(default=None, ge=0) @model_validator(mode="after") def require_move_direction(self): diff --git a/src/today_store.py b/src/today_store.py index 972624c..ced7054 100644 --- a/src/today_store.py +++ b/src/today_store.py @@ -2,6 +2,7 @@ import json import sqlite3 +import time from pathlib import Path @@ -10,10 +11,22 @@ class TodayPlanFull(ValueError): class TodayStore: - def __init__(self, path: str | Path, *, limit: int = 5, timeout: float = 1.0): + def __init__( + self, + path: str | Path, + *, + limit: int = 5, + timeout: float = 1.0, + operation_limit: int = 4096, + operation_retention_seconds: float = 30 * 24 * 60 * 60, + clock=time.time, + ): self.path = Path(path) self.limit = limit self.timeout = timeout + self.operation_limit = operation_limit + self.operation_retention_seconds = operation_retention_seconds + self.clock = clock def _connect(self) -> sqlite3.Connection: self.path.parent.mkdir(parents=True, exist_ok=True) @@ -33,12 +46,38 @@ class TodayStore: CREATE TABLE IF NOT EXISTS today_operations ( login TEXT NOT NULL, operation_id TEXT NOT NULL, + created_at REAL NOT NULL, PRIMARY KEY (login, operation_id) ) """ ) + columns = {row[1] for row in connection.execute("PRAGMA table_info(today_operations)")} + if "created_at" not in columns: + connection.execute("ALTER TABLE today_operations ADD COLUMN created_at REAL") + connection.execute( + "UPDATE today_operations SET created_at = ? WHERE created_at IS NULL", + (self.clock(),), + ) + connection.commit() return connection + def _record_operation(self, connection: sqlite3.Connection, login: str, operation_id: str) -> None: + now = self.clock() + connection.execute( + "INSERT INTO today_operations(login, operation_id, created_at) VALUES (?, ?, ?)", + (login, operation_id, now), + ) + connection.execute( + "DELETE FROM today_operations WHERE login = ? AND created_at < ?", + (login, now - self.operation_retention_seconds), + ) + connection.execute( + "DELETE FROM today_operations WHERE login = ? AND rowid NOT IN " + "(SELECT rowid FROM today_operations WHERE login = ? " + "ORDER BY created_at DESC, rowid DESC LIMIT ?)", + (login, login, self.operation_limit), + ) + @staticmethod def _normalize_login(login: str) -> str: normalized = login.strip().lower() @@ -123,10 +162,7 @@ class TodayStore: "UPDATE today_plans SET revision = ?, ids = ? WHERE login = ?", (revision, json.dumps(ids, separators=(",", ":")), login), ) - connection.execute( - "INSERT INTO today_operations(login, operation_id) VALUES (?, ?)", - (login, operation_id), - ) + self._record_operation(connection, login, operation_id) return {"revision": revision, "ids": ids} def apply_batch(self, login: str, operations: list[dict]) -> dict: @@ -163,6 +199,16 @@ class TodayStore: duplicates.append(operation_id) continue + base_revision = operation.get("base_revision") + if base_revision is None: + base_revision = snapshot["revision"] + if not isinstance(base_revision, int) or isinstance(base_revision, bool) or base_revision < 0: + raise ValueError("base_revision must be a non-negative integer") + if base_revision < snapshot["revision"]: + self._record_operation(connection, login, operation_id) + rejected.append({"operation_id": operation_id, "reason": "stale_intent"}) + continue + changed = False if action == "add": if item_id not in ids: @@ -185,10 +231,7 @@ class TodayStore: ids[index], ids[target] = ids[target], ids[index] changed = True revision += 1 if changed else 0 - connection.execute( - "INSERT INTO today_operations(login, operation_id) VALUES (?, ?)", - (login, operation_id), - ) + self._record_operation(connection, login, operation_id) accepted.append(operation_id) serialized = json.dumps(ids, separators=(",", ":")) diff --git a/tests/test_later_store.py b/tests/test_later_store.py index 533a72f..adb2384 100644 --- a/tests/test_later_store.py +++ b/tests/test_later_store.py @@ -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") diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py index 6908325..0fdc22b 100644 --- a/tests/test_later_sync.py +++ b/tests/test_later_sync.py @@ -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 diff --git a/tests/test_markdown_renderer.py b/tests/test_markdown_renderer.py index 304d6ba..ed556a3 100644 --- a/tests/test_markdown_renderer.py +++ b/tests/test_markdown_renderer.py @@ -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 diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py index c089eb2..a3b9017 100644 --- a/tests/test_mobile_composer_integration.py +++ b/tests/test_mobile_composer_integration.py @@ -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 diff --git a/tests/test_readme.py b/tests/test_readme.py index 51d110b..ab6e286 100644 --- a/tests/test_readme.py +++ b/tests/test_readme.py @@ -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 diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 0562a8a..3d1034c 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -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 diff --git a/tests/test_today_store.py b/tests/test_today_store.py index b748b05..fcd735d 100644 --- a/tests/test_today_store.py +++ b/tests/test_today_store.py @@ -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") diff --git a/tests/test_today_sync.py b/tests/test_today_sync.py index 9b17cb6..4527fdc 100644 --- a/tests/test_today_sync.py +++ b/tests/test_today_sync.py @@ -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"], -- 2.43.0