feat: move offline work into IndexedDB (Closes #525)
All checks were successful
CI / lint (pull_request) Successful in 1m8s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-11 00:34:25 +00:00
parent 7cadb36b41
commit 0a96dfb173
20 changed files with 451 additions and 69 deletions

View File

@ -386,8 +386,12 @@ newest 20 comments. Requested-review records also retain the head SHA, CI state,
newest 20 prior reviews, and at most 50 sanitized file previews with 400 diff lines newest 20 prior reviews, and at most 50 sanitized file previews with 400 diff lines
per file. A previously opened unread update is retained by notification per file. A previously opened unread update is retained by notification
identity with allowlisted subject context and its newest 20 conversation messages. identity with allowlisted subject context and its newest 20 conversation messages.
This account-bound cache is limited to ten records across all detail kinds; credentials, The snapshot and each account-bound detail are committed as separate IndexedDB records,
repository catalogs, events, raw patches, and complete API responses are excluded. so large review previews do not consume the small synchronous localStorage quota or require
rewriting the whole cache. Only the opt-in preference remains in localStorage. Existing v1
localStorage payloads migrate after a successful IndexedDB commit. The detail cache remains
limited to ten records across all detail kinds; credentials, repository catalogs, events, raw
patches, and complete API responses are excluded.
A cold offline launch labels the saved time. Cached Today details open in the existing A cold offline launch labels the saved time. Cached Today details open in the existing
phone sheet, where comments can enter the account-bound durable outbox. For issue and phone sheet, where comments can enter the account-bound durable outbox. For issue and
non-review pull details, planning, assignment, review, merge, and close controls remain disabled non-review pull details, planning, assignment, review, merge, and close controls remain disabled
@ -401,8 +405,8 @@ queued operation and its matching SHA-scoped draft and progress.
Cached unread updates use the same phone conversation sheet and replies enter the Cached unread updates use the same phone conversation sheet and replies enter the
account-bound durable outbox, while mark read, ownership, deferral, and older-message loading remain disabled until reconnection. account-bound durable outbox, while mark read, ownership, deferral, and older-message loading remain disabled until reconnection.
Cards without a saved detail explain that reconnection is required. **Clear offline Cards without a saved detail explain that reconnection is required. **Clear offline
work data** deletes both stores, and opting out or seven-day expiry deletes them work data**, opt-out, sign-out, remote revocation, and absolute session expiry delete the
automatically. IndexedDB records; seven-day expiry removes stale records automatically.
API responses and mutations are never cached by the service worker. New issue captures, API responses and mutations are never cached by the service worker. New issue captures,
issue comments, pull-request comments, and unread-update replies use bounded local issue comments, pull-request comments, and unread-update replies use bounded local

View File

@ -1,4 +1,4 @@
(function(){ (async function(){
const qs = (s, el=document) => el.querySelector(s); const qs = (s, el=document) => el.querySelector(s);
const fmt = (d) => new Date(d).toLocaleString(); const fmt = (d) => new Date(d).toLocaleString();
const cardPlanning = createCardPlanning(document); const cardPlanning = createCardPlanning(document);
@ -399,7 +399,8 @@
const pullController = createPullSheet({ fetchJson: fetchReviewJson, storage: localStorage }); const pullController = createPullSheet({ fetchJson: fetchReviewJson, storage: localStorage });
const draftInbox = createDraftInbox({ storage: localStorage, getCurrentLogin: () => activeFlushLogin }); const draftInbox = createDraftInbox({ storage: localStorage, getCurrentLogin: () => activeFlushLogin });
outboxCoordinator.subscribe(() => refreshMyWorkView()); outboxCoordinator.subscribe(() => refreshMyWorkView());
const offlineWorkStore = createOfflineWorkStore({ storage: localStorage }); const offlineWorkStore = createOfflineWorkStore({ storage: localStorage, indexedDB:window.indexedDB });
await offlineWorkStore.ready();
function renderOfflineTodayStatus(status) { function renderOfflineTodayStatus(status) {
const container = qs('#offline-today-readiness'); const container = qs('#offline-today-readiness');
const label = qs('#offline-today-status'); const label = qs('#offline-today-status');
@ -656,7 +657,7 @@
setOfflineUpdateControls(false); setOfflineUpdateControls(false);
qs('#keep-update-unread').focus(); qs('#keep-update-unread').focus();
}, },
onDetail: detail => { onDetail: async detail => {
qs('#update-sheet-title').textContent = detail.title || 'Unread update'; qs('#update-sheet-title').textContent = detail.title || 'Unread update';
qs('#update-subject-type').textContent = detail.subject_type || 'Update'; qs('#update-subject-type').textContent = detail.subject_type || 'Update';
qs('#update-subject-state').textContent = detail.state || ''; qs('#update-subject-state').textContent = detail.state || '';
@ -667,7 +668,7 @@
} else { } else {
updateOwnership.open(detail, selectedUpdate); updateOwnership.open(detail, selectedUpdate);
if (offlineWorkStore.enabled() && confirmedOwnerLogin && selectedUpdate) { if (offlineWorkStore.enabled() && confirmedOwnerLogin && selectedUpdate) {
offlineWorkStore.saveDetail(confirmedOwnerLogin, selectedUpdate, detail); await offlineWorkStore.saveDetail(confirmedOwnerLogin, selectedUpdate, detail);
} }
} }
qs('#retry-update-load').hidden = true; qs('#retry-update-load').hidden = true;
@ -1549,11 +1550,11 @@
setClock(); setClock();
} }
function handleContextError(e) { async function handleContextError(e) {
console.error('context failed', e); console.error('context failed', e);
liveMode = false; liveMode = false;
activeFlushLogin = ''; activeFlushLogin = '';
if (!hasContextSnapshot && hydrateOfflineWork('outage')) return; if (!hasContextSnapshot && await hydrateOfflineWork('outage')) return;
const timeoutStatus = e.name === 'TimeoutError' ? const timeoutStatus = e.name === 'TimeoutError' ?
'Update delayed · showing last snapshot' : 'Update failed · showing last snapshot'; 'Update delayed · showing last snapshot' : 'Update failed · showing last snapshot';
setStatus(hasContextSnapshot ? timeoutStatus : 'Unavailable'); setStatus(hasContextSnapshot ? timeoutStatus : 'Unavailable');
@ -2268,7 +2269,7 @@
qs('#issue-sheet-status').textContent = 'Offline copy · saved ' + fmt(detail.saved_at) + qs('#issue-sheet-status').textContent = 'Offline copy · saved ' + fmt(detail.saved_at) +
' · comments queue for sync'; ' · comments queue for sync';
} else if (offlineWorkStore.enabled() && confirmedOwnerLogin && todayWork.contains(item)) { } else if (offlineWorkStore.enabled() && confirmedOwnerLogin && todayWork.contains(item)) {
offlineWorkStore.saveDetail(confirmedOwnerLogin, item, detail); await offlineWorkStore.saveDetail(confirmedOwnerLogin, item, detail);
} }
} catch (error) { } catch (error) {
if (selectedIssue !== item) return; if (selectedIssue !== item) return;
@ -2445,7 +2446,7 @@
qs('#pull-sheet-status').textContent = 'Offline copy · saved ' + fmt(detail.saved_at) + qs('#pull-sheet-status').textContent = 'Offline copy · saved ' + fmt(detail.saved_at) +
' · comments queue for sync'; ' · comments queue for sync';
} else if (offlineWorkStore.enabled() && confirmedOwnerLogin && todayWork.contains(item)) { } else if (offlineWorkStore.enabled() && confirmedOwnerLogin && todayWork.contains(item)) {
offlineWorkStore.saveDetail(confirmedOwnerLogin, item, detail); await offlineWorkStore.saveDetail(confirmedOwnerLogin, item, detail);
} }
} catch (error) { } catch (error) {
if (selectedPull !== item) return; if (selectedPull !== item) return;
@ -3117,7 +3118,7 @@
qs('#gitea-events-status').textContent = message; qs('#gitea-events-status').textContent = message;
} }
function renderLiveSnapshot(snapshot, changedSections = ['context', 'events', 'notifications']) { async function renderLiveSnapshot(snapshot, changedSections = ['context', 'events', 'notifications']) {
setOfflineWorkMode(false); setOfflineWorkMode(false);
offlineStatus.hidden = true; offlineStatus.hidden = true;
const contextFreshness = snapshot.freshness?.sections?.context; const contextFreshness = snapshot.freshness?.sections?.context;
@ -3171,12 +3172,13 @@
if (contextFreshness?.stale) markMyWorkStale(); if (contextFreshness?.stale) markMyWorkStale();
else if (!notificationsFresh) markNotificationsStale(); else if (!notificationsFresh) markNotificationsStale();
if (!contextFreshness?.stale && notificationsFresh) { if (!contextFreshness?.stale && notificationsFresh) {
offlineWorkStore.save({ const admitted = await offlineWorkStore.save({
...snapshot.context, ...snapshot.context,
notifications: snapshot.notifications, notifications: snapshot.notifications,
notification_pagination: snapshot.notification_pagination, notification_pagination: snapshot.notification_pagination,
}); });
updateOfflineWorkControls(); await updateOfflineWorkControls(admitted === false ?
'Offline saving unavailable · retry after reconnect.' : undefined);
warmTodayOffline(); warmTodayOffline();
} }
flushIssueOutbox(); flushIssueOutbox();
@ -4690,8 +4692,8 @@
const contextPoller = createContextPoller({ const contextPoller = createContextPoller({
fetchContext: fetchLiveSnapshot, fetchContext: fetchLiveSnapshot,
onSnapshot: renderLiveSnapshot, onSnapshot: renderLiveSnapshot,
onError: error => { onError: async error => {
handleContextError(error); await handleContextError(error);
setEventStreamStatus('Update failed · showing last activity'); setEventStreamStatus('Update failed · showing last activity');
}, },
isHidden: () => document.hidden, isHidden: () => document.hidden,
@ -4704,9 +4706,9 @@
const offlineWorkStatus = qs('#offline-work-status'); const offlineWorkStatus = qs('#offline-work-status');
const deliveryReceipts = qs('#delivery-receipts'); const deliveryReceipts = qs('#delivery-receipts');
const deliveryReceiptStatus = qs('#delivery-receipt-status'); const deliveryReceiptStatus = qs('#delivery-receipt-status');
function updateOfflineWorkControls(message) { async function updateOfflineWorkControls(message) {
keepWorkOffline.checked = offlineWorkStore.enabled(); keepWorkOffline.checked = offlineWorkStore.enabled();
const saved = offlineWorkStore.load(); const saved = await offlineWorkStore.load();
offlineWorkStatus.textContent = message || (saved ? 'Saved ' + fmt(saved.saved_at) + ' · expires after 7 days.' : offlineWorkStatus.textContent = message || (saved ? 'Saved ' + fmt(saved.saved_at) + ' · expires after 7 days.' :
(keepWorkOffline.checked ? 'Waiting for a healthy live refresh.' : 'Off · no work data is stored.')); (keepWorkOffline.checked ? 'Waiting for a healthy live refresh.' : 'Off · no work data is stored.'));
} }
@ -4738,8 +4740,8 @@
.forEach(button => { button.disabled = true; }); .forEach(button => { button.disabled = true; });
} }
} }
function hydrateOfflineWork(mode = 'offline') { async function hydrateOfflineWork(mode = 'offline') {
const saved = offlineWorkStore.load(); const saved = await offlineWorkStore.load();
if (!saved) return false; if (!saved) return false;
const outage = mode === 'outage'; const outage = mode === 'outage';
confirmedOwnerLogin = String(saved.user?.login || '').trim(); confirmedOwnerLogin = String(saved.user?.login || '').trim();
@ -4768,11 +4770,11 @@
offlineStatus.hidden = false; offlineStatus.hidden = false;
return true; return true;
} }
function showOfflineStatus() { async function showOfflineStatus() {
activeFlushLogin = ''; activeFlushLogin = '';
offlineStatus.hidden = false; offlineStatus.hidden = false;
setStatus('Offline'); setStatus('Offline');
if (!hasContextSnapshot) hydrateOfflineWork(); if (!hasContextSnapshot) await hydrateOfflineWork();
} }
function reconnectLiveData() { function reconnectLiveData() {
offlineStatus.hidden = true; offlineStatus.hidden = true;
@ -4782,13 +4784,18 @@
if (selectedReview && offlineReview) openReviewSheet(selectedReview, reviewTrigger); if (selectedReview && offlineReview) openReviewSheet(selectedReview, reviewTrigger);
}); });
} }
keepWorkOffline.addEventListener('change', () => { keepWorkOffline.addEventListener('change', async () => {
offlineWorkStore.setEnabled(keepWorkOffline.checked); offlineWorkStore.setEnabled(keepWorkOffline.checked);
if (keepWorkOffline.checked && liveMode && lastContextSnapshot) { if (keepWorkOffline.checked && liveMode && lastContextSnapshot) {
offlineWorkStore.save({ ...lastContextSnapshot, notifications:lastNotifications, const admitted = await offlineWorkStore.save({ ...lastContextSnapshot, notifications:lastNotifications,
notification_pagination:notificationPagination }); notification_pagination:notificationPagination });
if (admitted === false) {
await updateOfflineWorkControls('Offline saving unavailable · retry after reconnect.');
return;
} }
updateOfflineWorkControls(keepWorkOffline.checked ? 'Offline saving enabled.' : 'Offline work data cleared.'); }
if (!keepWorkOffline.checked) await offlineWorkStore.clear();
await updateOfflineWorkControls(keepWorkOffline.checked ? 'Offline saving enabled.' : 'Offline work data cleared.');
if (keepWorkOffline.checked) warmTodayOffline(); if (keepWorkOffline.checked) warmTodayOffline();
else offlineToday.cancel(); else offlineToday.cancel();
}); });
@ -4804,18 +4811,18 @@
await updateDeliveryReceiptControls(enabled ? 'Background delivery receipts enabled.' : await updateDeliveryReceiptControls(enabled ? 'Background delivery receipts enabled.' :
(Notification.permission === 'denied' ? 'Notifications are blocked in browser settings.' : 'Background delivery receipts disabled.')); (Notification.permission === 'denied' ? 'Notifications are blocked in browser settings.' : 'Background delivery receipts disabled.'));
}); });
qs('#clear-offline-work').addEventListener('click', () => { qs('#clear-offline-work').addEventListener('click', async () => {
offlineWorkStore.clear(); await offlineWorkStore.clear();
offlineToday.cancel(); offlineToday.cancel();
renderOfflineTodayStatus({ total:0, ready:0, failed:0, pending:0 }); renderOfflineTodayStatus({ total:0, ready:0, failed:0, pending:0 });
updateOfflineWorkControls('Offline work data cleared.'); await updateOfflineWorkControls('Offline work data cleared.');
}); });
qs('#retry-offline-today').addEventListener('click', () => qs('#retry-offline-today').addEventListener('click', () =>
offlineToday.retry(confirmedOwnerLogin, todayMyWork) offlineToday.retry(confirmedOwnerLogin, todayMyWork)
); );
updateOfflineWorkControls(); await updateOfflineWorkControls();
updateDeliveryReceiptControls(); updateDeliveryReceiptControls();
if (!navigator.onLine) showOfflineStatus(); if (!navigator.onLine) await showOfflineStatus();
window.addEventListener('offline', showOfflineStatus); window.addEventListener('offline', showOfflineStatus);
window.addEventListener('online', reconnectLiveData); window.addEventListener('online', reconnectLiveData);

View File

@ -31,14 +31,18 @@
} }
const previousFailed = failed; const previousFailed = failed;
const readyKeys = new Set(items.filter(item => loadSavedDetail(login, item)).map(itemKey)); const savedDetails = new Map();
await Promise.all(items.map(async item => {
savedDetails.set(itemKey(item), await loadSavedDetail(login, item));
}));
const readyKeys = new Set(items.filter(item => savedDetails.get(itemKey(item))).map(itemKey));
const snapshot = pending => ({ const snapshot = pending => ({
total: items.length, ready: readyKeys.size, failed: failed.size, pending, total: items.length, ready: readyKeys.size, failed: failed.size, pending,
}); });
const candidates = items.filter(item => { const candidates = items.filter(item => {
const key = itemKey(item); const key = itemKey(item);
if (onlyFailed && !previousFailed.has(key)) return false; if (onlyFailed && !previousFailed.has(key)) return false;
const saved = loadSavedDetail(login, item); const saved = savedDetails.get(key);
return onlyFailed || !saved || saved.source_updated_at !== item.updated_at; return onlyFailed || !saved || saved.source_updated_at !== item.updated_at;
}); });
failed = new Set(); failed = new Set();
@ -51,8 +55,9 @@
try { try {
const detail = await loadDetail(item); const detail = await loadDetail(item);
if (runGeneration !== generation) return; if (runGeneration !== generation) return;
const saved = saveDetail(login, item, { ...detail, source_updated_at: item.updated_at }); const saved = await saveDetail(login, item, { ...detail, source_updated_at: item.updated_at });
if (saved !== false) readyKeys.add(itemKey(item)); if (saved === false) throw new Error('Offline detail was not durably admitted.');
readyKeys.add(itemKey(item));
} catch (_error) { } catch (_error) {
if (runGeneration === generation) failed.add(itemKey(item)); if (runGeneration === generation) failed.add(itemKey(item));
} }

View File

@ -27,6 +27,54 @@
]; ];
const REVIEW_FIELDS = ['id', 'state', 'body', 'submitted_at']; const REVIEW_FIELDS = ['id', 'state', 'body', 'submitted_at'];
function createIndexedDbTransaction(indexedDB, dbName = 'stackchain-offline-work-v2') {
if (!indexedDB) return null;
let databasePromise;
function database() {
if (!databasePromise) databasePromise = new Promise((resolve, reject) => {
const request = indexedDB.open(dbName, 1);
request.onupgradeneeded = () => {
if (!request.result.objectStoreNames.contains('work')) {
request.result.createObjectStore('work', { keyPath: 'id' });
}
};
request.onsuccess = () => {
const db = request.result;
db.onversionchange = () => { db.close(); databasePromise = undefined; };
resolve(db);
};
request.onerror = () => reject(request.error || new Error('Offline work database failed to open.'));
});
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('work', 'readwrite');
const store = transaction.objectStore('work');
let result;
let workError;
transaction.oncomplete = () => workError ? undefined : resolve(result);
transaction.onerror = () => reject(transaction.error);
transaction.onabort = () => reject(workError || transaction.error || new Error('Offline work transaction aborted.'));
Promise.resolve(work({
get: key => requested(store.get(key)),
getAll: () => requested(store.getAll()),
put: value => requested(store.put(value)),
delete: key => requested(store.delete(key)),
clear: () => requested(store.clear()),
})).then(value => { result = value; }).catch(error => {
workError = error;
try { transaction.abort(); } catch (_) { reject(error); }
});
});
};
}
function pick(source, fields) { function pick(source, fields) {
const output = {}; const output = {};
fields.forEach(field => { fields.forEach(field => {
@ -36,14 +84,94 @@
} }
function createOfflineWorkStore({ function createOfflineWorkStore({
storage, now = () => new Date(), maxAgeMs = DEFAULT_MAX_AGE_MS, maxDetails = 10, storage, indexedDB, transaction, now = () => new Date(), maxAgeMs = DEFAULT_MAX_AGE_MS,
maxDetails = 10,
}) { }) {
const transact = transaction || createIndexedDbTransaction(indexedDB);
let migrationPromise;
let recordCache = null;
function migrateLegacy() {
if (!transact) return Promise.resolve(true);
if (!migrationPromise) migrationPromise = (async () => {
let snapshot = null;
let details = [];
try {
snapshot = JSON.parse(storage.getItem(SNAPSHOT_KEY) || 'null');
const parsedDetails = JSON.parse(storage.getItem(DETAILS_KEY) || '[]');
details = Array.isArray(parsedDetails) ? parsedDetails : [];
} catch (_) {
return false;
}
if (!snapshot && !details.length) return true;
await transact(async records => {
if (snapshot && !(await records.get('snapshot'))) {
await records.put({ ...snapshot, id:'snapshot' });
}
for (const record of details.slice(-Math.max(1, maxDetails))) {
if (!record?.key || !record?.user_login || !record?.data) continue;
const id = 'detail:' + record.user_login + ':' + record.key;
if (!(await records.get(id))) await records.put({ ...record, id });
}
});
storage.removeItem(SNAPSHOT_KEY);
storage.removeItem(DETAILS_KEY);
return true;
})().catch(() => false);
return migrationPromise;
}
function ready() {
if (!transact) return Promise.resolve(true);
if (recordCache) return Promise.resolve(true);
return migrateLegacy().then(migrated => {
if (!migrated) {
recordCache = new Map();
try {
const snapshot = JSON.parse(storage.getItem(SNAPSHOT_KEY) || 'null');
if (snapshot) recordCache.set('snapshot', { ...snapshot, id:'snapshot' });
const details = JSON.parse(storage.getItem(DETAILS_KEY) || '[]');
if (Array.isArray(details)) details.forEach(record => {
if (!record?.key || !record?.user_login || !record?.data) return;
const id = 'detail:' + record.user_login + ':' + record.key;
recordCache.set(id, { ...record, id });
});
} catch (_) { recordCache.clear(); }
return false;
}
return transact(async records => {
const currentTime = now().getTime();
const valid = [];
for (const record of await records.getAll()) {
const savedAt = Date.parse(record?.saved_at || '');
const validShape = record?.id === 'snapshot' ?
record.version === VERSION && record.user_login && record.data :
record?.id?.startsWith('detail:') && record.user_login && record.key && record.data;
if (!validShape || !Number.isFinite(savedAt) || currentTime - savedAt > maxAgeMs) {
if (record?.id) await records.delete(record.id);
} else valid.push(record);
}
recordCache = new Map(valid.map(record => [record.id, record]));
return true;
});
}).catch(() => false);
}
function enabled() { function enabled() {
try { return storage.getItem(ENABLED_KEY) === 'true'; } try { return storage.getItem(ENABLED_KEY) === 'true'; }
catch (_) { return false; } catch (_) { return false; }
} }
function clear() { function clear() {
if (transact) {
try {
storage.removeItem(SNAPSHOT_KEY);
storage.removeItem(DETAILS_KEY);
} catch (_) { /* IndexedDB remains the source of truth. */ }
migrationPromise = Promise.resolve(true);
return transact(async records => {
await records.clear();
recordCache = new Map();
return true;
}).catch(() => false);
}
try { try {
storage.removeItem(SNAPSHOT_KEY); storage.removeItem(SNAPSHOT_KEY);
storage.removeItem(DETAILS_KEY); storage.removeItem(DETAILS_KEY);
@ -104,6 +232,22 @@
saved_at: now().toISOString(), saved_at: now().toISOString(),
data, data,
}; };
if (transact) {
record.id = 'detail:' + login + ':' + key;
return ready().then(() => transact(async records => {
const existing = (await records.getAll()).filter(candidate =>
candidate?.id?.startsWith('detail:') && candidate.id !== record.id
);
await records.put(record);
const overflow = existing.concat(record).sort((a, b) =>
String(a.saved_at).localeCompare(String(b.saved_at))
).slice(0, -Math.max(1, maxDetails));
for (const candidate of overflow) await records.delete(candidate.id);
overflow.forEach(candidate => recordCache.delete(candidate.id));
recordCache.set(record.id, record);
return true;
})).catch(() => false);
}
const records = readDetails().filter(candidate => const records = readDetails().filter(candidate =>
!(candidate?.key === key && candidate?.user_login === login) !(candidate?.key === key && candidate?.user_login === login)
); );
@ -117,6 +261,19 @@
const key = detailKey(item); const key = detailKey(item);
login = String(login || '').trim(); login = String(login || '').trim();
if (!login || !key) return null; if (!login || !key) return null;
if (transact) {
const id = 'detail:' + login + ':' + key;
if (!recordCache) return ready().then(() => loadDetail(login, item));
const record = recordCache.get(id);
if (!record) return null;
const savedAt = Date.parse(record.saved_at || '');
if (!record.data || !Number.isFinite(savedAt) || now().getTime() - savedAt > maxAgeMs) {
recordCache.delete(id);
transact(async records => { await records.delete(id); }).catch(() => {});
return null;
}
return { ...record.data, saved_at: record.saved_at };
}
const records = readDetails(); const records = readDetails();
const currentTime = now().getTime(); const currentTime = now().getTime();
const valid = records.filter(record => { const valid = records.filter(record => {
@ -142,6 +299,7 @@
function save(snapshot) { function save(snapshot) {
if (!enabled() || !snapshot?.user?.login) return false; if (!enabled() || !snapshot?.user?.login) return false;
const record = { const record = {
id: 'snapshot',
version: VERSION, version: VERSION,
user_login: String(snapshot.user.login), user_login: String(snapshot.user.login),
saved_at: now().toISOString(), saved_at: now().toISOString(),
@ -154,12 +312,36 @@
notification_pagination: snapshot.notification_pagination || {}, notification_pagination: snapshot.notification_pagination || {},
}, },
}; };
if (transact) {
return ready().then(() => transact(async records => {
await records.put(record);
recordCache.set(record.id, record);
return true;
})).catch(() => false);
}
try { storage.setItem(SNAPSHOT_KEY, JSON.stringify(record)); } try { storage.setItem(SNAPSHOT_KEY, JSON.stringify(record)); }
catch (_) { return false; } catch (_) { return false; }
return true; return true;
} }
function load(expectedLogin) { function load(expectedLogin) {
if (transact) {
if (!recordCache) return ready().then(() => load(expectedLogin));
const record = recordCache.get('snapshot');
const savedAt = Date.parse(record?.saved_at || '');
const invalid = record?.version !== VERSION || !record?.user_login || !record?.data ||
!Number.isFinite(savedAt);
const expired = Number.isFinite(savedAt) && now().getTime() - savedAt > maxAgeMs;
if (invalid || expired) {
if (record) {
recordCache.delete('snapshot');
transact(async records => { await records.delete('snapshot'); }).catch(() => {});
}
return null;
}
if (expectedLogin && record.user_login !== expectedLogin) return null;
return { ...record.data, saved_at: record.saved_at };
}
let record; let record;
try { record = JSON.parse(storage.getItem(SNAPSHOT_KEY) || 'null'); } try { record = JSON.parse(storage.getItem(SNAPSHOT_KEY) || 'null'); }
catch (_) { clear(); return null; } catch (_) { clear(); return null; }
@ -175,7 +357,7 @@
return { ...record.data, saved_at: record.saved_at }; return { ...record.data, saved_at: record.saved_at };
} }
return { enabled, setEnabled, save, load, saveDetail, loadDetail, clear }; return { enabled, setEnabled, ready, save, load, saveDetail, loadDetail, clear };
} }
return createOfflineWorkStore; return createOfflineWorkStore;

View File

@ -36,11 +36,11 @@
}); });
} }
function deletePrivateOutbox() { function deletePrivateDatabase(name) {
if (!indexedDB) return Promise.resolve(); if (!indexedDB) return Promise.resolve();
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let request; let request;
try { request = indexedDB.deleteDatabase('stackchain-background-outbox-v1'); } try { request = indexedDB.deleteDatabase(name); }
catch (error) { reject(error); return; } catch (error) { reject(error); return; }
request.onsuccess = () => resolve(); request.onsuccess = () => resolve();
request.onerror = () => reject(request.error || new Error('IndexedDB deletion failed.')); request.onerror = () => reject(request.error || new Error('IndexedDB deletion failed.'));
@ -52,7 +52,8 @@
removeOwnedStorage(localStorage); removeOwnedStorage(localStorage);
if (sessionStorage !== localStorage) removeOwnedStorage(sessionStorage); if (sessionStorage !== localStorage) removeOwnedStorage(sessionStorage);
await stopWorkerOutbox(); await stopWorkerOutbox();
await deletePrivateOutbox(); await deletePrivateDatabase('stackchain-background-outbox-v1');
await deletePrivateDatabase('stackchain-offline-work-v2');
const keys = await caches?.keys?.() || []; const keys = await caches?.keys?.() || [];
await Promise.all( await Promise.all(
keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key)) keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key))

View File

@ -1,6 +1,6 @@
const BASE = new URL('./', self.location.href).pathname; const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js'); importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v85'; const CACHE = 'stackchain-dashboard-shell-v86';
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href; const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000; const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;

View File

@ -572,11 +572,11 @@
}); });
} }
function deletePrivateOutbox() { function deletePrivateDatabase(name) {
if (!indexedDB) return Promise.resolve(); if (!indexedDB) return Promise.resolve();
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let request; let request;
try { request = indexedDB.deleteDatabase('stackchain-background-outbox-v1'); } try { request = indexedDB.deleteDatabase(name); }
catch (error) { reject(error); return; } catch (error) { reject(error); return; }
request.onsuccess = () => resolve(); request.onsuccess = () => resolve();
request.onerror = () => reject(request.error || new Error('IndexedDB deletion failed.')); request.onerror = () => reject(request.error || new Error('IndexedDB deletion failed.'));
@ -589,7 +589,8 @@
if (sessionStorage !== localStorage) removeDashboardStorage(sessionStorage); if (sessionStorage !== localStorage) removeDashboardStorage(sessionStorage);
try { try {
await stopWorkerOutbox(); await stopWorkerOutbox();
await deletePrivateOutbox(); await deletePrivateDatabase('stackchain-background-outbox-v1');
await deletePrivateDatabase('stackchain-offline-work-v2');
} catch (_error) { } catch (_error) {
const error = new Error('Could not clear private queued work from this device.'); const error = new Error('Could not clear private queued work from this device.');
onClearError(error); onClearError(error);

View File

@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
assert '.update-reply-actions button { min-height:44px;' in html assert '.update-reply-actions button { min-height:44px;' in html
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v85" in worker assert "stackchain-dashboard-shell-v86" in worker

View File

@ -346,7 +346,9 @@ process.stdout.write(JSON.stringify(state));
) )
assert result["deletedAtIdle"] == [] assert result["deletedAtIdle"] == []
assert result["deletedDatabases"] == ["stackchain-background-outbox-v1"] assert result["deletedDatabases"] == [
"stackchain-background-outbox-v1", "stackchain-offline-work-v2"
]
assert result["remaining"] == ["gitea.preference"] assert result["remaining"] == ["gitea.preference"]
assert result["replaced"] == [ assert result["replaced"] == [
"/dashboard/login?reason=session-idle", "/dashboard/login?reason=session-idle",
@ -369,7 +371,9 @@ process.stdout.write(JSON.stringify(state));
assert result["valid"] is False assert result["valid"] is False
assert result["remaining"] == ["gitea.preference"] assert result["remaining"] == ["gitea.preference"]
assert result["deletedDatabases"] == ["stackchain-background-outbox-v1"] assert result["deletedDatabases"] == [
"stackchain-background-outbox-v1", "stackchain-offline-work-v2"
]
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"] assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
assert result["replaced"] == ["/dashboard/login?reason=session-expired"] assert result["replaced"] == ["/dashboard/login?reason=session-expired"]
assert result["replacedAfterDeletion"] is True assert result["replacedAfterDeletion"] is True
@ -497,7 +501,9 @@ process.stdout.write(JSON.stringify(state));
"/dashboard/login?reason=session-expired" "/dashboard/login?reason=session-expired"
] ]
assert result["remaining"] == ["gitea.preference"] assert result["remaining"] == ["gitea.preference"]
assert result["deletedDatabases"] == ["stackchain-background-outbox-v1"] assert result["deletedDatabases"] == [
"stackchain-background-outbox-v1", "stackchain-offline-work-v2"
]
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"] assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}] assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
assert result["expiredAfterDeletion"] is True assert result["expiredAfterDeletion"] is True
@ -517,7 +523,9 @@ process.stdout.write(JSON.stringify(state));
) )
assert result["remaining"] == ["gitea.preference"] assert result["remaining"] == ["gitea.preference"]
assert result["deletedDatabases"] == ["stackchain-background-outbox-v1"] assert result["deletedDatabases"] == [
"stackchain-background-outbox-v1", "stackchain-offline-work-v2"
]
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"] assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}] assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
assert result["replaced"] == ["/dashboard/login?reason=session-revoked"] assert result["replaced"] == ["/dashboard/login?reason=session-revoked"]
@ -610,7 +618,9 @@ process.stdout.write(JSON.stringify(state));
assert request["headers"]["x-csrf-token"] == "csrf-proof" assert request["headers"]["x-csrf-token"] == "csrf-proof"
assert "stackchain.private" in result["removed"] assert "stackchain.private" in result["removed"]
assert result["remaining"] == ["gitea.preference"] assert result["remaining"] == ["gitea.preference"]
assert result["deletedDatabases"] == ["stackchain-background-outbox-v1"] assert result["deletedDatabases"] == [
"stackchain-background-outbox-v1", "stackchain-offline-work-v2"
]
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"] assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
assert result["assigned"] == "/dashboard/login" assert result["assigned"] == "/dashboard/login"
assert result["assignedAfterDeletion"] is True assert result["assignedAfterDeletion"] is True

View File

@ -89,7 +89,7 @@ def test_legacy_cache_marker_is_normalized_out_of_build_identity(tmp_path):
worker = changed_frontend / "service-worker.js" worker = changed_frontend / "service-worker.js"
worker.write_text( worker.write_text(
worker.read_text().replace( worker.read_text().replace(
"const CACHE = 'stackchain-dashboard-shell-v85';", "const CACHE = 'stackchain-dashboard-shell-v86';",
"const CACHE = 'stackchain-dashboard-shell-v999';", "const CACHE = 'stackchain-dashboard-shell-v999';",
) )
) )

View File

@ -347,5 +347,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
def test_later_sync_ships_atomically_in_the_offline_shell(): def test_later_sync_ships_atomically_in_the_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v85" in source assert "stackchain-dashboard-shell-v86" in source
assert "BASE + 'static/later-sync.js'" in source assert "BASE + 'static/later-sync.js'" in source

View File

@ -137,4 +137,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
assert ".markdown-content a { min-height:44px;" in css assert ".markdown-content a { min-height:44px;" in css
assert "stackchain-dashboard-shell-v85" in worker assert "stackchain-dashboard-shell-v86" in worker

View File

@ -41,7 +41,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0])) shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}" assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
assert "stackchain-dashboard-shell-v85" in worker assert "stackchain-dashboard-shell-v86" in worker
def test_all_conversation_composers_offer_accessible_mobile_mentions(): def test_all_conversation_composers_offer_accessible_mobile_mentions():

View File

@ -56,6 +56,33 @@ process.stdout.write(JSON.stringify({peak, loaded, saved, status}));
assert result["status"] == {"total": 5, "ready": 5, "failed": 0, "pending": 0} assert result["status"] == {"total": 5, "ready": 5, "failed": 0, "pending": 0}
def test_readiness_waits_for_durable_async_admission_and_reports_rejection():
result = run_scenario("""
const events = [];
const warmer = createOfflineToday({
loadDetail: async () => ({title:'Fetched detail'}),
loadSavedDetail: async () => null,
saveDetail: async () => {
events.push('admission-started');
await new Promise(resolve => setTimeout(resolve, 5));
events.push('admission-rejected');
return false;
},
onStatus: status => events.push('ready:' + status.ready + '/failed:' + status.failed),
});
const status = await warmer.warm('timmy', [{
kind:'issue', repository:'stackchain/dashboard', number:1, updated_at:'v1',
}]);
process.stdout.write(JSON.stringify({events, status, failed:warmer.failedKeys()}));
""")
assert result["status"] == {"total": 1, "ready": 0, "failed": 1, "pending": 0}
assert result["failed"] == ["issue:stackchain/dashboard:1"]
assert result["events"].index("admission-rejected") < result["events"].index(
"ready:0/failed:1"
)
def test_refreshes_only_changed_revisions_and_keeps_prior_copy_on_failure(): def test_refreshes_only_changed_revisions_and_keeps_prior_copy_on_failure():
result = run_scenario(""" result = run_scenario("""
const items = [ const items = [

View File

@ -27,6 +27,136 @@ const storage = {{
return json.loads(result.stdout) return json.loads(result.stdout)
def run_async_scenario(scenario: str) -> dict:
script = f"""
const createOfflineWorkStore = require({json.dumps(str(OFFLINE_WORK))});
const values = new Map();
const records = new Map();
const storage = {{
getItem: key => values.has(key) ? values.get(key) : null,
setItem: (key, value) => values.set(key, value),
removeItem: key => values.delete(key),
}};
const transaction = async work => work({{
get: async key => records.get(key),
getAll: async () => [...records.values()],
put: async value => records.set(value.id, structuredClone(value)),
delete: async key => records.delete(key),
clear: async () => records.clear(),
}});
(async () => {{
{scenario}
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
return json.loads(result.stdout)
def test_indexeddb_commit_persists_snapshot_and_details_without_localstorage_payloads():
result = run_async_scenario("""
const store = createOfflineWorkStore({storage, transaction, now:() => new Date('2026-08-07T12:00:00Z')});
store.setEnabled(true);
const snapshotSaved = await store.save({
user:{login:'timmy', full_name:'Timmy'}, issues:[{number:8, title:'Large offline review'}],
pull_requests:[], notifications:[],
});
const item = {kind:'pull', repository:'stackchain/dashboard', number:8, is_review:true};
const detailSaved = await store.saveDetail('timmy', item, {
title:'Large offline review', head_sha:'abc123',
files:[{filename:'large.diff', diff_lines:['+private review line']}],
});
const restarted = createOfflineWorkStore({storage, transaction, now:() => new Date('2026-08-07T12:01:00Z')});
process.stdout.write(JSON.stringify({
snapshotSaved, detailSaved,
loaded:await restarted.load('timmy'),
detail:await restarted.loadDetail('timmy', item),
localValues:[...values.values()].join(' '),
recordIds:[...records.keys()].sort(),
}));
""")
assert result["snapshotSaved"] is True
assert result["detailSaved"] is True
assert result["loaded"]["user"]["login"] == "timmy"
assert result["detail"]["head_sha"] == "abc123"
assert "Large offline review" not in result["localValues"]
assert "private review line" not in result["localValues"]
assert result["recordIds"] == ["detail:timmy:pull:stackchain/dashboard:8", "snapshot"]
def test_legacy_localstorage_payloads_migrate_only_after_indexeddb_commit():
result = run_async_scenario("""
values.set('stackchain.offline-work.enabled.v1', 'true');
values.set('stackchain.offline-work.snapshot.v1', JSON.stringify({
version:1, user_login:'timmy', saved_at:'2026-08-07T12:00:00.000Z',
data:{user:{login:'timmy'}, issues:[], pull_requests:[], notifications:[]},
}));
values.set('stackchain.offline-work.details.v1', JSON.stringify([{
key:'issue:stackchain/dashboard:9', user_login:'timmy', saved_at:'2026-08-07T12:00:00.000Z',
data:{title:'Migrated issue'},
}]));
const store = createOfflineWorkStore({storage, transaction, now:() => new Date('2026-08-07T12:01:00Z')});
const snapshot = await store.load('timmy');
const detail = await store.loadDetail('timmy', {kind:'issue', repository:'stackchain/dashboard', number:9});
process.stdout.write(JSON.stringify({
snapshot, detail,
legacySnapshot:values.has('stackchain.offline-work.snapshot.v1'),
legacyDetails:values.has('stackchain.offline-work.details.v1'),
recordIds:[...records.keys()].sort(),
}));
""")
assert result["snapshot"]["user"]["login"] == "timmy"
assert result["detail"]["title"] == "Migrated issue"
assert result["legacySnapshot"] is False
assert result["legacyDetails"] is False
assert result["recordIds"] == ["detail:timmy:issue:stackchain/dashboard:9", "snapshot"]
def test_failed_migration_keeps_legacy_snapshot_readable_and_untouched():
result = run_async_scenario("""
values.set('stackchain.offline-work.enabled.v1', 'true');
values.set('stackchain.offline-work.snapshot.v1', JSON.stringify({
version:1, user_login:'timmy', saved_at:'2026-08-07T12:00:00.000Z',
data:{user:{login:'timmy'}, issues:[{number:7, title:'Keep me'}], pull_requests:[], notifications:[]},
}));
let attempts = 0;
const flakyTransaction = async work => {
attempts += 1;
if (attempts === 1) throw new Error('quota admission failed');
return transaction(work);
};
const store = createOfflineWorkStore({storage, transaction:flakyTransaction, now:() => new Date('2026-08-07T12:01:00Z')});
const snapshot = await store.load('timmy');
process.stdout.write(JSON.stringify({
snapshot, attempts,
legacySnapshot:values.has('stackchain.offline-work.snapshot.v1'),
recordIds:[...records.keys()],
}));
""")
assert result["snapshot"]["issues"][0]["title"] == "Keep me"
assert result["legacySnapshot"] is True
assert result["recordIds"] == []
def test_ready_atomically_prunes_all_expired_indexeddb_records():
result = run_async_scenario("""
const writer = createOfflineWorkStore({storage, transaction, now:() => new Date('2026-08-07T12:00:00Z'), maxAgeMs:1000});
writer.setEnabled(true);
await writer.save({user:{login:'timmy'}, issues:[], pull_requests:[], notifications:[]});
await writer.saveDetail('timmy', {kind:'issue', repository:'stackchain/dashboard', number:1}, {title:'One'});
await writer.saveDetail('timmy', {kind:'pull', repository:'stackchain/dashboard', number:2}, {title:'Two'});
const expired = createOfflineWorkStore({storage, transaction, now:() => new Date('2026-08-07T12:00:02Z'), maxAgeMs:1000});
await expired.ready();
process.stdout.write(JSON.stringify({recordIds:[...records.keys()]}));
""")
assert result["recordIds"] == []
def test_opted_in_snapshot_survives_restart_with_only_queue_card_fields(): def test_opted_in_snapshot_survives_restart_with_only_queue_card_fields():
result = run_scenario(""" result = run_scenario("""
const store = createOfflineWorkStore({storage, now:() => new Date('2026-08-07T12:00:00Z')}); const store = createOfflineWorkStore({storage, now:() => new Date('2026-08-07T12:00:00Z')});
@ -213,11 +343,23 @@ async def test_dashboard_offers_private_offline_work_controls_and_read_only_hydr
assert '.offline-work-controls button { min-height:44px;' in html assert '.offline-work-controls button { min-height:44px;' in html
@pytest.mark.anyio
async def test_dashboard_uses_async_indexeddb_offline_store_end_to_end():
html = await dashboard()
assert "createOfflineWorkStore({ storage: localStorage, indexedDB:window.indexedDB })" in html
assert "async function hydrateOfflineWork" in html
assert "const saved = await offlineWorkStore.load();" in html
assert "await offlineWorkStore.save({" in html
assert "await offlineWorkStore.clear();" in html
assert "Offline saving unavailable · retry after reconnect." in html
@pytest.mark.anyio @pytest.mark.anyio
async def test_initial_http_outage_hydrates_saved_work_and_recovers_on_live_snapshot(): async def test_initial_http_outage_hydrates_saved_work_and_recovers_on_live_snapshot():
html = await dashboard() html = await dashboard()
assert "if (!hasContextSnapshot && hydrateOfflineWork('outage')) return;" in html assert "if (!hasContextSnapshot && await hydrateOfflineWork('outage')) return;" in html
assert "Outage · saved " in html assert "Outage · saved " in html
assert "Server unavailable · showing private My Work saved " in html assert "Server unavailable · showing private My Work saved " in html
assert "Live details and actions will return automatically." in html assert "Live details and actions will return automatically." in html

View File

@ -292,6 +292,6 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history(
def test_plan_today_controller_is_available_in_the_offline_shell(): def test_plan_today_controller_is_available_in_the_offline_shell():
source = SERVICE_WORKER.read_text() source = SERVICE_WORKER.read_text()
assert "stackchain-dashboard-shell-v85" in source assert "stackchain-dashboard-shell-v86" in source
assert "BASE + 'static/plan-today.js'" in source assert "BASE + 'static/plan-today.js'" in source
assert "BASE + 'static/plan-today-preview.js'" in source assert "BASE + 'static/plan-today-preview.js'" in source

View File

@ -43,7 +43,10 @@ const clear = createPrivateDeviceDataPurger({{
state = json.loads(result.stdout) state = json.loads(result.stdout)
assert state["remaining"] == ["other.preference"] assert state["remaining"] == ["other.preference"]
assert state["databases"] == ["stackchain-background-outbox-v1"] assert state["databases"] == [
"stackchain-background-outbox-v1",
"stackchain-offline-work-v2",
]
assert state["caches"] == ["stackchain-dashboard-shell-v37"] assert state["caches"] == ["stackchain-dashboard-shell-v37"]
assert state["workerMessages"] == [{"type": "stackchain-purge-outbox"}] assert state["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
assert state["complete"] is True assert state["complete"] is True

View File

@ -125,7 +125,7 @@ async function dispatchNotificationClick(route) {{
def test_resumable_today_session_ships_in_a_new_offline_shell(): def test_resumable_today_session_ships_in_a_new_offline_shell():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v85" in source assert "stackchain-dashboard-shell-v86" in source
assert "BASE + 'static/my-work.js'" in source assert "BASE + 'static/my-work.js'" in source
assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/dashboard.css'" in source assert "BASE + 'static/dashboard.css'" in source
@ -134,14 +134,14 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
def test_ownership_exit_runtime_rolls_the_offline_shell_cache(): def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v85" in source assert "stackchain-dashboard-shell-v86" in source
assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.js'" in source
def test_offline_review_next_ships_today_completion_atomically(): def test_offline_review_next_ships_today_completion_atomically():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v85" in source assert "stackchain-dashboard-shell-v86" in source
assert "BASE + 'static/today-completion.js'" in source assert "BASE + 'static/today-completion.js'" in source
assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.js'" in source
@ -149,7 +149,7 @@ def test_offline_review_next_ships_today_completion_atomically():
def test_duplicate_aware_capture_ships_in_a_new_offline_shell(): def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v85" in source assert "stackchain-dashboard-shell-v86" in source
assert "BASE + 'static/create-issue-sheet.js'" in source assert "BASE + 'static/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.js'" in source
@ -157,14 +157,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell(): def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v85" in source assert "stackchain-dashboard-shell-v86" in source
assert "BASE + 'static/later-picker.js'" in source assert "BASE + 'static/later-picker.js'" in source
def test_navigation_deadline_ships_in_a_new_shell_cache(): def test_navigation_deadline_ships_in_a_new_shell_cache():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v85" in source assert "stackchain-dashboard-shell-v86" in source
assert "BASE + 'static/dashboard.css'" in source assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source assert "BASE + 'static/install-app.js'" in source
@ -173,21 +173,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
def test_today_convergence_ships_in_a_new_shell_cache(): def test_today_convergence_ships_in_a_new_shell_cache():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v85" in source assert "stackchain-dashboard-shell-v86" in source
assert "BASE + 'static/today-sync.js'" in source assert "BASE + 'static/today-sync.js'" in source
def test_mobile_search_viewport_ships_in_a_new_offline_shell(): def test_mobile_search_viewport_ships_in_a_new_offline_shell():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v85" in source assert "stackchain-dashboard-shell-v86" in source
assert "BASE + 'static/mobile-search-viewport.js'" in source assert "BASE + 'static/mobile-search-viewport.js'" in source
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell(): def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v85" in source assert "stackchain-dashboard-shell-v86" in source
assert "BASE + 'static/update-ownership.js'" in source assert "BASE + 'static/update-ownership.js'" in source
@ -397,7 +397,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
def test_queue_today_ships_atomically_in_a_new_offline_shell(): def test_queue_today_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v85" in source assert "stackchain-dashboard-shell-v86" in source
assert "BASE + 'static/queue-today.js'" in source assert "BASE + 'static/queue-today.js'" in source

View File

@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate
def test_readiness_runtime_is_available_in_offline_shell(): def test_readiness_runtime_is_available_in_offline_shell():
service_worker = SERVICE_WORKER.read_text() service_worker = SERVICE_WORKER.read_text()
assert "const CACHE = 'stackchain-dashboard-shell-v85';" in service_worker assert "const CACHE = 'stackchain-dashboard-shell-v86';" in service_worker
assert "BASE + 'static/today-readiness.js'" in service_worker assert "BASE + 'static/today-readiness.js'" in service_worker

View File

@ -127,7 +127,7 @@ sync.enqueueConfiguration(120, {{'issue:r:1:':60}});
def test_inflight_today_drain_ships_in_a_new_offline_shell(): def test_inflight_today_drain_ships_in_a_new_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v85" in source assert "stackchain-dashboard-shell-v86" in source
assert "BASE + 'static/today-sync.js'" in source assert "BASE + 'static/today-sync.js'" in source