309 lines
12 KiB
JavaScript
309 lines
12 KiB
JavaScript
function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync, getOwnerLogin = () => '', 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', 'pull-review', 'issue-close']);
|
|
|
|
function reviewFingerprint(message) {
|
|
return JSON.stringify({
|
|
body: String(message.body || ''),
|
|
decision: String(message.decision || 'comment'),
|
|
expectedHeadSha: String(message.expectedHeadSha || ''),
|
|
comments: Array.isArray(message.comments) ? message.comments : [],
|
|
});
|
|
}
|
|
|
|
function clearConfirmedReviewState(item) {
|
|
if (item.kind !== 'pull-review') return;
|
|
try {
|
|
if (item.draftKey && item.draftFingerprint &&
|
|
storage?.getItem(item.draftKey) === item.draftFingerprint) storage.removeItem(item.draftKey);
|
|
if (item.progressKey && item.progressFingerprint &&
|
|
storage?.getItem(item.progressKey) === item.progressFingerprint) storage.removeItem(item.progressKey);
|
|
} catch (_error) { /* Delivery is confirmed even when local cleanup is unavailable. */ }
|
|
}
|
|
|
|
function read() {
|
|
try {
|
|
const record = JSON.parse(storage?.getItem(storageKey) || 'null');
|
|
if (![1, 2].includes(record?.version) || !Array.isArray(record.items)) return [];
|
|
return record.items.filter(item => item && supportedKinds.has(item.kind));
|
|
} catch (_error) { return []; }
|
|
}
|
|
|
|
function write(items, mirror = true) {
|
|
storage?.setItem(storageKey, JSON.stringify({ version: 2, items }));
|
|
coordinator?.notify('authored');
|
|
if (mirror && backgroundSync?.reconcile) {
|
|
Promise.resolve(backgroundSync.reconcile(items, 'authored'))
|
|
.then(() => items.length ? backgroundSync.requestSync?.() : undefined)
|
|
.catch(() => { /* Foreground reconnect remains the compatibility fallback. */ });
|
|
}
|
|
}
|
|
|
|
function enqueue(message, mirror = true) {
|
|
if (!supportedKinds.has(message?.kind)) throw new Error('This action cannot be queued.');
|
|
const ownerLogin = String(getOwnerLogin() || '').trim();
|
|
if (!ownerLogin) throw new Error('Confirm your Gitea account before queueing a message.');
|
|
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 (message.kind === 'pull-review') {
|
|
const queuedReview = items.find(item => item.kind === 'pull-review' &&
|
|
item.repository === String(message.repository || '') &&
|
|
item.number === Number(message.number || 0) &&
|
|
item.expectedHeadSha === String(message.expectedHeadSha || ''));
|
|
if (queuedReview) {
|
|
if (reviewFingerprint(queuedReview) === reviewFingerprint(message)) return { ...queuedReview };
|
|
throw new Error('A review for this saved head is already queued. Open Drafts to inspect or discard it first.');
|
|
}
|
|
}
|
|
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 || ''),
|
|
ownerLogin,
|
|
status: 'queued',
|
|
queuedAt: Number(now()),
|
|
...(message.kind === 'issue-comment' && message.attachment ? {
|
|
attachment: {
|
|
filename: String(message.attachment.filename || ''),
|
|
contentType: String(message.attachment.contentType || ''),
|
|
stored: true,
|
|
},
|
|
} : {}),
|
|
...(message.kind === 'pull-review' ? {
|
|
decision: String(message.decision || 'comment'),
|
|
expectedHeadSha: String(message.expectedHeadSha || ''),
|
|
comments: Array.isArray(message.comments) ? message.comments.map(comment => ({ ...comment })) : [],
|
|
draftKey: String(message.draftKey || ''),
|
|
progressKey: String(message.progressKey || ''),
|
|
draftFingerprint: String(message.draftFingerprint || ''),
|
|
progressFingerprint: String(message.progressFingerprint || ''),
|
|
} : {}),
|
|
};
|
|
items.push(item);
|
|
write(items, mirror);
|
|
return item;
|
|
}
|
|
|
|
async function enqueueDurably(message) {
|
|
const item = enqueue(message, false);
|
|
if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) {
|
|
return { item, background: false, durability: 'foreground-only' };
|
|
}
|
|
const durableItems = read().map(candidate => candidate.id === item.id && message.attachment ? {
|
|
...candidate,
|
|
attachment: {
|
|
filename: String(message.attachment.filename || ''),
|
|
contentType: String(message.attachment.contentType || ''),
|
|
data: String(message.attachment.data || ''),
|
|
},
|
|
} : candidate);
|
|
try {
|
|
await backgroundSync.reconcile(durableItems, 'authored');
|
|
} catch (error) {
|
|
write(read().filter(candidate => candidate.id !== item.id), false);
|
|
throw error;
|
|
}
|
|
try {
|
|
await backgroundSync.requestSync();
|
|
return { item, background: true, durability: 'background' };
|
|
} catch (error) {
|
|
return { item, background: false, durability: 'foreground-only', error };
|
|
}
|
|
}
|
|
|
|
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;
|
|
delete updated.deliveryState;
|
|
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('/');
|
|
if (item.kind === 'issue-close') {
|
|
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/close';
|
|
}
|
|
if (item.kind === 'pull-review') {
|
|
return 'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review';
|
|
}
|
|
const resource = item.kind === 'pull-comment' ? 'pulls' : 'issues';
|
|
return 'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(item.number) + '/comments';
|
|
}
|
|
|
|
async function sendItem(item, currentLogin) {
|
|
if (!currentLogin || item.ownerLogin !== currentLogin) return { blocked: true };
|
|
if (pending.has(item.id)) return pending.get(item.id);
|
|
const attemptAt = Number(now());
|
|
write(read().map(candidate => candidate.id === item.id ? {
|
|
...candidate, status:'sending', lastAttemptAt:attemptAt,
|
|
} : candidate), false);
|
|
const request = (async () => {
|
|
try {
|
|
let result;
|
|
if (backgroundSync?.send) {
|
|
const delivery = await backgroundSync.send(item, currentLogin);
|
|
if (delivery.attention) {
|
|
const error = delivery.error || new Error('Message needs attention');
|
|
error.status = Number(error.status || 422);
|
|
throw error;
|
|
}
|
|
result = delivery.message;
|
|
} else {
|
|
if (item.kind === 'issue-close') {
|
|
result = await fetchJson(endpoint(item), {
|
|
method: 'PATCH',
|
|
headers: { Accept: 'application/json', 'Idempotency-Key': item.operationId },
|
|
});
|
|
if (result?.state !== 'closed') throw new Error('Issue closure was not confirmed.');
|
|
} else {
|
|
const body = item.kind === 'pull-review' ? {
|
|
body: item.body,
|
|
decision: item.decision,
|
|
expected_head_sha: item.expectedHeadSha,
|
|
comments: item.comments,
|
|
} : { body: item.body };
|
|
result = await fetchJson(endpoint(item), {
|
|
method: 'POST',
|
|
headers: {
|
|
Accept: 'application/json',
|
|
'Content-Type': 'application/json',
|
|
'Idempotency-Key': item.operationId,
|
|
},
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
}
|
|
if (!result) return { blocked: true };
|
|
clearConfirmedReviewState(item);
|
|
discard(item.id);
|
|
return { result };
|
|
} catch (error) {
|
|
const status = Number(error?.status || 0);
|
|
const permanent = status >= 400 && status < 500;
|
|
const attemptError = String(error.message || 'Delivery failed').slice(0, 240);
|
|
if (permanent) {
|
|
write(read().map(candidate => candidate.id === item.id ? {
|
|
...candidate,
|
|
status: 'attention',
|
|
error: String(error.message || 'Message needs attention').slice(0, 240),
|
|
lastAttemptAt: attemptAt,
|
|
lastAttemptError: attemptError,
|
|
...(error.code === 'delivery_uncertain' ? { deliveryState: 'uncertain' } : {}),
|
|
} : candidate));
|
|
} else {
|
|
write(read().map(candidate => candidate.id === item.id ? {
|
|
...candidate, status:'queued', lastAttemptAt:attemptAt, lastAttemptError:attemptError,
|
|
} : 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 flushQueue(currentLogin) {
|
|
const confirmed = [];
|
|
let blocked = 0;
|
|
currentLogin = String(currentLogin || '').trim();
|
|
for (const item of read()) {
|
|
if (item.status === 'attention') continue;
|
|
if (!currentLogin || item.ownerLogin !== currentLogin) { blocked += 1; continue; }
|
|
const outcome = await sendItem(item, currentLogin);
|
|
if (outcome.result) confirmed.push(outcome.result);
|
|
if (outcome.transient) break;
|
|
}
|
|
return { confirmed, remaining: read(), blocked };
|
|
}
|
|
|
|
async function flush(currentLogin) {
|
|
if (coordinator) return coordinator.runExclusive('authored', () => flushQueue(currentLogin));
|
|
return flushQueue(currentLogin);
|
|
}
|
|
|
|
async function retryItem(id, currentLogin) {
|
|
const item = read().find(candidate => candidate.id === id);
|
|
if (!item) return { confirmed: [], remaining: read() };
|
|
currentLogin = String(currentLogin || '').trim();
|
|
if (!currentLogin || item.ownerLogin !== currentLogin) {
|
|
return { confirmed: [], remaining: read(), blocked: 1 };
|
|
}
|
|
let queued = item;
|
|
if (item.status === 'attention') {
|
|
queued = {
|
|
...item,
|
|
operationId: String(makeId()).slice(0, 128),
|
|
status: 'queued',
|
|
};
|
|
delete queued.error;
|
|
delete queued.deliveryState;
|
|
write(read().map(candidate => candidate.id === id ? queued : candidate));
|
|
}
|
|
const outcome = await sendItem(queued, currentLogin);
|
|
return { confirmed: outcome.result ? [outcome.result] : [], remaining: read(), blocked: 0 };
|
|
}
|
|
|
|
async function retry(id, currentLogin) {
|
|
if (coordinator) return coordinator.runExclusive('authored', () => retryItem(id, currentLogin));
|
|
return retryItem(id, currentLogin);
|
|
}
|
|
|
|
function reconcileBackground(records) {
|
|
const statuses = new Map((records || []).map(item => [item.id, item]));
|
|
const items = read().flatMap(item => {
|
|
const background = statuses.get(item.id);
|
|
if (background?.status === 'sent') {
|
|
clearConfirmedReviewState(item);
|
|
return [];
|
|
}
|
|
if (background?.status === 'attention') return [{
|
|
...item,
|
|
status: 'attention',
|
|
error: String(background.error || 'Message needs attention').slice(0, 240),
|
|
...(background.deliveryState ? { deliveryState: background.deliveryState } : {}),
|
|
}];
|
|
return [item];
|
|
});
|
|
write(items);
|
|
return items;
|
|
}
|
|
|
|
return { enqueue, enqueueDurably, update, discard, flush, retry, reconcileBackground, list: () => read().map(item => ({ ...item })) };
|
|
}
|
|
|
|
if (typeof module !== 'undefined' && module.exports) module.exports = createAuthoredOutbox;
|