All files / src/utils translation-cache.js

96.87% Statements 31/32
72.72% Branches 8/11
100% Functions 7/7
96.66% Lines 29/30

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85                              24x 24x 24x 24x   24x   24x     24x   2x 2x 2x           94x       70x 70x   1x         38x             35x   35x   35x     74x 74x         20x 20x 20x 20x 20x 20x 20x         1x         24x  
/**
 * Two-layer disk translation cache (SHY-0072).
 *
 *  - SEED  (committed, read-only at runtime): src/data/translation-cache-seed.json
 *    — written only by Claude backfill PRs draining the miss queue.
 *  - RUNTIME (gitignored, created on first write): data/translation-cache.json
 *    — populated by translate-on-first-view; wins over seed on collision.
 *
 * Keys: `sha256(text):target`. Disk, NOT Firestore — free-tier quota is a
 * real constraint and this cache is hot. Runtime writes are atomic
 * (write `.tmp`, rename). A corrupt layer never crashes boot: that layer
 * loads empty with an ERROR log (fail-open to re-translation, fail-silent
 * to users).
 */
 
const fs = require('node:fs');
const path = require('node:path');
const crypto = require('node:crypto');
const log = require('./log');
 
const os = require('node:os');
 
const DEFAULT_SEED = path.join(__dirname, '..', 'data', 'translation-cache-seed.json');
// Under Jest, an un-overridden runtime path falls back to a per-process
// tmpdir so test runs never pollute the repo's data/ dir or each other.
let testDefaultDir = null;
function defaultRuntimePath() {
  Eif (process.env.NODE_ENV === 'test') {
    Eif (!testDefaultDir) testDefaultDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tcache-default-'));
    return path.join(testDefaultDir, 'translation-cache.json');
  }
  return path.join(__dirname, '..', '..', 'data', 'translation-cache.json');
}
 
function keyOf(text, target) {
  return `${crypto.createHash('sha256').update(text).digest('hex')}:${target}`;
}
 
function loadLayer(p, label) {
  try {
    if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf-8'));
  } catch (err) {
    log.error('translate', `translation cache ${label} unreadable — starting that layer empty`, {
      path: p,
      error: err.message,
    });
  }
  return {};
}
 
function createTranslationCache({
  seedPath = DEFAULT_SEED,
  runtimePath = defaultRuntimePath(),
} = {}) {
  const runtime = loadLayer(runtimePath, 'runtime');
  // Merged view: seed under runtime (runtime wins).
  const mem = { ...loadLayer(seedPath, 'seed'), ...runtime };
 
  return {
    /** @returns {string|null} cached translation or null on miss */
    get(text, target) {
      const v = mem[keyOf(text, target)];
      return v === undefined ? null : v;
    },
 
    /** Persist a translation: memory + atomic runtime-file write. */
    set(text, target, translated) {
      const k = keyOf(text, target);
      mem[k] = translated;
      runtime[k] = translated;
      fs.mkdirSync(path.dirname(runtimePath), { recursive: true });
      const tmp = `${runtimePath}.tmp`;
      fs.writeFileSync(tmp, JSON.stringify(runtime));
      fs.renameSync(tmp, runtimePath);
    },
 
    /** Entry count across both layers (future 50K-entries WARN hook). */
    size() {
      return Object.keys(mem).length;
    },
  };
}
 
module.exports = { createTranslationCache };