Retry edited offline issue captures with a fresh idempotency key #284
|
|
@ -67,13 +67,27 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||||
let updated = null;
|
let updated = null;
|
||||||
write(read().map(item => {
|
write(read().map(item => {
|
||||||
if (item.id !== id) return 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 = {
|
updated = {
|
||||||
...item,
|
...item,
|
||||||
repository: String(draft?.repository || ''), title: String(draft?.title || ''),
|
repository: nextRepository, title: nextTitle,
|
||||||
body: String(draft?.body || ''),
|
body: nextBody, labelIds: nextLabelIds,
|
||||||
labelIds: Array.isArray(draft?.labelIds) ? draft.labelIds.filter(Number.isInteger).slice(0, 20) : [],
|
milestoneId: nextMilestoneId, dueDate: nextDueDate,
|
||||||
|
operationId: changed ? String(operationId()).slice(0, 128) : item.operationId,
|
||||||
status: 'queued',
|
status: 'queued',
|
||||||
};
|
};
|
||||||
|
if (nextMilestoneId === undefined) delete updated.milestoneId;
|
||||||
|
if (nextDueDate === undefined) delete updated.dueDate;
|
||||||
delete updated.error;
|
delete updated.error;
|
||||||
return updated;
|
return updated;
|
||||||
}), mirror);
|
}), mirror);
|
||||||
|
|
|
||||||
|
|
@ -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-v21';
|
const CACHE = 'stackchain-dashboard-shell-v22';
|
||||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||||
const SHELL = [
|
const SHELL = [
|
||||||
BASE,
|
BASE,
|
||||||
|
|
|
||||||
|
|
@ -110,9 +110,10 @@ const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
||||||
const values = new Map();
|
const values = new Map();
|
||||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||||
let invalid = true;
|
let invalid = true;
|
||||||
|
let operationSequence = 0;
|
||||||
const calls = [];
|
const calls = [];
|
||||||
const outbox = createIssueOutbox({{
|
const outbox = createIssueOutbox({{
|
||||||
storage, getOwnerLogin:()=>'timmy', createOperationId:() => 'stable-edit',
|
storage, getOwnerLogin:()=>'timmy', createOperationId:() => 'edit-' + (++operationSequence),
|
||||||
fetchJson:async (_url, options) => {{
|
fetchJson:async (_url, options) => {{
|
||||||
calls.push({{key:options.headers['Idempotency-Key'],body:JSON.parse(options.body)}});
|
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; }}
|
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"]["status"] == "attention"
|
||||||
assert output["attention"]["error"] == "Title is invalid"
|
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["calls"][1]["body"]["title"] == "Fixed title"
|
||||||
assert output["result"]["confirmed"][0]["number"] == 42
|
assert output["result"]["confirmed"][0]["number"] == 42
|
||||||
assert output["remaining"] == []
|
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():
|
def test_issue_outbox_is_single_flight_when_reconnect_and_send_now_overlap():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
||||||
|
|
|
||||||
|
|
@ -91,10 +91,10 @@ async function dispatchNotificationClick(route) {{
|
||||||
return json.loads(completed.stdout)
|
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()
|
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
|
assert "BASE + 'static/today-work.js'" in source
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user