Retry edited offline issue captures with a fresh idempotency key (#284)
Closes #283
This commit is contained in:
commit
e28472b268
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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))});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user