fix: complete private outbox purge before sign-out (#293)
CI / lint (pull_request) Successful in 28s
CI / build-frontend (pull_request) Successful in 4s

This commit is contained in:
timmy committed 2026-08-08 11:15:13 +00:00
1 parent fefbe7e084
commit 1f57b75d5d
6 files changed
+170 -16

No files matched your search

+35 -4
View File
@@ -8,7 +8,14 @@ function createIndexedDbTransaction(indexedDB, dbName = 'stackchain-background-o
request.result.createObjectStore('issues', { keyPath: 'id' });
}
};
request.onsuccess = () => resolve(request.result);
request.onsuccess = () => {
const db = request.result;
db.onversionchange = () => {
db.close();
databasePromise = undefined;
};
resolve(db);
};
request.onerror = () => reject(request.error);
});
return databasePromise;
@@ -17,7 +24,7 @@ function createIndexedDbTransaction(indexedDB, dbName = 'stackchain-background-o
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
return async work => {
const transact = async work => {
const db = await database();
return new Promise((resolve, reject) => {
const transaction = db.transaction('issues', 'readwrite');
@@ -38,6 +45,13 @@ function createIndexedDbTransaction(indexedDB, dbName = 'stackchain-background-o
});
});
};
transact.close = async () => {
if (!databasePromise) return;
const db = await databasePromise;
db.close();
databasePromise = undefined;
};
return transact;
}
function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, now = () => Date.now(), claimMs = 30000 } = {}) {
@@ -149,10 +163,13 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
item.status !== 'sent' && item.ownerLogin !== ownerLogin).length),
setReceiptPreference,
getReceiptPreference,
close: () => transact.close?.(),
};
}
function createBackgroundIssueSync({ store, fetchJson, base = '' }) {
let purgeRequested = false;
let activeFlush = null;
function receiptFor(item, status, delivered = {}) {
if (status === 'attention') {
return { id: item.id, status, kind: item.kind ? 'message' : 'issue', route: '#/my-work/drafts' };
@@ -238,6 +255,7 @@ function createBackgroundIssueSync({ store, fetchJson, base = '' }) {
}
async function send(item, currentLogin) {
if (purgeRequested) return { blocked: true };
if (!currentLogin || item.ownerLogin !== currentLogin) return { blocked: true };
await store.upsert(item);
const claimed = await store.claim(item.id, currentLogin);
@@ -245,7 +263,7 @@ function createBackgroundIssueSync({ store, fetchJson, base = '' }) {
return deliver(claimed);
}
async function flush() {
async function runFlush() {
const identity = await fetchJson(base + 'api/v1/background-identity', {
headers: { Accept: 'application/json' }, cache: 'no-store',
});
@@ -267,8 +285,21 @@ function createBackgroundIssueSync({ store, fetchJson, base = '' }) {
return { confirmed, blocked, attention, login, receipts };
}
function flush() {
if (purgeRequested) return Promise.resolve({ confirmed: [], blocked: 0, attention: 0, login: '', receipts: [] });
if (activeFlush) return activeFlush;
activeFlush = runFlush().finally(() => { activeFlush = null; });
return activeFlush;
}
async function purge() {
purgeRequested = true;
if (activeFlush) await activeFlush.catch(() => {});
await store.close?.();
}
return {
flush, send,
flush, send, purge,
reconcile: (items, outboxLane) => store.reconcile(items, outboxLane),
snapshot: () => store.snapshot(),
setReceiptPreference: (ownerLogin, enabled) => store.setReceiptPreference(ownerLogin, enabled),
+9 -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-v24';
const CACHE = 'stackchain-dashboard-shell-v25';
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const SHELL = [
BASE,
@@ -100,6 +100,14 @@ self.addEventListener('sync', event => {
self.addEventListener('message', event => {
if (event.data?.type === 'stackchain-resume-outbox') event.waitUntil(flushAndNotify());
if (event.data?.type === 'stackchain-purge-outbox') event.waitUntil((async () => {
try {
await issueSync.purge();
event.ports?.[0]?.postMessage({ ok: true });
} catch (error) {
event.ports?.[0]?.postMessage({ ok: false, error: String(error?.message || 'Outbox purge failed.') });
}
})());
});
self.addEventListener('notificationclick', event => {
+42 -3
View File
@@ -13,9 +13,14 @@
indexedDB: root.indexedDB,
caches: root.caches,
serviceWorker: root.navigator?.serviceWorker,
MessageChannel: root.MessageChannel,
location: root.location,
confirmAction: message => root.confirm(message),
onExpired: () => root.dispatchEvent(new CustomEvent('stackchain:session-expired')),
onClearError: error => {
root.dispatchEvent(new CustomEvent('stackchain:device-clear-failed', { detail: error.message }));
root.alert('Signed out, but private queued work could not be cleared. Close other Stackchain tabs and clear this site’s data.');
},
});
root.fetch = boundary.fetch;
const attach = () => {
@@ -30,8 +35,9 @@
root.stackchainSession = boundary;
}
})(typeof window !== 'undefined' ? window : this, function createSessionBoundary({
cookie, origin, base, fetchImpl, localStorage, sessionStorage, indexedDB, caches, serviceWorker, location, confirmAction,
cookie, origin, base, fetchImpl, localStorage, sessionStorage, indexedDB, caches, serviceWorker, MessageChannel, location, confirmAction,
onExpired = () => {},
onClearError = () => {},
}) {
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
@@ -73,11 +79,44 @@
} catch (_error) { /* Cookie invalidation still protects server data. */ }
}
async function stopWorkerOutbox() {
const registration = await serviceWorker?.ready;
if (!registration?.active || !MessageChannel) return;
await new Promise((resolve, reject) => {
const channel = new MessageChannel();
const timeout = setTimeout(() => reject(new Error('Background outbox purge timed out.')), 3000);
channel.port1.onmessage = event => {
clearTimeout(timeout);
if (event.data?.ok) resolve();
else reject(new Error(event.data?.error || 'Background outbox purge failed.'));
};
registration.active.postMessage({ type: 'stackchain-purge-outbox' }, [channel.port2]);
});
}
function deletePrivateOutbox() {
if (!indexedDB) return Promise.resolve();
return new Promise((resolve, reject) => {
let request;
try { request = indexedDB.deleteDatabase('stackchain-background-outbox-v1'); }
catch (error) { reject(error); return; }
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error || new Error('IndexedDB deletion failed.'));
request.onblocked = () => reject(new Error('IndexedDB deletion was blocked.'));
});
}
async function clearPrivateDeviceData() {
removeDashboardStorage(localStorage);
if (sessionStorage !== localStorage) removeDashboardStorage(sessionStorage);
try { indexedDB?.deleteDatabase('stackchain-background-outbox-v1'); }
catch (_error) { /* Continue clearing other dashboard state. */ }
try {
await stopWorkerOutbox();
await deletePrivateOutbox();
} catch (_error) {
const error = new Error('Could not clear private queued work from this device.');
onClearError(error);
throw error;
}
try {
const keys = await caches?.keys?.() || [];
await Promise.all(keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key)));