diff --git a/README.md b/README.md index e3c3ab3..38b4eb9 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ npm run dev Data files in `static/data/` are tracked in git. The only gitignored output is `scripts/output/minei.json` (33MB Chetyi-Minei source). -**Data version**: `DATA_VERSION` in `src/ui/app.ts` controls cache invalidation. Bump it when restructuring data (new fields, reorganized bundles, updated sources) to force users to re-fetch cached data on next app load. +**Data version**: derived automatically at build time from a content hash of `static/data/`, embedded as a `version` field in the preload manifest. Any data change (including edits to existing files) auto-invalidates cached data on the next app load — no manual bump required. Regenerate with `npm run generate-manifest` and commit the manifest with the data changes. **Preload manifest**: After adding or removing data files, regenerate the preload manifest: ```bash diff --git a/README_RUS.md b/README_RUS.md index ce484ba..cd36bfe 100644 --- a/README_RUS.md +++ b/README_RUS.md @@ -34,7 +34,7 @@ npm run dev Файлы данных в `static/data/` отслеживаются в git. Единственный игнорируемый файл вывода — `scripts/output/minei.json` (33МБ источник Четьи-Миней). -**Версия данных**: `DATA_VERSION` в `src/ui/app.ts` управляет инвалидацией кэша. Увеличивайте её при реструктуризации данных (новые поля, перегруппированные пачки, обновлённые источники), чтобы пользователи получили свежие данные при следующей загрузке приложения. +**Версия данных**: вычисляется автоматически при сборке как хеш содержимого `static/data/` и записывается в поле `version` манифеста предзагрузки. Любое изменение данных (включая правку существующих файлов) автоматически инвалидирует кэш при следующей загрузке приложения — ручное увеличение версии не требуется. Перегенерируйте через `npm run generate-manifest` и закоммитьте манифест вместе с изменениями данных. **Манифест предзагрузки**: После добавления или удаления файлов данных перегенерируйте манифест: ```bash diff --git a/scripts/generate-preload-manifest.ts b/scripts/generate-preload-manifest.ts index 3944de9..d088dab 100644 --- a/scripts/generate-preload-manifest.ts +++ b/scripts/generate-preload-manifest.ts @@ -7,19 +7,20 @@ import * as fs from 'fs'; import * as path from 'path'; +import { createHash } from 'crypto'; import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const DATA_DIR = path.resolve(__dirname, '../static/data'); -const LANGUAGES = ['en', 'ru', 'cu']; +const LANGUAGES = ['en', 'ru', 'cu'] as const; const dom = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; interface Manifest { - [lang: string]: { - calendar: string[]; - bible: string[]; - }; + version?: string; + en?: { calendar: string[]; bible: string[] }; + ru?: { calendar: string[]; bible: string[] }; + cu?: { calendar: string[]; bible: string[] }; } function exists(p: string): boolean { @@ -208,16 +209,53 @@ function calendarFiles(lang: string): string[] { return files; } -const manifest: Manifest = {} as Manifest; +const manifest: Manifest = {}; for (const lang of LANGUAGES) { manifest[lang] = { calendar: calendarFiles(lang), bible: ['/data/bible/versions.json'], }; - console.log(`${lang}: ${manifest[lang].calendar.length} calendar files`); + console.log(`${lang}: ${manifest[lang]!.calendar.length} calendar files`); } const outPath = path.join(DATA_DIR, 'shared', 'preload-manifest.json'); + +/** + * SHA-256 content hash over the data tree (relative path + bytes, sorted), + * truncated to 12 hex chars. The manifest itself is excluded so regenerating + * it cannot change its own version. Used by the runtime to invalidate the + * IndexedDB cache whenever any data file changes. + */ +function computeDataVersion(dataDir: string, excludePath: string): string { + const hash = createHash('sha256'); + const files: string[] = []; + const walk = (dir: string): void => { + const entries = fs.readdirSync(dir, { withFileTypes: true }) + .filter((e) => !e.name.startsWith('.')) + .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) walk(full); + else if (entry.isFile()) files.push(full); + } + }; + walk(dataDir); + files.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); + const excludeAbs = path.resolve(excludePath); + for (const file of files) { + if (path.resolve(file) === excludeAbs) continue; // exclude the manifest itself (avoids self-reference) + const rel = path.relative(dataDir, file).split(path.sep).join('/'); + hash.update(rel); + hash.update('\0'); + hash.update(fs.readFileSync(file)); + hash.update('\0'); + } + return hash.digest('hex').slice(0, 12); +} + +const version = computeDataVersion(DATA_DIR, outPath); +manifest.version = version; +console.log(`\nData version: ${version}`); fs.writeFileSync(outPath, JSON.stringify(manifest, null, 2)); -console.log(`\nManifest written to ${outPath}`); \ No newline at end of file +console.log(`\nManifest written to ${outPath}`); diff --git a/src/ui/app.ts b/src/ui/app.ts index a048345..de820cd 100644 --- a/src/ui/app.ts +++ b/src/ui/app.ts @@ -76,8 +76,19 @@ export class App { } private async checkDataVersion() { - const DATA_VERSION = '2026-08-04'; // bump when data structure changes try { + // Data version is derived at build time from a content hash of the data + // tree, embedded in the preload manifest (scripts/generate-preload-manifest.ts). + // Reading it here means any data change auto-invalidates cached data — + // no hand-maintained constant to forget to bump. + const manifest = await DataCache.fetchWithFallback<{ version?: string }>('/data/shared/preload-manifest.json'); + const dataVersion = manifest?.version; + if (typeof dataVersion !== 'string' || dataVersion.length === 0) { + // No manifest/version (offline first run, or a stale deployment) — + // can't determine staleness; never wipe the cache on an unreadable signal. + diagLog('version-check', { result: 'no-version', action: 'skip' }); + return; + } const present = await DataCache.has('__data_version__'); if (present === null) { // Unreadable IDB — never wipe the cache on a flaky read @@ -85,15 +96,15 @@ export class App { return; } if (!present) { - await DataCache.set('__data_version__', DATA_VERSION); - diagLog('version-init'); + await DataCache.set('__data_version__', dataVersion); + diagLog('version-init', { version: dataVersion }); return; } const cachedVer = await DataCache.get('__data_version__') as string | null; - if (cachedVer !== DATA_VERSION) { + if (cachedVer !== dataVersion) { await DataCache.clear(); - await DataCache.set('__data_version__', DATA_VERSION); - diagLog('version-clear', { old: cachedVer }); + await DataCache.set('__data_version__', dataVersion); + diagLog('version-clear', { old: cachedVer, version: dataVersion }); } } catch (err) { diagLog('idb-error', { where: 'checkDataVersion', msg: String(err) }); @@ -106,7 +117,7 @@ export class App { this.handleRoute(); window.addEventListener('hashchange', () => this.handleRoute()); - // Data version check — bump DATA_VERSION when static data structure changes + // Data version check — the version comes from the preload manifest's content hash; run `npm run generate-manifest` after data changes this.checkDataVersion(); // Close dropdown menus on click/touch outside diff --git a/static/data/shared/preload-manifest.json b/static/data/shared/preload-manifest.json index 10ac975..5cbbe1d 100644 --- a/static/data/shared/preload-manifest.json +++ b/static/data/shared/preload-manifest.json @@ -5260,5 +5260,6 @@ "bible": [ "/data/bible/versions.json" ] - } + }, + "version": "7631e35da99e" } \ No newline at end of file