// Values a provider resolves on one scan and reuses on the next — a board id
// that only a careers-page request reveals, for example.
//
// Providers never touch the disk. scan.mjs and scan-ats-full.mjs open the
// store, hand each provider its own slice as `ctx.idCache` (get / set /
// delete), and save when the run ends (scan-ats-full.mjs also at each
// checkpoint), never under --dry-run. A run that does not open the
// store (verify-portals, tests) passes no `ctx.idCache`, and a provider then
// resolves the value the way it would without a cache.
//
// One JSON object per provider, `
/.json`. A file is read the
// first time its provider asks for its slice. save() writes only the slices
// this run changed: under the same cross-process lock as data/pipeline.md
// (pipeline-lock.mjs, on the slice's own path) it reads each file again,
// applies this run's sets and deletes on top, and replaces the file through a
// temp file + rename, so two runs that overlap keep each other's entries.
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { withPipelineLock } from './pipeline-lock.mjs';
import { renameSyncWithRetry } from './tracker-utils.mjs';
export const PROVIDER_ID_CACHE_DIR = 'data/cache/provider-ids';
// Provider ids become file names, so only a plain id gets a slice.
const SAFE_PROVIDER_ID = /^[a-z0-9][a-z0-9_-]*$/i;
const DELETED = Symbol('deleted');
/** @param {string} file @returns {Record} */
function readSlice(file) {
if (!existsSync(file)) return {};
try {
const data = JSON.parse(readFileSync(file, 'utf8'));
return data && typeof data === 'object' && !Array.isArray(data) ? data : {};
} catch {
// An unreadable cache costs one extra lookup per key, never a failed scan.
return {};
}
}
/**
* Open the store rooted at `dir`.
*
* @param {string} dir - Directory holding one `.json` per provider.
* @returns {{
* forProvider: (providerId: string) => ({ get: (key: string) => any, set: (key: string, value: any) => void, delete: (key: string) => void } | undefined),
* save: () => Promise,
* }}
*/
export function openProviderIdCache(dir) {
/** @type {Map, changes: Map }>} */
const slices = new Map();
function slice(providerId) {
let s = slices.get(providerId);
if (!s) {
const file = path.join(dir, `${providerId}.json`);
s = { file, values: new Map(Object.entries(readSlice(file))), changes: new Map() };
slices.set(providerId, s);
}
return s;
}
return {
forProvider(providerId) {
if (typeof providerId !== 'string' || !SAFE_PROVIDER_ID.test(providerId)) return undefined;
const s = slice(providerId);
return {
get: (key) => s.values.get(String(key)),
set: (key, value) => {
s.values.set(String(key), value);
s.changes.set(String(key), value);
},
delete: (key) => {
s.values.delete(String(key));
s.changes.set(String(key), DELETED);
},
};
},
async save() {
// A slice that fails to write does not stop the others; the first error
// is thrown once every changed slice has been tried.
let firstError = null;
for (const s of slices.values()) {
if (s.changes.size === 0) continue;
// Taken from this point on, the changes made while the lock is awaited
// stay queued for the next save().
const changes = new Map(s.changes);
s.changes.clear();
try {
mkdirSync(path.dirname(s.file), { recursive: true });
await withPipelineLock(s.file, () => {
const merged = readSlice(s.file);
for (const [key, value] of changes) {
if (value === DELETED) delete merged[key];
else merged[key] = value;
}
// Keys sorted so the file diffs cleanly between runs.
const sorted = Object.fromEntries(Object.keys(merged).sort().map((key) => [key, merged[key]]));
const tmp = `${s.file}.${process.pid}.tmp`;
writeFileSync(tmp, `${JSON.stringify(sorted, null, 2)}\n`, 'utf8');
renameSyncWithRetry(tmp, s.file);
});
} catch (err) {
// Queue the unwritten changes again, unless a newer change to the
// same key came in meanwhile.
for (const [key, value] of changes) if (!s.changes.has(key)) s.changes.set(key, value);
firstError ??= err;
}
}
if (firstError) throw firstError;
},
};
}