stackchain-dashboard/frontend/offline-work.js
timmy 11a56b21a1
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
fix: bound IndexedDB dashboard startup (Closes #529)
2026-08-11 01:28:56 +00:00

426 lines
17 KiB
JavaScript

(function (root, factory) {
const api = factory();
if (typeof module !== 'undefined' && module.exports) module.exports = api;
else root.createOfflineWorkStore = api;
})(typeof self !== 'undefined' ? self : this, function () {
'use strict';
const ENABLED_KEY = 'stackchain.offline-work.enabled.v1';
const SNAPSHOT_KEY = 'stackchain.offline-work.snapshot.v1';
const DETAILS_KEY = 'stackchain.offline-work.details.v1';
const VERSION = 1;
const DEFAULT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
const ITEM_FIELDS = [
'id', 'number', 'title', 'state', 'repository', 'labels', 'assignees',
'updated_at', 'due_date', 'milestone', 'url', 'work_reasons',
];
const NOTIFICATION_FIELDS = [
'id', 'number', 'title', 'unread', 'repository', 'subject_type', 'updated_at', 'url',
];
const DETAIL_FIELDS = [
'title', 'body', 'state', 'labels', 'assignees', 'author', 'url', 'due_date', 'milestone',
'id', 'repository', 'subject_type', 'subject_body', 'source_updated_at', 'head_sha', 'ci_state',
];
const COMMENT_FIELDS = ['id', 'author', 'body', 'created_at', 'updated_at', 'url'];
const REVIEW_FILE_FIELDS = [
'filename', 'status', 'additions', 'deletions', 'diff_available', 'diff_binary', 'diff_truncated',
];
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 => {
if (source && source[field] !== undefined) output[field] = source[field];
});
return output;
}
function createOfflineWorkStore({
storage, indexedDB, transaction, now = () => new Date(), maxAgeMs = DEFAULT_MAX_AGE_MS,
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;
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;
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 });
}
});
if (generation !== readinessGeneration || readinessState.state === 'degraded') return false;
storage.removeItem(SNAPSHOT_KEY);
storage.removeItem(DETAILS_KEY);
return true;
})().catch(() => false);
return migrationPromise;
}
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);
}
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' };
}
resolve(Boolean(available));
});
});
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'; }
catch (_) { return false; }
}
function clear() {
if (transact) {
try {
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();
recordCache = new Map();
return true;
}).catch(() => false);
}
try {
storage.removeItem(SNAPSHOT_KEY);
storage.removeItem(DETAILS_KEY);
}
catch (_) { return false; }
return true;
}
function detailKey(item) {
if (item?.kind === 'update') {
const notificationId = Number(item?.notification_id || 0);
return Number.isInteger(notificationId) && notificationId > 0 ? 'update:' + notificationId : '';
}
const kind = item?.kind === 'review' ? 'review' :
item?.kind === 'pull' ? 'pull' : item?.kind === 'issue' ? 'issue' : '';
const repository = String(item?.repository || '');
const number = Number(item?.number || 0);
return kind && repository && number > 0 ? [kind, repository, number].join(':') : '';
}
function readDetails() {
try {
const value = JSON.parse(storage.getItem(DETAILS_KEY) || '[]');
return Array.isArray(value) ? value : [];
} catch (_) {
try { storage.removeItem(DETAILS_KEY); } catch (_error) { /* Best effort purge. */ }
return [];
}
}
function saveDetail(login, item, detail) {
const key = detailKey(item);
login = String(login || '').trim();
if (!enabled() || !login || !key || !detail) return false;
const conversation = detail.conversation || {};
const data = {
...pick(detail, DETAIL_FIELDS),
conversation: {
comments: (conversation.comments || []).slice(-20).map(comment => pick(comment, COMMENT_FIELDS)),
page: Number(conversation.page || 1),
older_page: conversation.older_page ?? null,
total: Number(conversation.total || 0),
},
};
if (item?.is_review || item?.kind === 'review') {
data.files = (detail.files || []).slice(0, 50).map(file => ({
...pick(file, REVIEW_FILE_FIELDS),
diff_lines: (file?.diff_lines || []).slice(0, 400).map(line => String(line)),
}));
data.reviews = (detail.reviews || []).slice(-20).map(review => ({
...pick(review, REVIEW_FIELDS),
user: pick(review?.user, ['login']),
}));
}
const record = {
key,
user_login: login,
saved_at: now().toISOString(),
data,
};
if (transact) {
record.id = 'detail:' + login + ':' + key;
return ready().then(available => available ? 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;
}) : false).catch(() => false);
}
const records = readDetails().filter(candidate =>
!(candidate?.key === key && candidate?.user_login === login)
);
records.push(record);
try { storage.setItem(DETAILS_KEY, JSON.stringify(records.slice(-Math.max(1, maxDetails)))); }
catch (_) { return false; }
return true;
}
function loadDetail(login, item) {
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 => {
const savedAt = Date.parse(record?.saved_at || '');
return record?.key && record?.user_login && record?.data && Number.isFinite(savedAt) &&
currentTime - savedAt <= maxAgeMs;
});
if (valid.length !== records.length) {
try { storage.setItem(DETAILS_KEY, JSON.stringify(valid)); } catch (_) { /* Best effort purge. */ }
}
const record = valid.find(candidate => candidate.key === key && candidate.user_login === login);
return record ? { ...record.data, saved_at: record.saved_at } : null;
}
function setEnabled(value) {
try {
storage.setItem(ENABLED_KEY, value ? 'true' : 'false');
if (!value) clear();
return true;
} catch (_) { return false; }
}
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(),
data: {
user: pick(snapshot.user, ['login', 'full_name']),
issues: (snapshot.issues || []).map(item => pick(item, ITEM_FIELDS)),
pull_requests: (snapshot.pull_requests || []).map(item => pick(item, ITEM_FIELDS)),
notifications: (snapshot.notifications || []).map(item => pick(item, NOTIFICATION_FIELDS)),
work_pagination: snapshot.work_pagination || {},
notification_pagination: snapshot.notification_pagination || {},
},
};
if (transact) {
return ready().then(available => available ? transact(async records => {
await records.put(record);
recordCache.set(record.id, record);
return true;
}) : false).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; }
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) {
clear();
return null;
}
if (expectedLogin && record.user_login !== expectedLogin) return null;
return { ...record.data, saved_at: record.saved_at };
}
return { enabled, setEnabled, ready, retry, status, save, load, saveDetail, loadDetail, clear };
}
return createOfflineWorkStore;
});