feat: coordinate offline outboxes across tabs (#242)
CI / lint (pull_request) Successful in 22s
CI / build-frontend (pull_request) Successful in 5s

This commit is contained in:
timmy committed 2026-08-07 23:34:21 +00:00
1 parent 8b71de70bd
commit ddd87e9051
8 files changed
+281 -14

No files matched your search

+14 -3
View File
@@ -1,4 +1,4 @@
function createAuthoredOutbox({ storage, fetchJson, getOwnerLogin = () => '', createOperationId, now = () => Date.now(), maxItems = 50 }) {
function createAuthoredOutbox({ storage, fetchJson, coordinator, 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)
@@ -16,6 +16,7 @@ function createAuthoredOutbox({ storage, fetchJson, getOwnerLogin = () => '', cr
function write(items) {
storage?.setItem(storageKey, JSON.stringify({ version: 2, items }));
coordinator?.notify('authored');
}
function enqueue(message) {
@@ -112,7 +113,7 @@ function createAuthoredOutbox({ storage, fetchJson, getOwnerLogin = () => '', cr
finally { if (pending.get(item.id) === request) pending.delete(item.id); }
}
async function flush(currentLogin) {
async function flushQueue(currentLogin) {
const confirmed = [];
let blocked = 0;
currentLogin = String(currentLogin || '').trim();
@@ -126,7 +127,12 @@ function createAuthoredOutbox({ storage, fetchJson, getOwnerLogin = () => '', cr
return { confirmed, remaining: read(), blocked };
}
async function retry(id, currentLogin) {
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();
@@ -138,6 +144,11 @@ function createAuthoredOutbox({ storage, fetchJson, getOwnerLogin = () => '', cr
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);
}
return { enqueue, update, discard, flush, retry, list: () => read().map(item => ({ ...item })) };
}
+9 -2
View File
@@ -739,6 +739,7 @@ textarea { resize: vertical; min-height: 120px; }
<script src="static/search-preview.js"></script>
<script src="static/widgets.js"></script>
<script src="static/drafts.js"></script>
<script src="static/outbox-coordinator.js"></script>
<script src="static/issue-outbox.js"></script>
<script src="static/authored-outbox.js"></script>
<script src="static/offline-work.js"></script>
@@ -884,11 +885,14 @@ textarea { resize: vertical; min-height: 120px; }
loadMilestones: item => issueController.loadMilestones(item),
});
const issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage });
const outboxCoordinator = createOutboxCoordinator({ storage: localStorage });
const issueOutbox = createIssueOutbox({
storage: localStorage, fetchJson: fetchReviewJson, getOwnerLogin: () => confirmedOwnerLogin,
storage: localStorage, fetchJson: fetchReviewJson, coordinator: outboxCoordinator,
getOwnerLogin: () => confirmedOwnerLogin,
});
const authoredOutbox = createAuthoredOutbox({
storage: localStorage, fetchJson: fetchReviewJson, getOwnerLogin: () => confirmedOwnerLogin,
storage: localStorage, fetchJson: fetchReviewJson, coordinator: outboxCoordinator,
getOwnerLogin: () => confirmedOwnerLogin,
});
const shareParams = new URLSearchParams(location.search);
const sharedLaunch = {
@@ -900,6 +904,7 @@ textarea { resize: vertical; min-height: 120px; }
issueCapture.stageSharedContent(sharedLaunch) : null;
const pullController = createPullSheet({ fetchJson: fetchReviewJson, storage: localStorage });
const draftInbox = createDraftInbox({ storage: localStorage, getCurrentLogin: () => activeFlushLogin });
outboxCoordinator.subscribe(() => refreshMyWorkView());
const offlineWorkStore = createOfflineWorkStore({ storage: localStorage });
const findWorkController = createFindWork({
fetchJson: fetchReviewJson,
@@ -2027,6 +2032,7 @@ textarea { resize: vertical; min-height: 120px; }
}
function applyOutboxResult(result, openCreated = false) {
if (result.lease_skipped) { refreshMyWorkView(); return; }
(result.confirmed || []).forEach(confirmed => {
if (lastContextSnapshot) lastContextSnapshot.issues = [confirmed].concat(lastContextSnapshot.issues || []);
});
@@ -2053,6 +2059,7 @@ textarea { resize: vertical; min-height: 120px; }
}
function applyAuthoredOutboxResult(result) {
if (result.lease_skipped) { refreshMyWorkView(); return; }
refreshMyWorkView();
const attention = (result.remaining || []).some(item => item.status === 'attention');
qs('#my-work-action-status').textContent = result.confirmed?.length ?
+14 -3
View File
@@ -1,4 +1,4 @@
function createIssueOutbox({ storage, fetchJson, getOwnerLogin = () => '', createOperationId, now = () => Date.now(), maxItems = 20 }) {
function createIssueOutbox({ storage, fetchJson, coordinator, getOwnerLogin = () => '', createOperationId, now = () => Date.now(), maxItems = 20 }) {
const storageKey = 'stackchain.issue-outbox.v1';
const operationId = createOperationId || (() =>
globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2)
@@ -15,6 +15,7 @@ function createIssueOutbox({ storage, fetchJson, getOwnerLogin = () => '', creat
function write(items) {
storage?.setItem(storageKey, JSON.stringify({ version: 2, items }));
coordinator?.notify('issue');
}
function enqueue(draft) {
@@ -102,7 +103,7 @@ function createIssueOutbox({ storage, fetchJson, getOwnerLogin = () => '', creat
finally { if (pending.get(item.id) === request) pending.delete(item.id); }
}
async function flush(currentLogin) {
async function flushQueue(currentLogin) {
const confirmed = [];
let blocked = 0;
currentLogin = String(currentLogin || '').trim();
@@ -116,7 +117,12 @@ function createIssueOutbox({ storage, fetchJson, getOwnerLogin = () => '', creat
return { confirmed, remaining: read(), blocked };
}
async function retry(id, currentLogin) {
async function flush(currentLogin) {
if (coordinator) return coordinator.runExclusive('issue', () => 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();
@@ -128,6 +134,11 @@ function createIssueOutbox({ storage, fetchJson, getOwnerLogin = () => '', creat
return { confirmed: result.issue ? [result.issue] : [], remaining: read(), blocked: 0 };
}
async function retry(id, currentLogin) {
if (coordinator) return coordinator.runExclusive('issue', () => retryItem(id, currentLogin));
return retryItem(id, currentLogin);
}
return { enqueue, update, discard, flush, retry, list: () => read().map(item => ({ ...item })) };
}
+98
View File
@@ -0,0 +1,98 @@
function createOutboxCoordinator({
storage,
locks = globalThis.navigator?.locks,
channelFactory = typeof globalThis.BroadcastChannel === 'function' ?
() => new globalThis.BroadcastChannel('stackchain-outbox-v1') : null,
addStorageListener = globalThis.addEventListener?.bind(globalThis),
removeStorageListener = globalThis.removeEventListener?.bind(globalThis),
tabId = globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2),
now = () => Date.now(),
leaseMs = 15000,
} = {}) {
const changeKey = 'stackchain.outbox-change.v1';
const subscribers = new Set();
const channel = channelFactory ? channelFactory() : null;
let sequence = 0;
function validChange(value) {
return value && (value.queue === 'issue' || value.queue === 'authored') ? value : null;
}
function publish(change) {
subscribers.forEach(listener => listener(change));
}
if (channel) {
channel.onmessage = event => {
const change = validChange(event?.data);
if (change && change.tabId !== tabId) publish(change);
};
}
const onStorage = event => {
if (event?.key !== changeKey || !event.newValue) return;
try {
const change = validChange(JSON.parse(event.newValue));
if (change && change.tabId !== tabId) publish(change);
} catch (_error) { /* Ignore malformed cross-tab signals. */ }
};
if (addStorageListener) addStorageListener('storage', onStorage);
function notify(queue) {
const change = { queue, tabId, changedAt: Number(now()), sequence: ++sequence };
if (!validChange(change)) return;
try { storage?.setItem(changeKey, JSON.stringify(change)); } catch (_error) { /* Broadcast may still work. */ }
try { channel?.postMessage(change); } catch (_error) { /* Storage events remain available. */ }
}
function skipped() {
return { confirmed: [], remaining: [], blocked: 0, lease_skipped: true };
}
async function withFallbackLease(queue, work) {
const key = 'stackchain.outbox-lease.' + queue + '.v1';
const token = tabId + ':' + (++sequence);
const timestamp = Number(now());
try {
const current = JSON.parse(storage?.getItem(key) || 'null');
if (current?.token && Number(current.expiresAt) > timestamp) return skipped();
storage?.setItem(key, JSON.stringify({ token, expiresAt: timestamp + leaseMs }));
const claimed = JSON.parse(storage?.getItem(key) || 'null');
if (claimed?.token !== token) return skipped();
} catch (_error) {
return work();
}
try {
return await work();
} finally {
try {
const current = JSON.parse(storage?.getItem(key) || 'null');
if (current?.token === token) storage?.removeItem(key);
} catch (_error) { /* An expired lease will be reclaimed. */ }
}
}
async function runExclusive(queue, work) {
if (locks?.request) {
return locks.request('stackchain-outbox-' + queue + '-v1', { mode: 'exclusive', ifAvailable: true },
lock => lock ? work() : skipped());
}
return withFallbackLease(queue, work);
}
function subscribe(listener) {
if (typeof listener !== 'function') return () => {};
subscribers.add(listener);
return () => subscribers.delete(listener);
}
function close() {
subscribers.clear();
try { channel?.close(); } catch (_error) { /* No-op. */ }
if (removeStorageListener) removeStorageListener('storage', onStorage);
}
return { runExclusive, notify, subscribe, close };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createOutboxCoordinator;
+2 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'stackchain-dashboard-shell-v9';
const CACHE = 'stackchain-dashboard-shell-v10';
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const BASE = new URL('./', self.location.href).pathname;
const SHELL = [
@@ -11,6 +11,7 @@ const SHELL = [
BASE + 'static/search-preview.js',
BASE + 'static/widgets.js',
BASE + 'static/drafts.js',
BASE + 'static/outbox-coordinator.js',
BASE + 'static/issue-outbox.js',
BASE + 'static/authored-outbox.js',
BASE + 'static/offline-work.js',