diff --git a/README.md b/README.md index f268f7e..08f3583 100644 --- a/README.md +++ b/README.md @@ -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 per file. A previously opened unread update is retained by notification 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, -repository catalogs, events, raw patches, and complete API responses are excluded. +The snapshot and each account-bound detail are committed as separate IndexedDB records, +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 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 @@ -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 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 -work data** deletes both stores, and opting out or seven-day expiry deletes them -automatically. +work data**, opt-out, sign-out, remote revocation, and absolute session expiry delete the +IndexedDB records; seven-day expiry removes stale records automatically. 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 diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 5ee1886..5786cbe 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -1,4 +1,4 @@ -(function(){ +(async function(){ const qs = (s, el=document) => el.querySelector(s); const fmt = (d) => new Date(d).toLocaleString(); const cardPlanning = createCardPlanning(document); @@ -399,7 +399,8 @@ const pullController = createPullSheet({ fetchJson: fetchReviewJson, storage: localStorage }); const draftInbox = createDraftInbox({ storage: localStorage, getCurrentLogin: () => activeFlushLogin }); outboxCoordinator.subscribe(() => refreshMyWorkView()); - const offlineWorkStore = createOfflineWorkStore({ storage: localStorage }); + const offlineWorkStore = createOfflineWorkStore({ storage: localStorage, indexedDB:window.indexedDB }); + await offlineWorkStore.ready(); function renderOfflineTodayStatus(status) { const container = qs('#offline-today-readiness'); const label = qs('#offline-today-status'); @@ -656,7 +657,7 @@ setOfflineUpdateControls(false); qs('#keep-update-unread').focus(); }, - onDetail: detail => { + onDetail: async detail => { qs('#update-sheet-title').textContent = detail.title || 'Unread update'; qs('#update-subject-type').textContent = detail.subject_type || 'Update'; qs('#update-subject-state').textContent = detail.state || ''; @@ -667,7 +668,7 @@ } else { updateOwnership.open(detail, selectedUpdate); if (offlineWorkStore.enabled() && confirmedOwnerLogin && selectedUpdate) { - offlineWorkStore.saveDetail(confirmedOwnerLogin, selectedUpdate, detail); + await offlineWorkStore.saveDetail(confirmedOwnerLogin, selectedUpdate, detail); } } qs('#retry-update-load').hidden = true; @@ -1549,11 +1550,11 @@ setClock(); } - function handleContextError(e) { + async function handleContextError(e) { console.error('context failed', e); liveMode = false; activeFlushLogin = ''; - if (!hasContextSnapshot && hydrateOfflineWork('outage')) return; + if (!hasContextSnapshot && await hydrateOfflineWork('outage')) return; const timeoutStatus = e.name === 'TimeoutError' ? 'Update delayed · showing last snapshot' : 'Update failed · showing last snapshot'; setStatus(hasContextSnapshot ? timeoutStatus : 'Unavailable'); @@ -2268,7 +2269,7 @@ qs('#issue-sheet-status').textContent = 'Offline copy · saved ' + fmt(detail.saved_at) + ' · comments queue for sync'; } else if (offlineWorkStore.enabled() && confirmedOwnerLogin && todayWork.contains(item)) { - offlineWorkStore.saveDetail(confirmedOwnerLogin, item, detail); + await offlineWorkStore.saveDetail(confirmedOwnerLogin, item, detail); } } catch (error) { if (selectedIssue !== item) return; @@ -2445,7 +2446,7 @@ qs('#pull-sheet-status').textContent = 'Offline copy · saved ' + fmt(detail.saved_at) + ' · comments queue for sync'; } else if (offlineWorkStore.enabled() && confirmedOwnerLogin && todayWork.contains(item)) { - offlineWorkStore.saveDetail(confirmedOwnerLogin, item, detail); + await offlineWorkStore.saveDetail(confirmedOwnerLogin, item, detail); } } catch (error) { if (selectedPull !== item) return; @@ -3117,7 +3118,7 @@ qs('#gitea-events-status').textContent = message; } - function renderLiveSnapshot(snapshot, changedSections = ['context', 'events', 'notifications']) { + async function renderLiveSnapshot(snapshot, changedSections = ['context', 'events', 'notifications']) { setOfflineWorkMode(false); offlineStatus.hidden = true; const contextFreshness = snapshot.freshness?.sections?.context; @@ -3171,12 +3172,13 @@ if (contextFreshness?.stale) markMyWorkStale(); else if (!notificationsFresh) markNotificationsStale(); if (!contextFreshness?.stale && notificationsFresh) { - offlineWorkStore.save({ + const admitted = await offlineWorkStore.save({ ...snapshot.context, notifications: snapshot.notifications, notification_pagination: snapshot.notification_pagination, }); - updateOfflineWorkControls(); + await updateOfflineWorkControls(admitted === false ? + 'Offline saving unavailable · retry after reconnect.' : undefined); warmTodayOffline(); } flushIssueOutbox(); @@ -4690,8 +4692,8 @@ const contextPoller = createContextPoller({ fetchContext: fetchLiveSnapshot, onSnapshot: renderLiveSnapshot, - onError: error => { - handleContextError(error); + onError: async error => { + await handleContextError(error); setEventStreamStatus('Update failed · showing last activity'); }, isHidden: () => document.hidden, @@ -4704,9 +4706,9 @@ const offlineWorkStatus = qs('#offline-work-status'); const deliveryReceipts = qs('#delivery-receipts'); const deliveryReceiptStatus = qs('#delivery-receipt-status'); - function updateOfflineWorkControls(message) { + async function updateOfflineWorkControls(message) { 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.' : (keepWorkOffline.checked ? 'Waiting for a healthy live refresh.' : 'Off · no work data is stored.')); } @@ -4738,8 +4740,8 @@ .forEach(button => { button.disabled = true; }); } } - function hydrateOfflineWork(mode = 'offline') { - const saved = offlineWorkStore.load(); + async function hydrateOfflineWork(mode = 'offline') { + const saved = await offlineWorkStore.load(); if (!saved) return false; const outage = mode === 'outage'; confirmedOwnerLogin = String(saved.user?.login || '').trim(); @@ -4768,11 +4770,11 @@ offlineStatus.hidden = false; return true; } - function showOfflineStatus() { + async function showOfflineStatus() { activeFlushLogin = ''; offlineStatus.hidden = false; setStatus('Offline'); - if (!hasContextSnapshot) hydrateOfflineWork(); + if (!hasContextSnapshot) await hydrateOfflineWork(); } function reconnectLiveData() { offlineStatus.hidden = true; @@ -4782,13 +4784,18 @@ if (selectedReview && offlineReview) openReviewSheet(selectedReview, reviewTrigger); }); } - keepWorkOffline.addEventListener('change', () => { + keepWorkOffline.addEventListener('change', async () => { offlineWorkStore.setEnabled(keepWorkOffline.checked); if (keepWorkOffline.checked && liveMode && lastContextSnapshot) { - offlineWorkStore.save({ ...lastContextSnapshot, notifications:lastNotifications, + const admitted = await offlineWorkStore.save({ ...lastContextSnapshot, notifications:lastNotifications, 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(); else offlineToday.cancel(); }); @@ -4804,18 +4811,18 @@ await updateDeliveryReceiptControls(enabled ? 'Background delivery receipts enabled.' : (Notification.permission === 'denied' ? 'Notifications are blocked in browser settings.' : 'Background delivery receipts disabled.')); }); - qs('#clear-offline-work').addEventListener('click', () => { - offlineWorkStore.clear(); + qs('#clear-offline-work').addEventListener('click', async () => { + await offlineWorkStore.clear(); offlineToday.cancel(); 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', () => offlineToday.retry(confirmedOwnerLogin, todayMyWork) ); - updateOfflineWorkControls(); + await updateOfflineWorkControls(); updateDeliveryReceiptControls(); - if (!navigator.onLine) showOfflineStatus(); + if (!navigator.onLine) await showOfflineStatus(); window.addEventListener('offline', showOfflineStatus); window.addEventListener('online', reconnectLiveData); diff --git a/frontend/offline-today.js b/frontend/offline-today.js index 56204fe..f5c0721 100644 --- a/frontend/offline-today.js +++ b/frontend/offline-today.js @@ -31,14 +31,18 @@ } 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 => ({ total: items.length, ready: readyKeys.size, failed: failed.size, pending, }); const candidates = items.filter(item => { const key = itemKey(item); 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; }); failed = new Set(); @@ -51,8 +55,9 @@ try { const detail = await loadDetail(item); if (runGeneration !== generation) return; - const saved = saveDetail(login, item, { ...detail, source_updated_at: item.updated_at }); - if (saved !== false) readyKeys.add(itemKey(item)); + const saved = await saveDetail(login, item, { ...detail, source_updated_at: item.updated_at }); + if (saved === false) throw new Error('Offline detail was not durably admitted.'); + readyKeys.add(itemKey(item)); } catch (_error) { if (runGeneration === generation) failed.add(itemKey(item)); } diff --git a/frontend/offline-work.js b/frontend/offline-work.js index 58a345e..7a69574 100644 --- a/frontend/offline-work.js +++ b/frontend/offline-work.js @@ -27,6 +27,54 @@ ]; 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) { const output = {}; fields.forEach(field => { @@ -36,14 +84,94 @@ } 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() { try { return storage.getItem(ENABLED_KEY) === 'true'; } catch (_) { return false; } } 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 { storage.removeItem(SNAPSHOT_KEY); storage.removeItem(DETAILS_KEY); @@ -104,6 +232,22 @@ saved_at: now().toISOString(), 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 => !(candidate?.key === key && candidate?.user_login === login) ); @@ -117,6 +261,19 @@ const key = detailKey(item); login = String(login || '').trim(); 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 currentTime = now().getTime(); const valid = records.filter(record => { @@ -142,6 +299,7 @@ function save(snapshot) { if (!enabled() || !snapshot?.user?.login) return false; const record = { + id: 'snapshot', version: VERSION, user_login: String(snapshot.user.login), saved_at: now().toISOString(), @@ -154,12 +312,36 @@ 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)); } catch (_) { return false; } return true; } 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; try { record = JSON.parse(storage.getItem(SNAPSHOT_KEY) || 'null'); } catch (_) { clear(); return null; } @@ -175,7 +357,7 @@ 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; diff --git a/frontend/private-device-data.js b/frontend/private-device-data.js index 1d6d631..6811723 100644 --- a/frontend/private-device-data.js +++ b/frontend/private-device-data.js @@ -36,11 +36,11 @@ }); } - function deletePrivateOutbox() { + function deletePrivateDatabase(name) { if (!indexedDB) return Promise.resolve(); return new Promise((resolve, reject) => { let request; - try { request = indexedDB.deleteDatabase('stackchain-background-outbox-v1'); } + try { request = indexedDB.deleteDatabase(name); } catch (error) { reject(error); return; } request.onsuccess = () => resolve(); request.onerror = () => reject(request.error || new Error('IndexedDB deletion failed.')); @@ -52,7 +52,8 @@ removeOwnedStorage(localStorage); if (sessionStorage !== localStorage) removeOwnedStorage(sessionStorage); await stopWorkerOutbox(); - await deletePrivateOutbox(); + await deletePrivateDatabase('stackchain-background-outbox-v1'); + await deletePrivateDatabase('stackchain-offline-work-v2'); const keys = await caches?.keys?.() || []; await Promise.all( keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key)) diff --git a/frontend/service-worker.js b/frontend/service-worker.js index e91f0eb..4b95338 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -1,6 +1,6 @@ const BASE = new URL('./', self.location.href).pathname; 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 OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000; diff --git a/frontend/session.js b/frontend/session.js index 7b9e967..98b16dd 100644 --- a/frontend/session.js +++ b/frontend/session.js @@ -572,11 +572,11 @@ }); } - function deletePrivateOutbox() { + function deletePrivateDatabase(name) { if (!indexedDB) return Promise.resolve(); return new Promise((resolve, reject) => { let request; - try { request = indexedDB.deleteDatabase('stackchain-background-outbox-v1'); } + try { request = indexedDB.deleteDatabase(name); } catch (error) { reject(error); return; } request.onsuccess = () => resolve(); request.onerror = () => reject(request.error || new Error('IndexedDB deletion failed.')); @@ -589,7 +589,8 @@ if (sessionStorage !== localStorage) removeDashboardStorage(sessionStorage); try { await stopWorkerOutbox(); - await deletePrivateOutbox(); + await deletePrivateDatabase('stackchain-background-outbox-v1'); + await deletePrivateDatabase('stackchain-offline-work-v2'); } catch (_error) { const error = new Error('Could not clear private queued work from this device.'); onClearError(error); diff --git a/tests/test_comment_next.py b/tests/test_comment_next.py index 5770c1c..0702e0a 100644 --- a/tests/test_comment_next.py +++ b/tests/test_comment_next.py @@ -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 button { min-height:44px;' in html 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 diff --git a/tests/test_dashboard_session_frontend.py b/tests/test_dashboard_session_frontend.py index d0792b5..9f66493 100644 --- a/tests/test_dashboard_session_frontend.py +++ b/tests/test_dashboard_session_frontend.py @@ -346,7 +346,9 @@ process.stdout.write(JSON.stringify(state)); ) 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["replaced"] == [ "/dashboard/login?reason=session-idle", @@ -369,7 +371,9 @@ process.stdout.write(JSON.stringify(state)); assert result["valid"] is False 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["replaced"] == ["/dashboard/login?reason=session-expired"] assert result["replacedAfterDeletion"] is True @@ -497,7 +501,9 @@ process.stdout.write(JSON.stringify(state)); "/dashboard/login?reason=session-expired" ] 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["workerMessages"] == [{"type": "stackchain-purge-outbox"}] assert result["expiredAfterDeletion"] is True @@ -517,7 +523,9 @@ process.stdout.write(JSON.stringify(state)); ) 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["workerMessages"] == [{"type": "stackchain-purge-outbox"}] 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 "stackchain.private" in result["removed"] 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["assigned"] == "/dashboard/login" assert result["assignedAfterDeletion"] is True diff --git a/tests/test_frontend_bundle.py b/tests/test_frontend_bundle.py index a0d8bcb..77ac34f 100644 --- a/tests/test_frontend_bundle.py +++ b/tests/test_frontend_bundle.py @@ -89,7 +89,7 @@ def test_legacy_cache_marker_is_normalized_out_of_build_identity(tmp_path): worker = changed_frontend / "service-worker.js" worker.write_text( worker.read_text().replace( - "const CACHE = 'stackchain-dashboard-shell-v85';", + "const CACHE = 'stackchain-dashboard-shell-v86';", "const CACHE = 'stackchain-dashboard-shell-v999';", ) ) diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py index 9422f8f..258b9bf 100644 --- a/tests/test_later_sync.py +++ b/tests/test_later_sync.py @@ -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(): 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 diff --git a/tests/test_markdown_renderer.py b/tests/test_markdown_renderer.py index 5ad880e..2c62d09 100644 --- a/tests/test_markdown_renderer.py +++ b/tests/test_markdown_renderer.py @@ -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 pre { max-width:100%; overflow-x:auto;" 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 diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py index ff3896e..e149b73 100644 --- a/tests/test_mobile_composer_integration.py +++ b/tests/test_mobile_composer_integration.py @@ -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])) 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(): diff --git a/tests/test_offline_today.py b/tests/test_offline_today.py index 25145af..c02dc40 100644 --- a/tests/test_offline_today.py +++ b/tests/test_offline_today.py @@ -56,6 +56,33 @@ process.stdout.write(JSON.stringify({peak, loaded, saved, status})); 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(): result = run_scenario(""" const items = [ diff --git a/tests/test_offline_work.py b/tests/test_offline_work.py index 8a16f47..df2565b 100644 --- a/tests/test_offline_work.py +++ b/tests/test_offline_work.py @@ -27,6 +27,136 @@ const storage = {{ 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(): result = run_scenario(""" 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 +@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 async def test_initial_http_outage_hydrates_saved_work_and_recovers_on_live_snapshot(): 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 "Server unavailable · showing private My Work saved " in html assert "Live details and actions will return automatically." in html diff --git a/tests/test_plan_today.py b/tests/test_plan_today.py index 9b13000..d70af78 100644 --- a/tests/test_plan_today.py +++ b/tests/test_plan_today.py @@ -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(): 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-preview.js'" in source diff --git a/tests/test_private_device_data.py b/tests/test_private_device_data.py index da597a6..43d8c0f 100644 --- a/tests/test_private_device_data.py +++ b/tests/test_private_device_data.py @@ -43,7 +43,10 @@ const clear = createPrivateDeviceDataPurger({{ state = json.loads(result.stdout) 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["workerMessages"] == [{"type": "stackchain-purge-outbox"}] assert state["complete"] is True diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 261d119..31ca15d 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -125,7 +125,7 @@ async function dispatchNotificationClick(route) {{ def test_resumable_today_session_ships_in_a_new_offline_shell(): 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/dashboard.js'" 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(): 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 def test_offline_review_next_ships_today_completion_atomically(): 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/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(): 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/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(): 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 def test_navigation_deadline_ships_in_a_new_shell_cache(): 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.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(): 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 def test_mobile_search_viewport_ships_in_a_new_offline_shell(): 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 def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell(): 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 @@ -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(): 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 diff --git a/tests/test_today_readiness.py b/tests/test_today_readiness.py index 4e6d9b5..aa9f00d 100644 --- a/tests/test_today_readiness.py +++ b/tests/test_today_readiness.py @@ -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(): 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 diff --git a/tests/test_today_sync.py b/tests/test_today_sync.py index bdb51ff..cec781c 100644 --- a/tests/test_today_sync.py +++ b/tests/test_today_sync.py @@ -127,7 +127,7 @@ sync.enqueueConfiguration(120, {{'issue:r:1:':60}}); def test_inflight_today_drain_ships_in_a_new_offline_shell(): 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