diff --git a/frontend/dashboard.js b/frontend/dashboard.js
index ef21b86..3dac5f5 100644
--- a/frontend/dashboard.js
+++ b/frontend/dashboard.js
@@ -417,7 +417,7 @@
const draftInbox = createDraftInbox({ storage: localStorage, getCurrentLogin: () => activeFlushLogin });
outboxCoordinator.subscribe(() => refreshMyWorkView());
const offlineWorkStore = createOfflineWorkStore({ storage: localStorage, indexedDB:window.indexedDB });
- await offlineWorkStore.ready();
+ let offlineStorageReady = await offlineWorkStore.ready();
function renderOfflineTodayStatus(status) {
const container = qs('#offline-today-readiness');
const label = qs('#offline-today-status');
@@ -4735,12 +4735,16 @@
const offlineStatus = qs('#offline-status');
const keepWorkOffline = qs('#keep-work-offline');
const offlineWorkStatus = qs('#offline-work-status');
+ const retryOfflineStorage = qs('#retry-offline-storage');
const deliveryReceipts = qs('#delivery-receipts');
const deliveryReceiptStatus = qs('#delivery-receipt-status');
async function updateOfflineWorkControls(message) {
keepWorkOffline.checked = offlineWorkStore.enabled();
+ keepWorkOffline.disabled = !offlineStorageReady;
+ retryOfflineStorage.hidden = offlineStorageReady;
const saved = await offlineWorkStore.load();
- offlineWorkStatus.textContent = message || (saved ? 'Saved ' + fmt(saved.saved_at) + ' · expires after 7 days.' :
+ offlineWorkStatus.textContent = message || (!offlineStorageReady ?
+ 'Offline saving unavailable · online work remains live.' : saved ? 'Saved ' + fmt(saved.saved_at) + ' · expires after 7 days.' :
(keepWorkOffline.checked ? 'Waiting for a healthy live refresh.' : 'Off · no work data is stored.'));
}
async function updateDeliveryReceiptControls(message) {
@@ -4848,6 +4852,18 @@
renderOfflineTodayStatus({ total:0, ready:0, failed:0, pending:0 });
await updateOfflineWorkControls('Offline work data cleared.');
});
+ retryOfflineStorage.addEventListener('click', async () => {
+ retryOfflineStorage.disabled = true;
+ offlineWorkStatus.textContent = 'Retrying offline saving…';
+ offlineStorageReady = await offlineWorkStore.retry();
+ retryOfflineStorage.disabled = false;
+ if (offlineStorageReady && offlineWorkStore.enabled() && liveMode && lastContextSnapshot) {
+ await offlineWorkStore.save({ ...lastContextSnapshot, notifications:lastNotifications,
+ notification_pagination:notificationPagination });
+ }
+ await updateOfflineWorkControls(offlineStorageReady ? 'Offline saving restored.' :
+ 'Offline saving unavailable · online work remains live.');
+ });
qs('#retry-offline-today').addEventListener('click', () =>
offlineToday.retry(confirmedOwnerLogin, todayMyWork)
);
diff --git a/frontend/index.html b/frontend/index.html
index 321f547..aa6e239 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -98,6 +98,7 @@
+
diff --git a/frontend/offline-work.js b/frontend/offline-work.js
index 7a69574..7055a25 100644
--- a/frontend/offline-work.js
+++ b/frontend/offline-work.js
@@ -85,12 +85,32 @@
function createOfflineWorkStore({
storage, indexedDB, transaction, now = () => new Date(), maxAgeMs = DEFAULT_MAX_AGE_MS,
- maxDetails = 10,
+ maxDetails = 10, initializationTimeoutMs = 1500,
}) {
const transact = transaction || createIndexedDbTransaction(indexedDB);
let migrationPromise;
+ let readinessPromise;
+ let readinessGeneration = 0;
+ let readinessState = transact ? { state:'initializing', reason:'' } : { state:'ready', reason:'' };
let recordCache = null;
- function migrateLegacy() {
+ let pendingDurableClear = false;
+
+ function hydrateLegacyCache() {
+ const cache = new Map();
+ try {
+ const snapshot = JSON.parse(storage.getItem(SNAPSHOT_KEY) || 'null');
+ if (snapshot) cache.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;
+ cache.set(id, { ...record, id });
+ });
+ } catch (_) { cache.clear(); }
+ recordCache = cache;
+ }
+
+ function migrateLegacy(generation) {
if (!transact) return Promise.resolve(true);
if (!migrationPromise) migrationPromise = (async () => {
let snapshot = null;
@@ -113,46 +133,82 @@
if (!(await records.get(id))) await records.put({ ...record, id });
}
});
+ if (generation !== readinessGeneration || readinessState.state === 'degraded') return false;
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;
+
+ async function initialize(generation) {
+ const migrated = await migrateLegacy(generation);
+ if (!migrated || generation !== readinessGeneration || readinessState.state === 'degraded') return false;
+ if (pendingDurableClear) {
+ await transact(async records => { await records.clear(); });
+ if (generation !== readinessGeneration || readinessState.state === 'degraded') return false;
+ pendingDurableClear = false;
+ recordCache = new Map();
+ return true;
+ }
+ 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);
}
- 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);
+ if (generation !== readinessGeneration || readinessState.state === 'degraded') return false;
+ recordCache = new Map(valid.map(record => [record.id, record]));
+ return true;
+ });
+ }
+
+ function startReady() {
+ const generation = ++readinessGeneration;
+ readinessState = { state:'initializing', reason:'' };
+ const operation = initialize(generation).catch(() => false);
+ readinessPromise = new Promise(resolve => {
+ const timer = setTimeout(() => {
+ if (generation !== readinessGeneration || readinessState.state !== 'initializing') return;
+ hydrateLegacyCache();
+ readinessState = { state:'degraded', reason:'deadline' };
+ resolve(false);
+ }, Math.max(1, Number(initializationTimeoutMs) || 1500));
+ operation.then(available => {
+ if (generation !== readinessGeneration || readinessState.state === 'degraded') return;
+ clearTimeout(timer);
+ if (available) readinessState = { state:'ready', reason:'' };
+ else {
+ hydrateLegacyCache();
+ readinessState = { state:'degraded', reason:'unavailable' };
}
- recordCache = new Map(valid.map(record => [record.id, record]));
- return true;
+ resolve(Boolean(available));
});
- }).catch(() => false);
+ });
+ return readinessPromise;
+ }
+
+ function ready() {
+ if (!transact || readinessState.state === 'ready') return Promise.resolve(true);
+ if (readinessState.state === 'degraded') return Promise.resolve(false);
+ return readinessPromise || startReady();
+ }
+
+ function status() {
+ return { ...readinessState };
+ }
+
+ function retry() {
+ if (!transact) return Promise.resolve(true);
+ migrationPromise = undefined;
+ readinessPromise = undefined;
+ return startReady();
}
function enabled() {
try { return storage.getItem(ENABLED_KEY) === 'true'; }
@@ -165,6 +221,11 @@
storage.removeItem(SNAPSHOT_KEY);
storage.removeItem(DETAILS_KEY);
} catch (_) { /* IndexedDB remains the source of truth. */ }
+ if (readinessState.state === 'degraded') {
+ pendingDurableClear = true;
+ recordCache = new Map();
+ return Promise.resolve(true);
+ }
migrationPromise = Promise.resolve(true);
return transact(async records => {
await records.clear();
@@ -234,7 +295,7 @@
};
if (transact) {
record.id = 'detail:' + login + ':' + key;
- return ready().then(() => transact(async records => {
+ return ready().then(available => available ? transact(async records => {
const existing = (await records.getAll()).filter(candidate =>
candidate?.id?.startsWith('detail:') && candidate.id !== record.id
);
@@ -246,7 +307,7 @@
overflow.forEach(candidate => recordCache.delete(candidate.id));
recordCache.set(record.id, record);
return true;
- })).catch(() => false);
+ }) : false).catch(() => false);
}
const records = readDetails().filter(candidate =>
!(candidate?.key === key && candidate?.user_login === login)
@@ -313,11 +374,11 @@
},
};
if (transact) {
- return ready().then(() => transact(async records => {
+ return ready().then(available => available ? transact(async records => {
await records.put(record);
recordCache.set(record.id, record);
return true;
- })).catch(() => false);
+ }) : false).catch(() => false);
}
try { storage.setItem(SNAPSHOT_KEY, JSON.stringify(record)); }
catch (_) { return false; }
@@ -357,7 +418,7 @@
return { ...record.data, saved_at: record.saved_at };
}
- return { enabled, setEnabled, ready, save, load, saveDetail, loadDetail, clear };
+ return { enabled, setEnabled, ready, retry, status, save, load, saveDetail, loadDetail, clear };
}
return createOfflineWorkStore;
diff --git a/tests/test_offline_work.py b/tests/test_offline_work.py
index df2565b..143f429 100644
--- a/tests/test_offline_work.py
+++ b/tests/test_offline_work.py
@@ -157,6 +157,91 @@ process.stdout.write(JSON.stringify({recordIds:[...records.keys()]}));
assert result["recordIds"] == []
+def test_ready_degrades_within_deadline_when_indexeddb_never_settles():
+ 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:'Still available'}], pull_requests:[], notifications:[]},
+}));
+const neverTransaction = async () => new Promise(() => {});
+const store = createOfflineWorkStore({
+ storage, transaction:neverTransaction, initializationTimeoutMs:20,
+ now:() => new Date('2026-08-07T12:01:00Z'),
+});
+const started = Date.now();
+const outcome = await Promise.race([
+ store.ready().then(value => ({value, status:store.status(), saved:store.load('timmy')})),
+ new Promise(resolve => setTimeout(() => resolve({timedOut:true}), 100)),
+]);
+process.stdout.write(JSON.stringify({...outcome, elapsed:Date.now() - started,
+ legacySnapshot:values.has('stackchain.offline-work.snapshot.v1')}));
+""")
+
+ assert result.get("timedOut", False) is False
+ assert result["value"] is False
+ assert result["status"] == {"state": "degraded", "reason": "deadline"}
+ assert result["saved"]["issues"][0]["title"] == "Still available"
+ assert result["legacySnapshot"] is True
+ assert result["elapsed"] < 100
+
+
+def test_degraded_store_rejects_saves_without_waiting_for_stalled_indexeddb():
+ result = run_async_scenario("""
+values.set('stackchain.offline-work.enabled.v1', 'true');
+const neverTransaction = async () => new Promise(() => {});
+const store = createOfflineWorkStore({storage, transaction:neverTransaction, initializationTimeoutMs:10});
+await store.ready();
+const outcome = await Promise.race([
+ store.save({user:{login:'timmy'}, issues:[], pull_requests:[], notifications:[]})
+ .then(value => ({value})),
+ new Promise(resolve => setTimeout(() => resolve({timedOut:true}), 80)),
+]);
+process.stdout.write(JSON.stringify(outcome));
+""")
+
+ assert result == {"value": False}
+
+
+def test_degraded_store_clears_legacy_data_without_waiting_for_indexeddb():
+ result = run_async_scenario("""
+values.set('stackchain.offline-work.snapshot.v1', '{"private":"snapshot"}');
+values.set('stackchain.offline-work.details.v1', '[{"private":"detail"}]');
+const neverTransaction = async () => new Promise(() => {});
+const store = createOfflineWorkStore({storage, transaction:neverTransaction, initializationTimeoutMs:10});
+await store.ready();
+const outcome = await Promise.race([
+ store.clear().then(value => ({value, keys:[...values.keys()]})),
+ new Promise(resolve => setTimeout(() => resolve({timedOut:true}), 80)),
+]);
+process.stdout.write(JSON.stringify(outcome));
+""")
+
+ assert result == {"value": True, "keys": []}
+
+
+def test_retry_recovers_durable_saving_after_initialization_deadline():
+ result = run_async_scenario("""
+let stalled = true;
+const recoveringTransaction = async work => stalled ? new Promise(() => {}) : transaction(work);
+const store = createOfflineWorkStore({storage, transaction:recoveringTransaction, initializationTimeoutMs:10});
+const initial = await store.ready();
+stalled = false;
+const recovered = await store.retry();
+store.setEnabled(true);
+const saved = await store.save({user:{login:'timmy'}, issues:[], pull_requests:[], notifications:[]});
+process.stdout.write(JSON.stringify({initial, recovered, saved, status:store.status(), recordIds:[...records.keys()]}));
+""")
+
+ assert result == {
+ "initial": False,
+ "recovered": True,
+ "saved": True,
+ "status": {"state": "ready", "reason": ""},
+ "recordIds": ["snapshot"],
+ }
+
+
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')});
@@ -348,6 +433,10 @@ 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 "let offlineStorageReady = await offlineWorkStore.ready();" in html
+ assert 'id="retry-offline-storage"' in html
+ assert "Offline saving unavailable · online work remains live." in html
+ assert "await offlineWorkStore.retry()" in html
assert "async function hydrateOfflineWork" in html
assert "const saved = await offlineWorkStore.load();" in html
assert "await offlineWorkStore.save({" in html