feat: bound planning sync history (#381)
All checks were successful
CI / lint (pull_request) Successful in 39s
CI / build-release (pull_request) Successful in 4s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-09 07:43:53 +00:00
parent 51278bc86f
commit 4e13f5482b
16 changed files with 390 additions and 57 deletions

View File

@ -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 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, 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 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 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 queues an idempotent retirement before removing it locally; partial and degraded refreshes
leave the plan unchanged, and offline retirements replay after reconnect. When leave the plan unchanged, and offline retirements replay after reconnect. When

View File

@ -155,8 +155,9 @@
status.textContent = state === 'saved' ? 'Today saved to account.' : status.textContent = state === 'saved' ? 'Today saved to account.' :
(state === 'retrying' ? `Today saved on this device · retrying in ${Math.ceil(detail.delayMs / 1000)}s.` : (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 === 'pending' ? 'Today saved on this device · sync pending.' :
(state === 'full' ? 'Another device filled Today · showing its saved plan.' : (state === 'full' ? 'Another device filled Today · showing its saved plan.' :
'Today sync unavailable · changes stay on this device.'))); (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 }); todaySync.startLifecycle({ window, document });
@ -189,7 +190,8 @@
(state === 'conflict' ? `Another device changed this Later item${detail.count > 1 ? 's' : ''} · account plan kept.` : (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 === 'retrying' ? `Later saved on this device · retrying in ${Math.ceil(detail.delayMs / 1000)}s.` :
(state === 'pending' ? 'Later saved on this device · sync pending.' : (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 }); laterSync.startLifecycle({ window, document });

View File

@ -1,5 +1,6 @@
function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStatus, createOperationId, createChannel, coordinator, 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 prefix = 'stackchain.later-sync.v1.';
const migrationPrefix = 'stackchain.later-sync-migrated.v1.'; const migrationPrefix = 'stackchain.later-sync-migrated.v1.';
const snapshotPrefix = 'stackchain.later-sync-snapshot.v1.'; const snapshotPrefix = 'stackchain.later-sync-snapshot.v1.';
@ -8,6 +9,7 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat
let channelKey = ''; let channelKey = '';
let retryTimer = null; let retryTimer = null;
let retryAttempt = 0; let retryAttempt = 0;
let expiredCount = 0;
const knownOperationKeys = new Set(); const knownOperationKeys = new Set();
function cancelRetry() { function cancelRetry() {
@ -105,16 +107,27 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat
const records = [...keys].map(recordKey => { const records = [...keys].map(recordKey => {
const record = JSON.parse(storage.getItem(recordKey) || 'null'); const record = JSON.parse(storage.getItem(recordKey) || 'null');
return record ? { ...record, recordKey } : null; return record ? { ...record, recordKey } : null;
}).filter(record => record?.operation) }).filter(record => record?.operation);
.sort((left, right) => Number(left.queued_at || 0) - Number(right.queued_at || 0) || const expired = records.filter(record => Number(record.queued_at) >= 1_000_000_000_000 &&
left.operation.operation_id.localeCompare(right.operation.operation_id)); now() - Number(record.queued_at) > maxOfflineMs);
const latestByItem = new Map(); expired.forEach(record => {
records.forEach(record => latestByItem.set(record.operation.item_id, record));
records.filter(record => latestByItem.get(record.operation.item_id) !== record).forEach(record => {
storage.removeItem(record.recordKey); storage.removeItem(record.recordKey);
knownOperationKeys.delete(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 => ({ .map(record => ({
...record.operation, ...record.operation,
base_revision: Number.isInteger(record.operation.base_revision) 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); const recordKey = storageKey + '.operation.' + encodeURIComponent(operation.operation_id);
let saved = false; let saved = false;
try { try {
storage.setItem(recordKey, JSON.stringify({ operation, queued_at: Date.now() })); storage.setItem(recordKey, JSON.stringify({ operation, queued_at: now() }));
knownOperationKeys.add(recordKey); knownOperationKeys.add(recordKey);
saved = true; saved = true;
coordinator?.notify('later'); coordinator?.notify('later');
@ -190,6 +203,7 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat
const ownerKey = key(); const ownerKey = key();
if (!ownerKey) return false; if (!ownerKey) return false;
ensureChannel(); ensureChannel();
expiredCount = 0;
try { try {
let operations = pending(); let operations = pending();
let plan; let plan;
@ -222,7 +236,8 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat
} }
adopt(plan); adopt(plan);
onStatus?.(pending().length ? 'pending' : onStatus?.(pending().length ? 'pending' :
(conflictCount ? 'conflict' : 'saved'), conflictCount ? { count: conflictCount } : {}); (conflictCount ? 'conflict' : expiredCount ? 'expired' : 'saved'),
conflictCount ? { count: conflictCount } : expiredCount ? { count: expiredCount } : {});
retryAttempt = 0; retryAttempt = 0;
cancelRetry(); cancelRetry();
return true; return true;

View File

@ -1,6 +1,6 @@
const BASE = new URL('./', self.location.href).pathname; const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js'); 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 OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000; const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
const SHELL = [ const SHELL = [

View File

@ -1,5 +1,6 @@
function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus, createOperationId, createChannel, coordinator, 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 prefix = 'stackchain.today-sync.v1.';
const migrationPrefix = 'stackchain.today-sync-migrated.v1.'; const migrationPrefix = 'stackchain.today-sync-migrated.v1.';
const snapshotPrefix = 'stackchain.today-sync-snapshot.v1.'; const snapshotPrefix = 'stackchain.today-sync-snapshot.v1.';
@ -8,6 +9,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus,
let channelKey = ''; let channelKey = '';
let retryTimer = null; let retryTimer = null;
let retryAttempt = 0; let retryAttempt = 0;
let expiredCount = 0;
const knownOperationKeys = new Set(); const knownOperationKeys = new Set();
function cancelRetry() { function cancelRetry() {
@ -97,11 +99,28 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus,
const candidate = storage.key?.(index); const candidate = storage.key?.(index);
if (candidate?.startsWith(recordPrefix)) keys.add(candidate); if (candidate?.startsWith(recordPrefix)) keys.add(candidate);
} }
return [...keys].map(recordKey => JSON.parse(storage.getItem(recordKey) || 'null')) const records = [...keys].map(recordKey => {
.filter(record => record?.operation) 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) || .sort((left, right) => Number(left.queued_at || 0) - Number(right.queued_at || 0) ||
left.operation.operation_id.localeCompare(right.operation.operation_id)) 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' && .filter(operation => operation && typeof operation.operation_id === 'string' &&
['add', 'remove', 'move'].includes(operation.action) && typeof operation.item_id === 'string'); ['add', 'remove', 'move'].includes(operation.action) && typeof operation.item_id === 'string');
} catch (_error) { } catch (_error) {
@ -136,13 +155,16 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus,
onStatus?.('pending'); onStatus?.('pending');
return true; 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(); const storageKey = key();
if (!storageKey || !storage) return false; if (!storageKey || !storage) return false;
const recordKey = storageKey + '.operation.' + encodeURIComponent(operation.operation_id); const recordKey = storageKey + '.operation.' + encodeURIComponent(operation.operation_id);
let saved = false; let saved = false;
try { try {
storage.setItem(recordKey, JSON.stringify({ operation, queued_at: Date.now() })); storage.setItem(recordKey, JSON.stringify({ operation, queued_at: now() }));
knownOperationKeys.add(recordKey); knownOperationKeys.add(recordKey);
saved = true; saved = true;
coordinator?.notify('today'); coordinator?.notify('today');
@ -169,6 +191,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus,
const ownerKey = key(); const ownerKey = key();
if (!ownerKey) return false; if (!ownerKey) return false;
ensureChannel(); ensureChannel();
expiredCount = 0;
try { try {
let operations = pending(); let operations = pending();
let plan; let plan;
@ -199,7 +222,8 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus,
operations = pending(); operations = pending();
} }
adopt(plan); adopt(plan);
onStatus?.(pending().length ? 'pending' : hadConflict ? 'full' : 'saved'); onStatus?.(pending().length ? 'pending' : hadConflict ? 'full' :
expiredCount ? 'expired' : 'saved', expiredCount ? { count: expiredCount } : {});
retryAttempt = 0; retryAttempt = 0;
cancelRetry(); cancelRetry();
return !hadConflict; return !hadConflict;

View File

@ -2,14 +2,26 @@
import json import json
import sqlite3 import sqlite3
import time
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
class LaterStore: 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.path = Path(path)
self.timeout = timeout self.timeout = timeout
self.operation_limit = operation_limit
self.operation_retention_seconds = operation_retention_seconds
self.clock = clock
def _connect(self) -> sqlite3.Connection: def _connect(self) -> sqlite3.Connection:
self.path.parent.mkdir(parents=True, exist_ok=True) self.path.parent.mkdir(parents=True, exist_ok=True)
@ -29,6 +41,7 @@ class LaterStore:
CREATE TABLE IF NOT EXISTS later_operations ( CREATE TABLE IF NOT EXISTS later_operations (
login TEXT NOT NULL, login TEXT NOT NULL,
operation_id TEXT NOT NULL, operation_id TEXT NOT NULL,
created_at REAL NOT NULL,
PRIMARY KEY (login, operation_id) 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 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 @staticmethod
def _normalize_login(login: str) -> str: def _normalize_login(login: str) -> str:
normalized = login.strip().lower() 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: 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") raise ValueError("base_revision must be a non-negative integer")
if batch_start_item_revisions.get(item_id, 0) > base_revision: if batch_start_item_revisions.get(item_id, 0) > base_revision:
connection.execute( self._record_operation(connection, login, operation_id)
"INSERT INTO later_operations(login, operation_id) VALUES (?, ?)",
(login, operation_id),
)
rejected.append({ rejected.append({
"operation_id": operation_id, "operation_id": operation_id,
"reason": "stale_intent", "reason": "stale_intent",
@ -173,10 +208,7 @@ class LaterStore:
"ON CONFLICT(login, item_id) DO UPDATE SET revision = excluded.revision", "ON CONFLICT(login, item_id) DO UPDATE SET revision = excluded.revision",
(login, item_id, revision), (login, item_id, revision),
) )
connection.execute( self._record_operation(connection, login, operation_id)
"INSERT INTO later_operations(login, operation_id) VALUES (?, ?)",
(login, operation_id),
)
accepted.append(operation_id) accepted.append(operation_id)
serialized = json.dumps(records, separators=(",", ":"), sort_keys=True) serialized = json.dumps(records, separators=(",", ":"), sort_keys=True)

View File

@ -207,6 +207,7 @@ class TodayOperation(BaseModel):
action: Literal["add", "remove", "move"] action: Literal["add", "remove", "move"]
item_id: str = Field(min_length=1, max_length=500) item_id: str = Field(min_length=1, max_length=500)
direction: Literal["up", "down"] | None = None direction: Literal["up", "down"] | None = None
base_revision: int | None = Field(default=None, ge=0)
@model_validator(mode="after") @model_validator(mode="after")
def require_move_direction(self): def require_move_direction(self):

View File

@ -2,6 +2,7 @@
import json import json
import sqlite3 import sqlite3
import time
from pathlib import Path from pathlib import Path
@ -10,10 +11,22 @@ class TodayPlanFull(ValueError):
class TodayStore: 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.path = Path(path)
self.limit = limit self.limit = limit
self.timeout = timeout self.timeout = timeout
self.operation_limit = operation_limit
self.operation_retention_seconds = operation_retention_seconds
self.clock = clock
def _connect(self) -> sqlite3.Connection: def _connect(self) -> sqlite3.Connection:
self.path.parent.mkdir(parents=True, exist_ok=True) self.path.parent.mkdir(parents=True, exist_ok=True)
@ -33,12 +46,38 @@ class TodayStore:
CREATE TABLE IF NOT EXISTS today_operations ( CREATE TABLE IF NOT EXISTS today_operations (
login TEXT NOT NULL, login TEXT NOT NULL,
operation_id TEXT NOT NULL, operation_id TEXT NOT NULL,
created_at REAL NOT NULL,
PRIMARY KEY (login, operation_id) 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 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 @staticmethod
def _normalize_login(login: str) -> str: def _normalize_login(login: str) -> str:
normalized = login.strip().lower() normalized = login.strip().lower()
@ -123,10 +162,7 @@ class TodayStore:
"UPDATE today_plans SET revision = ?, ids = ? WHERE login = ?", "UPDATE today_plans SET revision = ?, ids = ? WHERE login = ?",
(revision, json.dumps(ids, separators=(",", ":")), login), (revision, json.dumps(ids, separators=(",", ":")), login),
) )
connection.execute( self._record_operation(connection, login, operation_id)
"INSERT INTO today_operations(login, operation_id) VALUES (?, ?)",
(login, operation_id),
)
return {"revision": revision, "ids": ids} return {"revision": revision, "ids": ids}
def apply_batch(self, login: str, operations: list[dict]) -> dict: def apply_batch(self, login: str, operations: list[dict]) -> dict:
@ -163,6 +199,16 @@ class TodayStore:
duplicates.append(operation_id) duplicates.append(operation_id)
continue 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 changed = False
if action == "add": if action == "add":
if item_id not in ids: if item_id not in ids:
@ -185,10 +231,7 @@ class TodayStore:
ids[index], ids[target] = ids[target], ids[index] ids[index], ids[target] = ids[target], ids[index]
changed = True changed = True
revision += 1 if changed else 0 revision += 1 if changed else 0
connection.execute( self._record_operation(connection, login, operation_id)
"INSERT INTO today_operations(login, operation_id) VALUES (?, ?)",
(login, operation_id),
)
accepted.append(operation_id) accepted.append(operation_id)
serialized = json.dumps(ids, separators=(",", ":")) serialized = json.dumps(ids, separators=(",", ":"))

View File

@ -137,6 +137,63 @@ def test_batch_rejects_stale_same_item_intent_without_blocking_other_items(tmp_p
assert duplicate["records"] == replay["records"] 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 @pytest.mark.anyio
async def test_authenticated_later_api_uses_confirmed_account_and_csrf(monkeypatch, tmp_path): async def test_authenticated_later_api_uses_confirmed_account_and_csrf(monkeypatch, tmp_path):
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator") monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")

View File

@ -97,6 +97,29 @@ sync.enqueue('defer','issue:r:2:','2026-08-10T09:00:00.000Z');
assert result["pending"] == [] 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(): def test_stale_receipt_adopts_server_truth_and_reports_another_device_conflict():
script = f""" script = f"""
const createLaterSync=require({json.dumps(str(LATER_SYNC))}); 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 "laterSync.flush();" in html
assert "Later saved to account." in html assert "Later saved to account." in html
assert "Another device changed this Later item" 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(): def test_later_sync_ships_atomically_in_the_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() 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 assert "BASE + 'static/later-sync.js'" in source

View File

@ -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 { 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 pre { max-width:100%; overflow-x:auto;" in css
assert ".markdown-content a { min-height:44px;" 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

View File

@ -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])) 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 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

View File

@ -57,3 +57,12 @@ def test_readme_documents_offline_unread_update_conversations_and_boundaries():
assert "previously opened unread update" in text assert "previously opened unread update" in text
assert "replies enter the account-bound durable outbox" 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 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

View File

@ -108,7 +108,7 @@ async function dispatchNotificationClick(route) {{
def test_duplicate_aware_capture_ships_in_a_new_offline_shell(): def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
source = WORKER.read_text() 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/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.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(): def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text() 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 assert "BASE + 'static/later-picker.js'" in source
def test_navigation_deadline_ships_in_a_new_shell_cache(): def test_navigation_deadline_ships_in_a_new_shell_cache():
source = WORKER.read_text() 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.css'" in source
assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.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(): def test_today_convergence_ships_in_a_new_shell_cache():
source = WORKER.read_text() 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 assert "BASE + 'static/today-sync.js'" in source
def test_mobile_search_viewport_ships_in_a_new_offline_shell(): def test_mobile_search_viewport_ships_in_a_new_offline_shell():
source = WORKER.read_text() 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 assert "BASE + 'static/mobile-search-viewport.js'" in source
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell(): def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text() 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 assert "BASE + 'static/update-ownership.js'" in source

View File

@ -107,6 +107,96 @@ def test_batch_applies_ordered_operations_once_and_rejects_only_capacity_conflic
assert replay["duplicate_operation_ids"] == ["two", "three"] 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 @pytest.mark.anyio
async def test_authenticated_today_api_uses_confirmed_account_and_csrf(monkeypatch, tmp_path): async def test_authenticated_today_api_uses_confirmed_account_and_csrf(monkeypatch, tmp_path):
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator") monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")

View File

@ -74,8 +74,8 @@ sync.enqueue('add', 'issue:r:1:');
assert result == { assert result == {
"sharesInflight": True, "sharesInflight": True,
"patches": [ "patches": [
{"operation_id": "op-1", "action": "add", "item_id": "issue:r:1:", "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}, {"operation_id": "op-2", "action": "add", "item_id": "issue:r:2:", "direction": None, "base_revision": 0},
], ],
"pending": [], "pending": [],
"ids": ["issue:r:1:", "issue:r:2:"], "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(): def test_inflight_today_drain_ships_in_a_new_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() 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 assert "BASE + 'static/today-sync.js'" in source
@ -137,12 +137,41 @@ sync.enqueue('add', 'issue:r:2:');
"action": "add", "action": "add",
"item_id": "issue:r:2:", "item_id": "issue:r:2:",
"direction": None, "direction": None,
"base_revision": 0,
}]} }]}
assert result["adopted"] == ["issue:r:9:", "issue:r:2:"] assert result["adopted"] == ["issue:r:9:", "issue:r:2:"]
assert result["status"] == "saved" assert result["status"] == "saved"
assert result["pending"] == [] 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(): def test_pending_today_edits_are_sent_as_one_batch_without_a_preflight_get():
script = f""" script = f"""
const createTodaySync = require({json.dumps(str(TODAY_SYNC))}); const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
@ -184,6 +213,7 @@ sync.enqueue('move', 'issue:r:2:', 'up');
"action": "move", "action": "move",
"item_id": "issue:r:2:", "item_id": "issue:r:2:",
"direction": "up", "direction": "up",
"base_revision": 0,
}], }],
"status": "retrying", "status": "retrying",
} }
@ -221,7 +251,7 @@ sync.enqueue('add','issue:r:2:');
assert result["scheduled"] == [2000] assert result["scheduled"] == [2000]
assert result["attempts"] == 2 assert result["attempts"] == 2
assert result["patches"] == [{ 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 result["pending"] == []
assert ["retrying", 2000] in result["statuses"] assert ["retrying", 2000] in result["statuses"]
@ -254,6 +284,7 @@ process.stdout.write(JSON.stringify({{first, duplicate, pending:sync.pending(),
"action": "remove", "action": "remove",
"item_id": "issue:r:1:", "item_id": "issue:r:1:",
"direction": None, "direction": None,
"base_revision": 0,
}], }],
"statuses": ["pending", "pending"], "statuses": ["pending", "pending"],
} }
@ -280,8 +311,8 @@ process.stdout.write(JSON.stringify({{first, second, pending:sync.pending()}}));
"first": True, "first": True,
"second": False, "second": False,
"pending": [ "pending": [
{"operation_id": "migration-1", "action": "add", "item_id": "issue:r:1:", "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}, {"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 == { assert result == {
"result": False, "result": False,
"patches": [ "patches": [
{"operation_id": "op-1", "action": "add", "item_id": "6", "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}, {"operation_id": "op-2", "action": "remove", "item_id": "5", "direction": None, "base_revision": 0},
], ],
"pending": [], "pending": [],
"ids": ["1", "2", "3", "4"], "ids": ["1", "2", "3", "4"],