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
+68 -18

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 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
+1 -1
View File
@@ -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
+46 -8
View File
@@ -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}`);
console.log(`\nManifest written to ${outPath}`);
+18 -7
View File
@@ -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
+2 -1
View File
@@ -5260,5 +5260,6 @@
"bible": [
"/data/bible/versions.json"
]
}
},
"version": "7631e35da99e"
}