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
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

View File

@ -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);

View File

@ -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));
}

View File

@ -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;

View File

@ -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))

View File

@ -1,6 +1,6 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-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;

View File

@ -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);

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 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

View File

@ -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

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.write_text(
worker.read_text().replace(
"const CACHE = 'stackchain-dashboard-shell-v85';",
"const CACHE = 'stackchain-dashboard-shell-v86';",
"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():
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

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 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

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]))
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():

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}
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 = [

View File

@ -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

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():
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

View File

@ -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

View File

@ -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

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():
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

View File

@ -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