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:
1 parent
f534fe6006
commit
baf2baf65d
5 files changed
+68
-18
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -5260,5 +5260,6 @@
|
||||
"bible": [
|
||||
"/data/bible/versions.json"
|
||||
]
|
||||
}
|
||||
},
|
||||
"version": "7631e35da99e"
|
||||
}
|
||||
Reference in new issue
Block a user