feat(version): derive data cache version from content hash of the data tree

Replace the hand-maintained DATA_VERSION constant with a build-time SHA-256
hash of static/data/ embedded as a 'version' field in the preload manifest.
The runtime reads it via fetchWithFallback and clears the IDB cache when it
differs, so any data edit auto-invalidates cached data — no manual bump to
forget. The no-version (offline/stale) and IDB read-error guards are kept,
so the cache is never wiped on an unreadable signal.

Closes audit item L1.
This commit is contained in:
catarrh committed 2026-11-02 20:53:20 +03:00
1 parent f534fe6006
commit baf2baf65d
5 files changed
+67 -17

No files matched your search

+1 -1
View File
@@ -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 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: **Preload manifest**: After adding or removing data files, regenerate the preload manifest:
```bash ```bash
+1 -1
View File
@@ -34,7 +34,7 @@ npm run dev
Файлы данных в `static/data/` отслеживаются в git. Единственный игнорируемый файл вывода — `scripts/output/minei.json` (33МБ источник Четьи-Миней). Файлы данных в `static/data/` отслеживаются в git. Единственный игнорируемый файл вывода — `scripts/output/minei.json` (33МБ источник Четьи-Миней).
**Версия данных**: `DATA_VERSION` в `src/ui/app.ts` управляет инвалидацией кэша. Увеличивайте её при реструктуризации данных (новые поля, перегруппированные пачки, обновлённые источники), чтобы пользователи получили свежие данные при следующей загрузке приложения. **Версия данных**: вычисляется автоматически при сборке как хеш содержимого `static/data/` и записывается в поле `version` манифеста предзагрузки. Любое изменение данных (включая правку существующих файлов) автоматически инвалидирует кэш при следующей загрузке приложения — ручное увеличение версии не требуется. Перегенерируйте через `npm run generate-manifest` и закоммитьте манифест вместе с изменениями данных.
**Манифест предзагрузки**: После добавления или удаления файлов данных перегенерируйте манифест: **Манифест предзагрузки**: После добавления или удаления файлов данных перегенерируйте манифест:
```bash ```bash
+45 -7
View File
@@ -7,19 +7,20 @@
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import { createHash } from 'crypto';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
const DATA_DIR = path.resolve(__dirname, '../static/data'); 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]; const dom = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
interface Manifest { interface Manifest {
[lang: string]: { version?: string;
calendar: string[]; en?: { calendar: string[]; bible: string[] };
bible: string[]; ru?: { calendar: string[]; bible: string[] };
}; cu?: { calendar: string[]; bible: string[] };
} }
function exists(p: string): boolean { function exists(p: string): boolean {
@@ -208,16 +209,53 @@ function calendarFiles(lang: string): string[] {
return files; return files;
} }
const manifest: Manifest = {} as Manifest; const manifest: Manifest = {};
for (const lang of LANGUAGES) { for (const lang of LANGUAGES) {
manifest[lang] = { manifest[lang] = {
calendar: calendarFiles(lang), calendar: calendarFiles(lang),
bible: ['/data/bible/versions.json'], 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'); 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)); fs.writeFileSync(outPath, JSON.stringify(manifest, null, 2));
console.log(`\nManifest written to ${outPath}`); console.log(`\nManifest written to ${outPath}`);
+18 -7
View File
@@ -76,8 +76,19 @@ export class App {
} }
private async checkDataVersion() { private async checkDataVersion() {
const DATA_VERSION = '2026-08-04'; // bump when data structure changes
try { 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__'); const present = await DataCache.has('__data_version__');
if (present === null) { if (present === null) {
// Unreadable IDB — never wipe the cache on a flaky read // Unreadable IDB — never wipe the cache on a flaky read
@@ -85,15 +96,15 @@ export class App {
return; return;
} }
if (!present) { if (!present) {
await DataCache.set('__data_version__', DATA_VERSION); await DataCache.set('__data_version__', dataVersion);
diagLog('version-init'); diagLog('version-init', { version: dataVersion });
return; return;
} }
const cachedVer = await DataCache.get('__data_version__') as string | null; const cachedVer = await DataCache.get('__data_version__') as string | null;
if (cachedVer !== DATA_VERSION) { if (cachedVer !== dataVersion) {
await DataCache.clear(); await DataCache.clear();
await DataCache.set('__data_version__', DATA_VERSION); await DataCache.set('__data_version__', dataVersion);
diagLog('version-clear', { old: cachedVer }); diagLog('version-clear', { old: cachedVer, version: dataVersion });
} }
} catch (err) { } catch (err) {
diagLog('idb-error', { where: 'checkDataVersion', msg: String(err) }); diagLog('idb-error', { where: 'checkDataVersion', msg: String(err) });
@@ -106,7 +117,7 @@ export class App {
this.handleRoute(); this.handleRoute();
window.addEventListener('hashchange', () => 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(); this.checkDataVersion();
// Close dropdown menus on click/touch outside // Close dropdown menus on click/touch outside
+2 -1
View File
@@ -5260,5 +5260,6 @@
"bible": [ "bible": [
"/data/bible/versions.json" "/data/bible/versions.json"
] ]
} },
"version": "7631e35da99e"
} }