feat: serve all precached data offline via DataCache fallback

- DataCache.fetchWithFallback: network-first with IDB write-through under
  the same url: keys the preload writes; falls back to precached IDB copy
  when offline — closes the preload(IDB) vs read(SW cache) gap that made
  the Bible reader (and every other view) fail offline despite precaching
- Convert all 33 raw /data fetches across bible/service/menaion/prayer/
  calendar/settings views; in-memory caches and notFound maps preserved
- fetchCommandText: switch to lowercase services/commands/ (already in
  preload manifest); tolerate both TEXT/text node casings per migrate-data
- Remove stale duplicate static/data/en/services/Command/ (15 files)
- fetchLives: fix misc fallback to real lives/misc/{N}.json bundles
This commit is contained in:
catarrh committed 2026-08-24 09:50:07 +03:00
1 parent 623c56a96f
commit a005e386a6
22 files changed
+118 -198

No files matched your search

+24
View File
@@ -204,6 +204,30 @@ export class DataCache {
}
}
/**
* Network-first fetch with IndexedDB fallback.
*
* Online: fetches, parses and writes through to IDB under the same
* `url:` keys the offline preload uses. Offline: serves the precached
* IDB copy, or null when the file was never preloaded.
*/
static async fetchWithFallback<T = unknown>(url: string, type: 'json' | 'text' = 'json'): Promise<T | null> {
try {
const resp = await fetch(url);
const ct = resp.headers.get('content-type');
if (resp.ok && !(ct && ct.includes('text/html'))) {
const data: unknown = type === 'text' ? await resp.text() : await resp.json();
await DataCache.set(`url:${url}`, data);
return data as T;
}
} catch { /* offline or invalid response — fall through to IDB */ }
try {
return ((await DataCache.get(`url:${url}`)) as T) ?? null;
} catch {
return null;
}
}
/**
* Cache-first fetch: tries IndexedDB first, falls back to network.
* If the data is fetched from network, it's cached for future use.
+6 -5
View File
@@ -9,6 +9,7 @@ import {
formatPassageHTML,
} from '../core/bible-reader';
import { getTranslations, type LanguageCode } from '../core/i18n';
import { DataCache } from '../core/data-cache';
const SETTINGS_KEY = 'ponomar-settings';
const BIBLE_STATE_KEY = 'ponomar-bible-state';
@@ -138,8 +139,9 @@ export class BibleView {
private async loadVersions() {
try {
const resp = await fetch('/data/bible/versions.json');
this.versions = await resp.json();
const versions = await DataCache.fetchWithFallback<typeof this.versions>('/data/bible/versions.json');
if (!versions || !Array.isArray(versions) || versions.length === 0) throw new Error('unavailable');
this.versions = versions;
this.sortVersionsByLanguage();
} catch {
this.versions = [
@@ -473,9 +475,8 @@ export class BibleView {
if (!text) {
// Version IDs are full paths like "en/bible/kjv"
const resp = await fetch(`/data/${this.currentVersion}/${this.currentBook}.text`);
if (!resp.ok) throw new Error('Not found');
text = await resp.text();
text = (await DataCache.fetchWithFallback<string>(`/data/${this.currentVersion}/${this.currentBook}.text`, 'text')) ?? undefined;
if (!text) throw new Error('Not found');
this.loadedText.set(cacheKey, text);
}
+11 -24
View File
@@ -52,12 +52,10 @@ async function loadBookAbbrevs(lang: string): Promise<void> {
bookAbbrevLang = lang;
for (let attempt = 0; attempt < 2; attempt++) {
try {
const resp = await fetch('/data/bible/versions.json');
if (!resp.ok) { if (attempt === 0) await new Promise(r => setTimeout(r, 300)); continue; }
const ct = resp.headers.get('content-type');
if (ct && ct.includes('text/html')) { if (attempt === 0) await new Promise(r => setTimeout(r, 300)); continue; }
const versions = await resp.json() as any[];
const ver = versions.find((v: any) => v.id.startsWith(lang + '/')) || versions[0];
const versions = await DataCache.fetchWithFallback<any[]>('/data/bible/versions.json');
const ver = Array.isArray(versions)
? (versions.find((v: any) => v.id.startsWith(lang + '/')) || versions[0])
: null;
if (ver) {
const map: Record<string, string> = {};
if (ver.books) {
@@ -72,8 +70,9 @@ async function loadBookAbbrevs(lang: string): Promise<void> {
return;
}
} catch {
if (attempt === 0) await new Promise(r => setTimeout(r, 300));
// fall through
}
if (attempt === 0) await new Promise(r => setTimeout(r, 300));
}
}
@@ -295,11 +294,7 @@ async function loadLife(lang: LanguageCode, cid: string): Promise<LifeData | nul
async function loadCommemoration(sharedId: string): Promise<CommemorationData | null> {
try {
const resp = await fetch(`/data/shared/commemorations/${sharedId}.json`);
if (!resp.ok) return null;
const ct = resp.headers.get('content-type');
if (ct && ct.includes('text/html')) return null;
return await resp.json();
return await DataCache.fetchWithFallback<CommemorationData>(`/data/shared/commemorations/${sharedId}.json`);
} catch {
return null;
}
@@ -350,12 +345,8 @@ async function loadMenaion(lang: LanguageCode, month: number, day: number): Prom
async function loadTriodionData(): Promise<Record<string, unknown>> {
if (cachedTriodion) return cachedTriodion;
try {
const resp = await fetch('/data/shared/calendar/triodion.json');
if (!resp.ok) return {};
const ct = resp.headers.get('content-type');
if (ct && ct.includes('text/html')) return {};
cachedTriodion = await resp.json();
return cachedTriodion!;
cachedTriodion = (await DataCache.fetchWithFallback<Record<string, unknown>>('/data/shared/calendar/triodion.json')) ?? {};
return cachedTriodion;
} catch {
return {};
}
@@ -371,12 +362,8 @@ async function loadTriodionFile(index: number): Promise<unknown[]> {
async function loadPentecostarionData(): Promise<Record<string, unknown>> {
if (cachedPentecostarion) return cachedPentecostarion;
try {
const resp = await fetch('/data/shared/calendar/pentecostarion.json');
if (!resp.ok) return {};
const ct = resp.headers.get('content-type');
if (ct && ct.includes('text/html')) return {};
cachedPentecostarion = await resp.json();
return cachedPentecostarion!;
cachedPentecostarion = (await DataCache.fetchWithFallback<Record<string, unknown>>('/data/shared/calendar/pentecostarion.json')) ?? {};
return cachedPentecostarion;
} catch {
return {};
}
+6 -6
View File
@@ -7,6 +7,7 @@ import { assembleService, type ServiceContext } from '../core/service-assembler'
import type { EvalContext, ServiceNode } from '../core/types';
import { getTranslations, type LanguageCode } from '../core/i18n';
import { loadSettings, fontClass } from './settings-view';
import { DataCache } from '../core/data-cache';
export class MenaionView {
private container: HTMLElement;
@@ -45,9 +46,9 @@ export class MenaionView {
async render() {
try {
const resp = await fetch('/data/shared/menaion-daily/index.json');
if (resp.ok) {
this.index = await resp.json() as Record<string, number>;
const index = await DataCache.fetchWithFallback<Record<string, number>>('/data/shared/menaion-daily/index.json');
if (index) {
this.index = index;
}
} catch { /* empty */ }
@@ -164,9 +165,8 @@ export class MenaionView {
let html = '';
for (let i = 1; i <= count; i++) {
try {
const resp = await fetch(`/data/shared/menaion-daily/${dateKey}/${i}.json`);
if (!resp.ok) continue;
const nodes: ServiceNode[] = await resp.json();
const nodes = await DataCache.fetchWithFallback<ServiceNode[]>(`/data/shared/menaion-daily/${dateKey}/${i}.json`);
if (!nodes) continue;
const evalCtx: EvalContext = {};
const ctx: ServiceContext = {
evalCtx, lang: 'cu', t: this.t,
+3 -3
View File
@@ -8,6 +8,7 @@ import { assembleService, type ServiceContext } from '../core/service-assembler'
import type { EvalContext, ServiceNode } from '../core/types';
import { getTranslations, type LanguageCode } from '../core/i18n';
import { loadSettings, fontClass } from './settings-view';
import { DataCache } from '../core/data-cache';
export type PrayerCollection = 'prayer-rule' | 'akathists' | 'parimii' | 'horologion' | 'sbornik' | 'paraclete' | 'irmologion' | 'triodion';
@@ -270,12 +271,11 @@ export class PrayerView {
}
dataPath = `/data/shared/services/paraclete/tone${m[1]}/${m[2]}.json`;
}
const resp = await fetch(dataPath);
if (!resp.ok) {
const nodes = await DataCache.fetchWithFallback<ServiceNode[]>(dataPath);
if (!nodes) {
contentEl.innerHTML = `<p class="text-navy-light">${this.t.prayer.notFound}</p>`;
return;
}
const nodes: ServiceNode[] = await resp.json();
const evalCtx: EvalContext = {};
const ctx: ServiceContext = {
+62 -139
View File
@@ -15,6 +15,7 @@ import {
HOLY_WEEK, holyWeekVarName, type ServiceTabDef,
} from '../core/service-tabs';
import { loadSettings, fontClass } from './settings-view';
import { DataCache } from '../core/data-cache';
interface ServiceDef {
id: string;
@@ -74,9 +75,8 @@ export class ServiceView {
// Load the daily Menaion index (date -> section count) for the Служба дня tabs
try {
const resp = await fetch('/data/shared/menaion-daily/index.json');
if (resp.ok) {
const index = await resp.json() as Record<string, number>;
const index = await DataCache.fetchWithFallback<Record<string, number>>('/data/shared/menaion-daily/index.json');
if (index) {
for (const [dk, n] of Object.entries(index)) {
this.menaionDayCount.set(dk, n);
}
@@ -386,9 +386,8 @@ export class ServiceView {
try {
const tryFetch = async (lang: string) => {
const url = `/data/${lang}/services/octoecheos/tone${toneNum}/${weekday.toLowerCase()}.json`;
const resp = await fetch(url);
if (!resp.ok) return null;
const nodes: ServiceNode[] = await resp.json();
const nodes = await DataCache.fetchWithFallback<ServiceNode[]>(url);
if (!nodes) return null;
for (const node of nodes) {
if (node.type === serviceType) {
if (!node.cmd || evalBool(node.cmd as string, this.evalContext)) {
@@ -417,11 +416,10 @@ export class ServiceView {
try {
const tryLoad = async (url: string): Promise<{ cid: string }[] | null> => {
try {
const resp = await fetch(url);
if (!resp.ok) return null;
const all = await resp.json();
const all = await DataCache.fetchWithFallback<Record<string, unknown>[]>(url);
if (!all) return null;
if (Array.isArray(all)) return all.map((e: Record<string, unknown>) => ({ cid: String(e.id ?? '') }));
const entries = all[dateKey] as Record<string, unknown>[] | undefined;
const entries = (all as Record<string, unknown>)[dateKey] as Record<string, unknown>[] | undefined;
if (Array.isArray(entries)) return entries.map((e: Record<string, unknown>) => ({ cid: String(e.id ?? '') }));
return null;
} catch { return null; }
@@ -435,11 +433,10 @@ export class ServiceView {
// Load from merged calendar files
const loadCalendarEntry = async (file: string, index: number): Promise<{ cid: string }[] | null> => {
try {
const resp = await fetch(`/data/shared/calendar/${file}.json`);
if (!resp.ok) return null;
const data = await resp.json();
const data = await DataCache.fetchWithFallback<Record<string, unknown>>(`/data/shared/calendar/${file}.json`);
if (!data) return null;
const key = String(index).padStart(2, '0');
const entry = data[key];
const entry = data[key] as { id?: unknown } | undefined;
return entry ? [{ cid: String(entry.id ?? '') }] : null;
} catch { return null; }
};
@@ -457,9 +454,8 @@ export class ServiceView {
for (const entry of allEntries) {
if (!entry.cid) continue;
try {
const shmResp = await fetch(`/data/shared/commemorations/${entry.cid}.json`);
if (!shmResp.ok) continue;
const shm = await shmResp.json();
const shm = await DataCache.fetchWithFallback<Record<string, unknown>>(`/data/shared/commemorations/${entry.cid}.json`);
if (!shm) continue;
const troparion = shm.TROPARION as { Tone?: string; text?: string } | undefined;
const kontakion = shm.KONTAKION as { Tone?: string; text?: string } | undefined;
if (troparion?.text || kontakion?.text) {
@@ -507,9 +503,8 @@ export class ServiceView {
const toneNum = tone === 8 ? 0 : tone;
const url = `/data/shared/services/canons/tone${toneNum}/sunday.json`;
try {
const resp = await fetch(url);
if (!resp.ok) return null;
const canonData: CanonData = await resp.json();
const canonData = await DataCache.fetchWithFallback<CanonData>(url);
if (!canonData) return null;
const ODE_SLAV = ['', 'а', 'в', 'г', 'д', 'є', 'ѕ', 'з', 'и', 'ѳ'];
const nodes: ServiceNode[] = [];
@@ -543,62 +538,27 @@ export class ServiceView {
const dayKey = WEEKDAY[dow] ?? '';
if (!dayKey) return null;
const url = `/data/shared/services/canons/paraclete/tone${tone}/${dayKey}.json`;
try {
const resp = await fetch(url);
if (!resp.ok) return null;
const nodes: ServiceNode[] = await resp.json();
return nodes;
} catch {
return null;
}
return DataCache.fetchWithFallback<ServiceNode[]>(url);
}
private async loadMenaionDay(dateKey: string, index: number): Promise<ServiceNode[] | null> {
const url = `/data/shared/menaion-daily/${dateKey}/${index}.json`;
try {
const resp = await fetch(url);
if (!resp.ok) return null;
const nodes: ServiceNode[] = await resp.json();
return nodes;
} catch {
return null;
}
return DataCache.fetchWithFallback<ServiceNode[]>(url);
}
private async loadTriodion(index: number): Promise<ServiceNode[] | null> {
const url = `/data/shared/triodion/${String(index).padStart(2, '0')}.json`;
try {
const resp = await fetch(url);
if (!resp.ok) return null;
const nodes: ServiceNode[] = await resp.json();
return nodes;
} catch {
return null;
}
return DataCache.fetchWithFallback<ServiceNode[]>(url);
}
private async loadMariasStandingData(): Promise<ServiceNode[] | null> {
const url = `/data/shared/services/marias-standing/full.json`;
try {
const resp = await fetch(url);
if (!resp.ok) return null;
const nodes: ServiceNode[] = await resp.json();
return nodes;
} catch {
return null;
}
return DataCache.fetchWithFallback<ServiceNode[]>(url);
}
private async loadHolyWeekData(service: string): Promise<ServiceNode[] | null> {
const url = `/data/shared/services/${service}/full.json`;
try {
const resp = await fetch(url);
if (!resp.ok) return null;
const nodes: ServiceNode[] = await resp.json();
return nodes;
} catch {
return null;
}
return DataCache.fetchWithFallback<ServiceNode[]>(url);
}
private async loadGreatCanonData(): Promise<ServiceNode[] | null> {
@@ -617,9 +577,8 @@ export class ServiceView {
const url = `/data/shared/services/canons/great-canon/part${part}.json`;
try {
const resp = await fetch(url);
if (!resp.ok) return null;
const canonData: GreatCanonPart = await resp.json();
const canonData = await DataCache.fetchWithFallback<GreatCanonPart>(url);
if (!canonData) return null;
const ODE_SLAV = ['', 'а҃', 'в҃', 'г҃', 'д҃', 'є҃', 'ѕ҃', 'з҃', 'и҃', 'ѳ҃'];
const nodes: ServiceNode[] = [];
@@ -872,12 +831,11 @@ export class ServiceView {
const dd = String(this.currentDate.getDay()).padStart(2, '0');
const dateKey = `${mm}-${dd}`;
try {
const resp = await fetch(`/data/${this.serviceLang}/menaion-bundle.json`);
if (resp.ok) {
const bundle = await resp.json();
const bundle = await DataCache.fetchWithFallback<Record<string, unknown>>(`/data/${this.serviceLang}/menaion-bundle.json`);
if (bundle) {
const entries = bundle[dateKey];
if (entries) {
for (const e of entries) {
for (const e of entries as Record<string, unknown>[]) {
if (e.id) ids.push(String(e.id));
}
}
@@ -885,9 +843,8 @@ export class ServiceView {
} catch {}
for (const cid of ids) {
try {
const resp = await fetch(`/data/shared/commemorations/${cid}.json`);
if (!resp.ok) continue;
const data = await resp.json();
const data = await DataCache.fetchWithFallback<Record<string, unknown>>(`/data/shared/commemorations/${cid}.json`);
if (!data) continue;
const scripts = data.SCRIPTURE;
if (!Array.isArray(scripts)) continue;
let epistlePassage = '';
@@ -1000,11 +957,9 @@ export class ServiceView {
const tryFetch = async (lang: string): Promise<string | null> => {
const url = `/data/${lang}/bible/${version}/${book}.text`;
if (this.notFound.has(url)) return null;
const resp = await fetch(url);
if (!resp.ok) { this.notFound.add(url); return null; }
const ct = resp.headers.get('content-type');
if (ct && ct.includes('text/html')) { this.notFound.add(url); return null; }
return resp.text();
const text = await DataCache.fetchWithFallback<string>(url, 'text');
if (!text) { this.notFound.add(url); return null; }
return text;
};
let text = await tryFetch(this.serviceLang);
@@ -1103,15 +1058,12 @@ export class ServiceView {
const cached = this.livesCache.get(cacheKey);
if (cached) return cached;
const bundleName = /^(0[1-9]|1[0-2])\d+/.test(id) ? id.substring(0, 2) : 'misc';
const bundleName = /^(0[1-9]|1[0-2])\d+/.test(id) ? id.substring(0, 2) : `misc/${id.charAt(0)}`;
const tryFetch = async (lang: string): Promise<Record<string, Record<string, Record<string, unknown>>> | null> => {
const url = `/data/${lang}/lives/${bundleName}.json`;
if (this.notFound.has(url)) return null;
const resp = await fetch(url);
if (!resp.ok) { this.notFound.add(url); return null; }
const ct = resp.headers.get('content-type');
if (ct && ct.includes('text/html')) { this.notFound.add(url); return null; }
const bundle = await resp.json() as Record<string, Record<string, unknown>>;
const bundle = await DataCache.fetchWithFallback<Record<string, Record<string, unknown>>>(url);
if (!bundle) { this.notFound.add(url); return null; }
const entry = bundle[id] as { services?: Record<string, Record<string, Record<string, unknown>>> } | undefined;
return entry?.services || null;
};
@@ -1129,15 +1081,12 @@ export class ServiceView {
if (cached) return cached;
const tryFetch = async (lang: string): Promise<string | null> => {
const url = `/data/${lang}/services/Command/${name}.json`;
const url = `/data/${lang}/services/commands/${name}.json`;
if (this.notFound.has(url)) return null;
const resp = await fetch(url);
if (!resp.ok) { this.notFound.add(url); return null; }
const ct = resp.headers.get('content-type');
if (ct && ct.includes('text/html')) { this.notFound.add(url); return null; }
const json = await resp.json() as Record<string, unknown>[];
const json = await DataCache.fetchWithFallback<Record<string, unknown>[]>(url);
if (!json) { this.notFound.add(url); return null; }
return json
.filter(n => n.type === 'TEXT')
.filter(n => n.type === 'TEXT' || n.type === 'text')
.map(n => String(n.value ?? ''))
.filter(Boolean)
.join(' ') || null;
@@ -1157,11 +1106,8 @@ export class ServiceView {
const tryFetch = async (lang: string): Promise<{ cmd: string; value: string }[] | null> => {
const url = `/data/${lang}/commands/Times.json`;
if (this.notFound.has(url)) return null;
const resp = await fetch(url);
if (!resp.ok) { this.notFound.add(url); return null; }
const ct = resp.headers.get('content-type');
if (ct && ct.includes('text/html')) { this.notFound.add(url); return null; }
const json = await resp.json() as { DATA: { TIMES: { '@_Value': string; '@_Cmd': string }[] } };
const json = await DataCache.fetchWithFallback<{ DATA?: { TIMES?: { '@_Value': string; '@_Cmd': string }[] } }>(url);
if (!json) { this.notFound.add(url); return null; }
const arr = json?.DATA?.TIMES;
if (!Array.isArray(arr)) return null;
return arr.map(r => ({ value: r['@_Value'], cmd: r['@_Cmd'] }));
@@ -1227,22 +1173,13 @@ export class ServiceView {
if (this.notFound.has(url)) return null;
const cached = this.jsonCache.get(url);
if (cached) return cached;
try {
const resp = await fetch(url);
if (!resp.ok) { this.notFound.add(url); return null; }
const ct = resp.headers.get('content-type');
if (ct && ct.includes('text/html')) { this.notFound.add(url); return null; }
const json = JSON.parse(await resp.text());
if (Array.isArray(json)) {
this.jsonCache.set(url, json);
return json;
}
this.notFound.add(url);
return null;
} catch {
this.notFound.add(url);
return null;
const json = await DataCache.fetchWithFallback<Record<string, unknown>[]>(url);
if (Array.isArray(json)) {
this.jsonCache.set(url, json);
return json;
}
this.notFound.add(url);
return null;
};
const filterNodes = (json: Record<string, unknown>[], header: boolean): string | null => {
@@ -1254,7 +1191,7 @@ export class ServiceView {
.join(' ') || null;
}
return json
.filter(n => n.type === 'TEXT')
.filter(n => n.type === 'TEXT' || n.type === 'text')
.map(n => String(n.value ?? '').replace(/\n/g, ' '))
.filter(Boolean)
.join('<br>') || null;
@@ -1293,27 +1230,18 @@ export class ServiceView {
return `/data/${lang}/services/${fetchPath}`;
};
const tryFetch = async (url: string): Promise<ServiceNode[] | null> => {
if (this.notFound.has(url)) return null;
const cached = this.nodeCache.get(url);
if (cached) return cached;
try {
const resp = await fetch(url);
if (!resp.ok) { this.notFound.add(url); return null; }
const ct = resp.headers.get('content-type') || '';
if (ct.includes('text/html')) { this.notFound.add(url); return null; }
const nodes = await resp.json();
if (Array.isArray(nodes)) {
this.nodeCache.set(url, nodes);
return nodes;
}
this.notFound.add(url);
return null;
} catch {
this.notFound.add(url);
return null;
}
};
const tryFetch = async (url: string): Promise<ServiceNode[] | null> => {
if (this.notFound.has(url)) return null;
const cached = this.nodeCache.get(url);
if (cached) return cached;
const nodes = await DataCache.fetchWithFallback<ServiceNode[]>(url);
if (Array.isArray(nodes)) {
this.nodeCache.set(url, nodes);
return nodes;
}
this.notFound.add(url);
return null;
};
try {
let url = resolvePath(this.serviceLang);
@@ -1373,15 +1301,10 @@ export class ServiceView {
if (this.notFound.has(url)) return null;
const cached = this.nodeCache.get(url);
if (cached) return cached;
try {
const resp = await fetch(url);
if (!resp.ok) { this.notFound.add(url); return null; }
const ct = resp.headers.get('content-type') || '';
if (ct.includes('text/html')) { this.notFound.add(url); return null; }
const nodes = await resp.json();
this.nodeCache.set(url, nodes);
return nodes;
} catch { this.notFound.add(url); return null; }
const nodes = await DataCache.fetchWithFallback<ServiceNode[]>(url);
if (!nodes) { this.notFound.add(url); return null; }
this.nodeCache.set(url, nodes);
return nodes;
};
let url = resolvePath(this.serviceLang);
if (url) {
+6 -6
View File
@@ -326,8 +326,8 @@ export class SettingsView {
// Load default Bible versions dynamically
(async () => {
try {
const resp = await fetch('/data/bible/versions.json');
const versions = await resp.json();
const versions = await DataCache.fetchWithFallback<any[]>('/data/bible/versions.json');
if (!versions || !Array.isArray(versions)) return;
const userLang = this.settings.language;
// Sort by language priority
@@ -479,8 +479,8 @@ export class SettingsView {
// Load translations on first expand
if (container.innerHTML === '') {
try {
const resp = await fetch('/data/bible/versions.json');
const versions = await resp.json();
const versions = await DataCache.fetchWithFallback<any[]>('/data/bible/versions.json');
if (!versions || !Array.isArray(versions)) throw new Error('unavailable');
const userLang = this.settings.language;
versions.sort((a: any, b: any) => {
const pa = versionPriority(a.language, userLang);
@@ -637,8 +637,8 @@ export class SettingsView {
// Count cached Bible translations
(async () => {
try {
const resp = await fetch('/data/bible/versions.json');
const versions = await resp.json();
const versions = await DataCache.fetchWithFallback<any[]>('/data/bible/versions.json');
if (!versions || !Array.isArray(versions)) return;
const total = versions.length;
let cached = 0;
for (const v of versions) {