feat: coordinate offline outboxes across tabs (#242)
This commit is contained in:
1 parent
8b71de70bd
commit
ddd87e9051
8 files changed
+281
-14
No files matched your search
@@ -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
@@ -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 ?
|
||||
|
||||
@@ -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 })) };
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user