stackchain-dashboard/frontend/authored-outbox.js
timmy d66c1b48ef
All checks were successful
CI / lint (pull_request) Successful in 22s
CI / build-frontend (pull_request) Successful in 5s
feat: queue authored messages for reconnect (#234)
2026-08-07 22:14:49 +00:00

134 lines
4.8 KiB
JavaScript

function createAuthoredOutbox({ storage, fetchJson, createOperationId, now = () => Date.now(), maxItems = 50 }) {
const storageKey = 'stackchain.authored-outbox.v1';
const makeId = createOperationId || (() =>
globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2)
);
const pending = new Map();
const supportedKinds = new Set(['issue-comment', 'pull-comment', 'update-reply']);
function read() {
try {
const record = JSON.parse(storage?.getItem(storageKey) || 'null');
if (record?.version !== 1 || !Array.isArray(record.items)) return [];
return record.items.filter(item => item && supportedKinds.has(item.kind));
} catch (_error) { return []; }
}
function write(items) {
storage?.setItem(storageKey, JSON.stringify({ version: 1, items }));
}
function enqueue(message) {
if (!supportedKinds.has(message?.kind)) throw new Error('This action cannot be queued.');
const items = read();
const requestedOperationId = String(message.operationId || '').slice(0, 128);
const existing = requestedOperationId && items.find(item => item.operationId === requestedOperationId);
if (existing) return { ...existing };
if (items.length >= maxItems) throw new Error('Message outbox is full. Send or discard a queued message first.');
const id = String(requestedOperationId || makeId()).slice(0, 128);
const item = {
id,
operationId: requestedOperationId || id,
kind: message.kind,
repository: String(message.repository || ''),
number: Number(message.number || 0),
notificationId: Number(message.notificationId || 0),
body: String(message.body || ''),
status: 'queued',
queuedAt: Number(now()),
};
items.push(item);
write(items);
return item;
}
function update(id, changes) {
let updated = null;
write(read().map(item => {
if (item.id !== id) return item;
const body = String(changes?.body ?? item.body);
updated = {
...item,
body,
operationId: body === item.body ? item.operationId : String(makeId()).slice(0, 128),
status: 'queued',
};
delete updated.error;
return updated;
}));
return updated;
}
function discard(id) {
const items = read();
if (!items.some(item => item.id === id)) return false;
write(items.filter(item => item.id !== id));
return true;
}
function endpoint(item) {
if (item.kind === 'update-reply') {
return 'api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply';
}
const repository = item.repository.split('/').map(encodeURIComponent).join('/');
const resource = item.kind === 'pull-comment' ? 'pulls' : 'issues';
return 'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(item.number) + '/comments';
}
async function sendItem(item) {
if (pending.has(item.id)) return pending.get(item.id);
const request = (async () => {
try {
const result = await fetchJson(endpoint(item), {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'Idempotency-Key': item.operationId,
},
body: JSON.stringify({ body: item.body }),
});
discard(item.id);
return { result };
} catch (error) {
const status = Number(error?.status || 0);
const permanent = status >= 400 && status < 500;
if (permanent) {
write(read().map(candidate => candidate.id === item.id ? {
...candidate,
status: 'attention',
error: String(error.message || 'Message needs attention').slice(0, 240),
} : candidate));
}
return { error, transient: !permanent };
}
})();
pending.set(item.id, request);
try { return await request; }
finally { if (pending.get(item.id) === request) pending.delete(item.id); }
}
async function flush() {
const confirmed = [];
for (const item of read()) {
if (item.status === 'attention') continue;
const outcome = await sendItem(item);
if (outcome.result) confirmed.push(outcome.result);
if (outcome.transient) break;
}
return { confirmed, remaining: read() };
}
async function retry(id) {
const item = read().find(candidate => candidate.id === id);
if (!item) return { confirmed: [], remaining: read() };
const queued = item.status === 'attention' ? update(id, item) : item;
const outcome = await sendItem(queued);
return { confirmed: outcome.result ? [outcome.result] : [], remaining: read() };
}
return { enqueue, update, discard, flush, retry, list: () => read().map(item => ({ ...item })) };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createAuthoredOutbox;