134 lines
4.8 KiB
JavaScript
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;
|