feat: deliver authored messages after app close (#252)
CI / lint (pull_request) Successful in 23s
CI / build-frontend (pull_request) Successful in 4s

This commit is contained in:
timmy committed 2026-08-08 02:11:26 +00:00
1 parent 5f6dd265a5
commit c3e8ead47b
8 files changed
+235 -31

No files matched your search

+44 -11
View File
@@ -1,4 +1,4 @@
function createAuthoredOutbox({ storage, fetchJson, coordinator, getOwnerLogin = () => '', createOperationId, now = () => Date.now(), maxItems = 50 }) {
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)
@@ -17,6 +17,11 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, getOwnerLogin =
function write(items) {
storage?.setItem(storageKey, JSON.stringify({ version: 2, items }));
coordinator?.notify('authored');
if (backgroundSync?.reconcile) {
Promise.resolve(backgroundSync.reconcile(items, 'authored'))
.then(() => items.length ? backgroundSync.requestSync?.() : undefined)
.catch(() => { /* Foreground reconnect remains the compatibility fallback. */ });
}
}
function enqueue(message) {
@@ -84,15 +89,27 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, getOwnerLogin =
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 }),
});
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 {
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 }),
});
}
if (!result) return { blocked: true };
discard(item.id);
return { result };
} catch (error) {
@@ -149,7 +166,23 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, getOwnerLogin =
return retryItem(id, currentLogin);
}
return { enqueue, update, discard, flush, retry, list: () => read().map(item => ({ ...item })) };
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') return [];
if (background?.status === 'attention') return [{
...item,
status: 'attention',
error: String(background.error || 'Message needs attention').slice(0, 240),
}];
return [item];
});
write(items);
return items;
}
return { enqueue, update, discard, flush, retry, reconcileBackground, list: () => read().map(item => ({ ...item })) };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createAuthoredOutbox;
+35 -7
View File
@@ -43,11 +43,13 @@ function createIndexedDbTransaction(indexedDB, dbName = 'stackchain-background-o
function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, now = () => Date.now(), claimMs = 30000 } = {}) {
const transact = transaction || createIndexedDbTransaction(indexedDB);
async function reconcile(items) {
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 }]));
const incoming = new Map(items.map(item => [item.id, { ...item, outboxLane }]));
for (const current of existing) {
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())) {
@@ -125,8 +127,18 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
}
function createBackgroundIssueSync({ store, fetchJson, base = '' }) {
function issueRequest(item) {
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: {
@@ -147,12 +159,27 @@ function createBackgroundIssueSync({ store, fetchJson, base = '' }) {
};
}
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 = issueRequest(item);
const request = deliveryRequest(item);
try {
const issue = await fetchJson(request.url, request.options);
const delivered = await fetchJson(request.url, request.options);
await store.complete(item.id);
return { issue };
return item.kind ? { message: delivered } : { issue: delivered };
} catch (error) {
const status = Number(error?.status || 0);
if (status >= 400 && status < 500) {
@@ -185,6 +212,7 @@ function createBackgroundIssueSync({ store, fetchJson, base = '' }) {
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;
}
const blocked = store.countBlocked ? await store.countBlocked(login) : 0;
@@ -193,7 +221,7 @@ function createBackgroundIssueSync({ store, fetchJson, base = '' }) {
return {
flush, send,
reconcile: items => store.reconcile(items),
reconcile: (items, outboxLane) => store.reconcile(items, outboxLane),
snapshot: () => store.snapshot(),
};
}
+7 -4
View File
@@ -913,14 +913,17 @@ textarea { resize: vertical; min-height: 120px; }
backgroundSync: backgroundIssueSync,
getOwnerLogin: () => confirmedOwnerLogin,
});
if (backgroundIssueSync) {
backgroundIssueSync.snapshot().then(records => issueOutbox.reconcileBackground(records))
.catch(() => { /* The foreground localStorage outbox remains available. */ });
}
const authoredOutbox = createAuthoredOutbox({
storage: localStorage, fetchJson: fetchReviewJson, coordinator: outboxCoordinator,
backgroundSync: backgroundIssueSync,
getOwnerLogin: () => confirmedOwnerLogin,
});
if (backgroundIssueSync) {
backgroundIssueSync.snapshot().then(records => {
issueOutbox.reconcileBackground(records);
authoredOutbox.reconcileBackground(records);
}).catch(() => { /* The foreground localStorage outboxes remain available. */ });
}
const shareParams = new URLSearchParams(location.search);
const sharedLaunch = {
title: shareParams.get('title') || '',
+1 -1
View File
@@ -1,6 +1,6 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v12';
const CACHE = 'stackchain-dashboard-shell-v13';
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const SHELL = [
BASE,