Retry edited offline issue captures with a fresh idempotency key (#284)
All checks were successful
CI / lint (push) Successful in 28s
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 4s

Closes #283
This commit is contained in:
timmy 2026-08-08 09:01:35 +00:00
commit e28472b268
4 changed files with 57 additions and 8 deletions

View File

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

View File

@ -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,

View File

@ -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))});

View File

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