From 3b85b4f35deceeab986faa9b8da099d22a11aeb2 Mon Sep 17 00:00:00 2001 From: timmy Date: Sat, 8 Aug 2026 09:00:21 +0000 Subject: [PATCH] fix: rotate idempotency keys after offline issue edits (#283) --- frontend/issue-outbox.js | 20 +++++++++++++++--- frontend/service-worker.js | 2 +- tests/test_issue_outbox.py | 39 ++++++++++++++++++++++++++++++++++-- tests/test_service_worker.py | 4 ++-- 4 files changed, 57 insertions(+), 8 deletions(-) diff --git a/frontend/issue-outbox.js b/frontend/issue-outbox.js index b3fca33..1b4a026 100644 --- a/frontend/issue-outbox.js +++ b/frontend/issue-outbox.js @@ -67,13 +67,27 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge let updated = null; write(read().map(item => { if (item.id !== id) return item; + const nextRepository = String(draft?.repository || ''); + const nextTitle = String(draft?.title || ''); + const nextBody = String(draft?.body || ''); + const nextLabelIds = Array.isArray(draft?.labelIds) ? draft.labelIds.filter(Number.isInteger).slice(0, 20) : []; + const nextMilestoneId = Number.isInteger(Number(draft?.milestoneId)) && Number(draft.milestoneId) > 0 + ? Number(draft.milestoneId) : undefined; + const nextDueDate = /^\d{4}-\d{2}-\d{2}$/.test(String(draft?.dueDate || '')) + ? String(draft.dueDate) : undefined; + const changed = item.repository !== nextRepository || item.title !== nextTitle || item.body !== nextBody + || JSON.stringify(item.labelIds || []) !== JSON.stringify(nextLabelIds) + || item.milestoneId !== nextMilestoneId || item.dueDate !== nextDueDate; updated = { ...item, - repository: String(draft?.repository || ''), title: String(draft?.title || ''), - body: String(draft?.body || ''), - labelIds: Array.isArray(draft?.labelIds) ? draft.labelIds.filter(Number.isInteger).slice(0, 20) : [], + repository: nextRepository, title: nextTitle, + body: nextBody, labelIds: nextLabelIds, + milestoneId: nextMilestoneId, dueDate: nextDueDate, + operationId: changed ? String(operationId()).slice(0, 128) : item.operationId, status: 'queued', }; + if (nextMilestoneId === undefined) delete updated.milestoneId; + if (nextDueDate === undefined) delete updated.dueDate; delete updated.error; return updated; }), mirror); diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 349ff8a..bea9e98 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-v21'; +const CACHE = 'stackchain-dashboard-shell-v22'; const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const SHELL = [ BASE, diff --git a/tests/test_issue_outbox.py b/tests/test_issue_outbox.py index 9706c55..ea81da6 100644 --- a/tests/test_issue_outbox.py +++ b/tests/test_issue_outbox.py @@ -110,9 +110,10 @@ const createIssueOutbox = require({json.dumps(str(OUTBOX))}); const values = new Map(); const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}}; let invalid = true; +let operationSequence = 0; const calls = []; const outbox = createIssueOutbox({{ - storage, getOwnerLogin:()=>'timmy', createOperationId:() => 'stable-edit', + storage, getOwnerLogin:()=>'timmy', createOperationId:() => 'edit-' + (++operationSequence), fetchJson:async (_url, options) => {{ calls.push({{key:options.headers['Idempotency-Key'],body:JSON.parse(options.body)}}); if (invalid) {{ const error = new Error('Title is invalid'); error.status = 422; throw error; }} @@ -132,12 +133,46 @@ outbox.flush('timmy').then(async () => {{ assert output["attention"]["status"] == "attention" assert output["attention"]["error"] == "Title is invalid" - assert [call["key"] for call in output["calls"]] == ["stable-edit", "stable-edit"] + assert [call["key"] for call in output["calls"]] == ["edit-1", "edit-2"] assert output["calls"][1]["body"]["title"] == "Fixed title" assert output["result"]["confirmed"][0]["number"] == 42 assert output["remaining"] == [] +def test_issue_outbox_rotates_operation_id_only_when_delivery_payload_changes(): + script = f""" +const createIssueOutbox = require({json.dumps(str(OUTBOX))}); +const values = new Map(); +const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}}; +let sequence = 0; +const outbox = createIssueOutbox({{ + storage, getOwnerLogin:()=>'timmy', createOperationId:() => 'payload-' + (++sequence), +}}); +const queued = outbox.enqueue({{ + repository:'stackchain/api',title:'Plan',body:'Context',labelIds:[1],milestoneId:2,dueDate:'2026-08-09' +}}); +const unchanged = outbox.update(queued.id, {{...queued}}); +const fields = [ + ['repository', 'stackchain/web'], ['title', 'Revised'], ['body', 'More context'], + ['labelIds', [1, 3]], ['milestoneId', 4], ['dueDate', '2026-08-10'], +]; +const edits = []; +for (const [field, value] of fields) {{ + const current = outbox.list()[0]; + edits.push(outbox.update(queued.id, {{...current, [field]:value}})); +}} +process.stdout.write(JSON.stringify({{queued,unchanged,edits,final:outbox.list()[0]}})); +""" + output = run_node(script) + + assert output["unchanged"]["operationId"] == output["queued"]["operationId"] == "payload-1" + assert [item["operationId"] for item in output["edits"]] == [ + "payload-2", "payload-3", "payload-4", "payload-5", "payload-6", "payload-7" + ] + assert output["final"]["milestoneId"] == 4 + assert output["final"]["dueDate"] == "2026-08-10" + + def test_issue_outbox_is_single_flight_when_reconnect_and_send_now_overlap(): script = f""" const createIssueOutbox = require({json.dumps(str(OUTBOX))}); diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index b26037e..3be7467 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -91,10 +91,10 @@ async function dispatchNotificationClick(route) {{ return json.loads(completed.stdout) -def test_issue_handoff_ships_in_a_new_shell_cache(): +def test_edited_issue_retry_ships_in_a_new_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v21" in source + assert "stackchain-dashboard-shell-v22" in source assert "BASE + 'static/today-work.js'" in source -- 2.43.0