fix: bound IndexedDB dashboard startup (Closes #529)
All checks were successful
CI / lint (pull_request) Successful in 1m16s
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 01:28:56 +00:00
parent 3e16dd00d1
commit 11a56b21a1
4 changed files with 207 additions and 40 deletions

View File

@ -417,7 +417,7 @@
const draftInbox = createDraftInbox({ storage: localStorage, getCurrentLogin: () => activeFlushLogin }); const draftInbox = createDraftInbox({ storage: localStorage, getCurrentLogin: () => activeFlushLogin });
outboxCoordinator.subscribe(() => refreshMyWorkView()); outboxCoordinator.subscribe(() => refreshMyWorkView());
const offlineWorkStore = createOfflineWorkStore({ storage: localStorage, indexedDB:window.indexedDB }); const offlineWorkStore = createOfflineWorkStore({ storage: localStorage, indexedDB:window.indexedDB });
await offlineWorkStore.ready(); let offlineStorageReady = await offlineWorkStore.ready();
function renderOfflineTodayStatus(status) { function renderOfflineTodayStatus(status) {
const container = qs('#offline-today-readiness'); const container = qs('#offline-today-readiness');
const label = qs('#offline-today-status'); const label = qs('#offline-today-status');
@ -4735,12 +4735,16 @@
const offlineStatus = qs('#offline-status'); const offlineStatus = qs('#offline-status');
const keepWorkOffline = qs('#keep-work-offline'); const keepWorkOffline = qs('#keep-work-offline');
const offlineWorkStatus = qs('#offline-work-status'); const offlineWorkStatus = qs('#offline-work-status');
const retryOfflineStorage = qs('#retry-offline-storage');
const deliveryReceipts = qs('#delivery-receipts'); const deliveryReceipts = qs('#delivery-receipts');
const deliveryReceiptStatus = qs('#delivery-receipt-status'); const deliveryReceiptStatus = qs('#delivery-receipt-status');
async function updateOfflineWorkControls(message) { async function updateOfflineWorkControls(message) {
keepWorkOffline.checked = offlineWorkStore.enabled(); keepWorkOffline.checked = offlineWorkStore.enabled();
keepWorkOffline.disabled = !offlineStorageReady;
retryOfflineStorage.hidden = offlineStorageReady;
const saved = await offlineWorkStore.load(); 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.')); (keepWorkOffline.checked ? 'Waiting for a healthy live refresh.' : 'Off · no work data is stored.'));
} }
async function updateDeliveryReceiptControls(message) { async function updateDeliveryReceiptControls(message) {
@ -4848,6 +4852,18 @@
renderOfflineTodayStatus({ total:0, ready:0, failed:0, pending:0 }); renderOfflineTodayStatus({ total:0, ready:0, failed:0, pending:0 });
await updateOfflineWorkControls('Offline work data cleared.'); 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', () => qs('#retry-offline-today').addEventListener('click', () =>
offlineToday.retry(confirmedOwnerLogin, todayMyWork) offlineToday.retry(confirmedOwnerLogin, todayMyWork)
); );

View File

@ -98,6 +98,7 @@
<label for="keep-work-offline"><input id="keep-work-offline" type="checkbox" /> Keep My Work available offline</label> <label for="keep-work-offline"><input id="keep-work-offline" type="checkbox" /> Keep My Work available offline</label>
<label for="delivery-receipts"><input id="delivery-receipts" type="checkbox" /> Notify me when queued work finishes</label> <label for="delivery-receipts"><input id="delivery-receipts" type="checkbox" /> Notify me when queued work finishes</label>
<button id="clear-offline-work" type="button">Clear offline work data</button> <button id="clear-offline-work" type="button">Clear offline work data</button>
<button id="retry-offline-storage" type="button" hidden>Retry offline saving</button>
<span class="small" id="offline-work-status" role="status" aria-live="polite"></span> <span class="small" id="offline-work-status" role="status" aria-live="polite"></span>
<span class="offline-today-readiness" id="offline-today-readiness" hidden> <span class="offline-today-readiness" id="offline-today-readiness" hidden>
<span class="small" id="offline-today-status" role="status" aria-live="polite"></span> <span class="small" id="offline-today-status" role="status" aria-live="polite"></span>

View File

@ -85,12 +85,32 @@
function createOfflineWorkStore({ function createOfflineWorkStore({
storage, indexedDB, transaction, now = () => new Date(), maxAgeMs = DEFAULT_MAX_AGE_MS, storage, indexedDB, transaction, now = () => new Date(), maxAgeMs = DEFAULT_MAX_AGE_MS,
maxDetails = 10, maxDetails = 10, initializationTimeoutMs = 1500,
}) { }) {
const transact = transaction || createIndexedDbTransaction(indexedDB); const transact = transaction || createIndexedDbTransaction(indexedDB);
let migrationPromise; let migrationPromise;
let readinessPromise;
let readinessGeneration = 0;
let readinessState = transact ? { state:'initializing', reason:'' } : { state:'ready', reason:'' };
let recordCache = null; 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 (!transact) return Promise.resolve(true);
if (!migrationPromise) migrationPromise = (async () => { if (!migrationPromise) migrationPromise = (async () => {
let snapshot = null; let snapshot = null;
@ -113,46 +133,82 @@
if (!(await records.get(id))) await records.put({ ...record, id }); if (!(await records.get(id))) await records.put({ ...record, id });
} }
}); });
if (generation !== readinessGeneration || readinessState.state === 'degraded') return false;
storage.removeItem(SNAPSHOT_KEY); storage.removeItem(SNAPSHOT_KEY);
storage.removeItem(DETAILS_KEY); storage.removeItem(DETAILS_KEY);
return true; return true;
})().catch(() => false); })().catch(() => false);
return migrationPromise; return migrationPromise;
} }
function ready() {
if (!transact) return Promise.resolve(true); async function initialize(generation) {
if (recordCache) return Promise.resolve(true); const migrated = await migrateLegacy(generation);
return migrateLegacy().then(migrated => { if (!migrated || generation !== readinessGeneration || readinessState.state === 'degraded') return false;
if (!migrated) { if (pendingDurableClear) {
recordCache = new Map(); await transact(async records => { await records.clear(); });
try { if (generation !== readinessGeneration || readinessState.state === 'degraded') return false;
const snapshot = JSON.parse(storage.getItem(SNAPSHOT_KEY) || 'null'); pendingDurableClear = false;
if (snapshot) recordCache.set('snapshot', { ...snapshot, id:'snapshot' }); recordCache = new Map();
const details = JSON.parse(storage.getItem(DETAILS_KEY) || '[]'); return true;
if (Array.isArray(details)) details.forEach(record => { }
if (!record?.key || !record?.user_login || !record?.data) return; return transact(async records => {
const id = 'detail:' + record.user_login + ':' + record.key; const currentTime = now().getTime();
recordCache.set(id, { ...record, id }); const valid = [];
}); for (const record of await records.getAll()) {
} catch (_) { recordCache.clear(); } const savedAt = Date.parse(record?.saved_at || '');
return false; 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 => { if (generation !== readinessGeneration || readinessState.state === 'degraded') return false;
const currentTime = now().getTime(); recordCache = new Map(valid.map(record => [record.id, record]));
const valid = []; return true;
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 : function startReady() {
record?.id?.startsWith('detail:') && record.user_login && record.key && record.data; const generation = ++readinessGeneration;
if (!validShape || !Number.isFinite(savedAt) || currentTime - savedAt > maxAgeMs) { readinessState = { state:'initializing', reason:'' };
if (record?.id) await records.delete(record.id); const operation = initialize(generation).catch(() => false);
} else valid.push(record); 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])); resolve(Boolean(available));
return true;
}); });
}).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() { function enabled() {
try { return storage.getItem(ENABLED_KEY) === 'true'; } try { return storage.getItem(ENABLED_KEY) === 'true'; }
@ -165,6 +221,11 @@
storage.removeItem(SNAPSHOT_KEY); storage.removeItem(SNAPSHOT_KEY);
storage.removeItem(DETAILS_KEY); storage.removeItem(DETAILS_KEY);
} catch (_) { /* IndexedDB remains the source of truth. */ } } catch (_) { /* IndexedDB remains the source of truth. */ }
if (readinessState.state === 'degraded') {
pendingDurableClear = true;
recordCache = new Map();
return Promise.resolve(true);
}
migrationPromise = Promise.resolve(true); migrationPromise = Promise.resolve(true);
return transact(async records => { return transact(async records => {
await records.clear(); await records.clear();
@ -234,7 +295,7 @@
}; };
if (transact) { if (transact) {
record.id = 'detail:' + login + ':' + key; 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 => const existing = (await records.getAll()).filter(candidate =>
candidate?.id?.startsWith('detail:') && candidate.id !== record.id candidate?.id?.startsWith('detail:') && candidate.id !== record.id
); );
@ -246,7 +307,7 @@
overflow.forEach(candidate => recordCache.delete(candidate.id)); overflow.forEach(candidate => recordCache.delete(candidate.id));
recordCache.set(record.id, record); recordCache.set(record.id, record);
return true; return true;
})).catch(() => false); }) : false).catch(() => false);
} }
const records = readDetails().filter(candidate => const records = readDetails().filter(candidate =>
!(candidate?.key === key && candidate?.user_login === login) !(candidate?.key === key && candidate?.user_login === login)
@ -313,11 +374,11 @@
}, },
}; };
if (transact) { if (transact) {
return ready().then(() => transact(async records => { return ready().then(available => available ? transact(async records => {
await records.put(record); await records.put(record);
recordCache.set(record.id, record); recordCache.set(record.id, record);
return true; return true;
})).catch(() => false); }) : false).catch(() => false);
} }
try { storage.setItem(SNAPSHOT_KEY, JSON.stringify(record)); } try { storage.setItem(SNAPSHOT_KEY, JSON.stringify(record)); }
catch (_) { return false; } catch (_) { return false; }
@ -357,7 +418,7 @@
return { ...record.data, saved_at: record.saved_at }; 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; return createOfflineWorkStore;

View File

@ -157,6 +157,91 @@ process.stdout.write(JSON.stringify({recordIds:[...records.keys()]}));
assert result["recordIds"] == [] 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(): def test_opted_in_snapshot_survives_restart_with_only_queue_card_fields():
result = run_scenario(""" result = run_scenario("""
const store = createOfflineWorkStore({storage, now:() => new Date('2026-08-07T12:00:00Z')}); const store = createOfflineWorkStore({storage, now:() => new Date('2026-08-07T12:00:00Z')});
@ -348,6 +433,10 @@ async def test_dashboard_uses_async_indexeddb_offline_store_end_to_end():
html = await dashboard() html = await dashboard()
assert "createOfflineWorkStore({ storage: localStorage, indexedDB:window.indexedDB })" in html 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 "async function hydrateOfflineWork" in html
assert "const saved = await offlineWorkStore.load();" in html assert "const saved = await offlineWorkStore.load();" in html
assert "await offlineWorkStore.save({" in html assert "await offlineWorkStore.save({" in html