function createIndexedDbTransaction(indexedDB, dbName = 'stackchain-background-outbox-v1') { let databasePromise; function database() { if (!databasePromise) databasePromise = new Promise((resolve, reject) => { const request = indexedDB.open(dbName, 1); request.onupgradeneeded = () => { if (!request.result.objectStoreNames.contains('issues')) { request.result.createObjectStore('issues', { keyPath: 'id' }); } }; request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); return databasePromise; } const requested = request => new Promise((resolve, reject) => { request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); return async work => { const db = await database(); return new Promise((resolve, reject) => { const transaction = db.transaction('issues', 'readwrite'); const objectStore = transaction.objectStore('issues'); let result; let failed = false; transaction.oncomplete = () => failed ? undefined : resolve(result); transaction.onerror = () => reject(transaction.error); transaction.onabort = () => reject(transaction.error || new Error('Issue outbox transaction aborted')); Promise.resolve(work({ getAll: () => requested(objectStore.getAll()), put: value => requested(objectStore.put(value)), delete: id => requested(objectStore.delete(id)), })).then(value => { result = value; }).catch(error => { failed = true; try { transaction.abort(); } catch (_abortError) { reject(error); } reject(error); }); }); }; } function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, now = () => Date.now(), claimMs = 30000 } = {}) { const transact = transaction || createIndexedDbTransaction(indexedDB); async function reconcile(items, outboxLane = 'issue') { return transact(async records => { const existing = await records.getAll(); const incoming = new Map(items.map(item => [item.id, { ...item, outboxLane }])); for (const current of existing) { if (current.recordType === 'receipt-preference') continue; const currentLane = current.outboxLane || 'issue'; if (currentLane !== outboxLane) continue; const replacement = incoming.get(current.id); if (!replacement) { if (current.status !== 'sending' || Number(current.claimUntil) <= Number(now())) { await records.delete(current.id); } continue; } if ((current.status === 'sending' && Number(current.claimUntil) > Number(now())) || (current.status === 'attention' && replacement.status === 'attention') || current.status === 'sent') { incoming.set(current.id, current); } } for (const item of incoming.values()) await records.put(item); }); } async function claimNext(ownerLogin) { return transact(async records => { const timestamp = Number(now()); const items = await records.getAll(); const item = items.find(candidate => candidate.ownerLogin === ownerLogin && (candidate.status === 'queued' || candidate.status === 'sending') && (candidate.status !== 'sending' || Number(candidate.claimUntil) <= timestamp)); if (!item) return null; const claimed = { ...item, status: 'sending', claimUntil: timestamp + claimMs }; await records.put(claimed); return claimed; }); } async function claim(id, ownerLogin) { return transact(async records => { const timestamp = Number(now()); const item = (await records.getAll()).find(candidate => candidate.id === id); if (!item || item.ownerLogin !== ownerLogin || !['queued', 'sending'].includes(item.status) || (item.status === 'sending' && Number(item.claimUntil) > timestamp)) return null; const claimed = { ...item, status: 'sending', claimUntil: timestamp + claimMs }; await records.put(claimed); return claimed; }); } async function upsert(item) { return transact(async records => { const current = (await records.getAll()).find(candidate => candidate.id === item.id); if (current && ((current.status === 'sending' && Number(current.claimUntil) > Number(now())) || (current.status === 'attention' && item.status === 'attention') || current.status === 'sent')) return current; await records.put({ ...item }); return item; }); } async function update(id, transform) { return transact(async records => { const item = (await records.getAll()).find(candidate => candidate.id === id); if (item) await records.put(transform(item)); }); } function preferenceId(ownerLogin) { return 'receipt-preference:' + String(ownerLogin || '').trim(); } async function setReceiptPreference(ownerLogin, enabled) { const login = String(ownerLogin || '').trim(); if (!login) return; return transact(async records => { const id = preferenceId(login); if (!enabled) return records.delete(id); return records.put({ id, recordType: 'receipt-preference', ownerLogin: login, enabled: true }); }); } async function getReceiptPreference(ownerLogin) { const id = preferenceId(ownerLogin); return transact(async records => Boolean( (await records.getAll()).find(item => item.id === id)?.enabled )); } return { reconcile, upsert, claim, claimNext, complete: id => update(id, item => ({ ...item, status: 'sent', claimUntil: 0 })), release: id => update(id, item => ({ ...item, status: 'queued', claimUntil: 0 })), fail: (id, error) => update(id, item => ({ ...item, status: 'attention', claimUntil: 0, error })), snapshot: () => transact(async records => (await records.getAll()).filter(item => item.recordType !== 'receipt-preference')), countBlocked: ownerLogin => transact(async records => (await records.getAll()).filter(item => item.recordType !== 'receipt-preference' && item.status !== 'sent' && item.ownerLogin !== ownerLogin).length), setReceiptPreference, getReceiptPreference, }; } function createBackgroundIssueSync({ store, fetchJson, base = '' }) { function receiptFor(item, status, delivered = {}) { if (status === 'attention') { return { id: item.id, status, kind: item.kind ? 'message' : 'issue', route: '#/my-work/drafts' }; } if (item.kind === 'update-reply') { return { id: item.id, status, kind: 'message', route: '#/my-work/update/' + encodeURIComponent(item.notificationId) }; } const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/'); if (item.kind === 'issue-comment' || item.kind === 'pull-comment') { const resource = item.kind === 'pull-comment' ? 'pull' : 'issue'; return { id: item.id, status, kind: 'message', route: '#/my-work/' + resource + '/' + repository + '/' + encodeURIComponent(item.number) }; } return { id: item.id, status, kind: 'issue', route: '#/my-work/issue/' + repository + '/' + encodeURIComponent(delivered.number) }; } function deliveryRequest(item) { if (item.kind === 'update-reply') { return authoredRequest('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply', item); } const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/'); if (item.kind === 'issue-comment' || item.kind === 'pull-comment') { const resource = item.kind === 'pull-comment' ? 'pulls' : 'issues'; return authoredRequest( 'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(item.number) + '/comments', item, ); } return { url: base + 'api/v1/repos/' + repository + '/issues', options: { method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'Idempotency-Key': item.operationId, }, body: JSON.stringify({ title: item.title, body: item.body, label_ids: item.labelIds, ...(item.milestoneId ? { milestone_id: item.milestoneId } : {}), ...(item.dueDate ? { due_date: item.dueDate + 'T23:59:59Z' } : {}), }), }, }; } function authoredRequest(url, item) { return { url: base + url, options: { method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'Idempotency-Key': item.operationId, }, body: JSON.stringify({ body: item.body }), }, }; } async function deliver(item) { const request = deliveryRequest(item); try { const delivered = await fetchJson(request.url, request.options); await store.complete(item.id); const receipt = receiptFor(item, 'confirmed', delivered); return item.kind ? { message: delivered, receipt } : { issue: delivered, receipt }; } catch (error) { const status = Number(error?.status || 0); if (status === 401) { await store.release(item.id); throw error; } if (status >= 400 && status < 500) { await store.fail(item.id, String(error?.message || 'Issue needs attention').slice(0, 240)); return { attention: true, error, receipt: receiptFor(item, 'attention') }; } await store.release(item.id); throw error; } } async function send(item, currentLogin) { if (!currentLogin || item.ownerLogin !== currentLogin) return { blocked: true }; await store.upsert(item); const claimed = await store.claim(item.id, currentLogin); if (!claimed) return { busy: true }; return deliver(claimed); } async function flush() { const identity = await fetchJson(base + 'api/v1/background-identity', { headers: { Accept: 'application/json' }, cache: 'no-store', }); const login = String(identity?.login || '').trim(); const confirmed = []; const receipts = []; let attention = 0; if (!login) return { confirmed, blocked: 0, attention, login, receipts }; while (true) { const item = await store.claimNext(login); if (!item) break; const result = await deliver(item); if (result.issue) confirmed.push(result.issue); if (result.message) confirmed.push(result.message); if (result.attention) attention += 1; if (result.receipt) receipts.push(result.receipt); } const blocked = store.countBlocked ? await store.countBlocked(login) : 0; return { confirmed, blocked, attention, login, receipts }; } return { flush, send, reconcile: (items, outboxLane) => store.reconcile(items, outboxLane), snapshot: () => store.snapshot(), setReceiptPreference: (ownerLogin, enabled) => store.setReceiptPreference(ownerLogin, enabled), getReceiptPreference: ownerLogin => store.getReceiptPreference(ownerLogin), }; } createBackgroundIssueSync.createIssueSyncStore = createIssueSyncStore; if (typeof module !== 'undefined' && module.exports) module.exports = createBackgroundIssueSync; if (typeof globalThis !== 'undefined') { globalThis.createBackgroundIssueSync = createBackgroundIssueSync; globalThis.createIssueSyncStore = createIssueSyncStore; }