Skip to content
GitHubRSS

src/workspace/config-cache.ts — config evaluation cache

Skip re-evaluating a vx.config whose result cannot have changed. A config is a program; evaluating a thousand of them is the largest fixed cost of a warm run (2026-09-02, synthetic 1000-project workspace: ~80 ms to import the modules, ~12 ms to read the same files as data). The cache stores the validated config as JSON in cache.db (config_evals), and loadProjectConfig serves a hit without importing the module. prepareRun loads each round of configs through loadProjectConfigs, which reads every file’s bytes and key in parallel and asks the store ONCE (ConfigEvalStore.getConfigEvals, optional; Cache answers with one IN query per 900 keys — 1,000 point lookups measured 3.6 ms against 0.7 for the batch), then evaluates only the misses in the order given, so a failure names the first broken file as a one-by-one load did, and writes what the round learned ONCE at the end (putConfigEvals / putConfigClosures, one transaction each; a per-config put was one autocommit transaction each, and 1,000 of them cost 180–240 ms against 2.5 — item 615). The slow path keys a closure file from the bytes it read to scan it (hashBytes), not through the stat memo: the memo row it wrote was a third autocommit per file, and the first warm load builds the memo in one transaction instead.

export const CONFIG_EVAL_VERSION = 3
/** Where cached evaluations live; `Cache` implements it over `cache.db`. */
export interface ConfigEvalStore {
hashFile?(file: string): Promise<string> // the warm fast path: a file's git blob id behind a stat memo
hashFiles?(files: readonly string[]): Promise<Map<string, string>> // the same over many paths, one memo query per 500
hashBytes?(bytes: Uint8Array, nearPath: string): string // hashFile's identity from the bytes: the slow path's, no memo row
getConfigClosures?(configPaths: readonly string[]): Map<string, string[]>
putConfigClosure?(configPath: string, files: readonly string[]): void
putConfigClosures?(entries: ReadonlyArray<readonly [string, readonly string[]]>): void // a round's closures, one transaction
getConfigEval(key: string): string | null
getConfigEvals?(keys: readonly string[]): Map<string, string> // many keys in one round-trip
putConfigEval(key: string, json: string): void
putConfigEvals?(entries: ReadonlyArray<readonly [string, string]>): void // a round's evaluations, one transaction
}
/** What `configEvalKey` learned besides the key, for the store's closure index. */
export interface ConfigEvalKeyResult {
key: string
closure: string[] // the config first, then every relative import in discovery order
indexable: boolean // false when a relative import does not name its file outright
}
export interface ConfigEvalKeyArgs {
configPath: string
hashBytes?: (bytes: Uint8Array, nearPath: string) => string // the identity from the bytes in hand; preferred over hashFile
hashFile?: (file: string) => Promise<string> // absent too: the blob id is computed from the bytes in-process
bytes: Uint8Array
workspaceRoot?: string | undefined // a link below it makes the closure unindexable; absent, any link does
workspaceFingerprint: string
}
export const PURE_CORE_EXPORTS: ReadonlySet<string> // the `@vzn/vx` values a pure config may import
export function stripLiterals(source: string, strings?: string[]): string | null
export function blobOidOf(bytes: Uint8Array): string
export function transpileInputs(): string // the bunfig, flags and BUN_OPTIONS the key folds; once per process
export async function configEvalKey(a: ConfigEvalKeyArgs): Promise<ConfigEvalKeyResult | null>
export async function configEvalKeyFromClosure(a: {
closure: readonly string[]
hashFile: (file: string) => Promise<string>
workspaceFingerprint: string
}): Promise<string | null>
/** A config's relative-import closure outside node_modules, itself excluded; `vx watch` arms these (item 949). */
export async function configImports(configPath: string): Promise<string[]>

configEvalKey({ configPath, bytes, workspaceFingerprint }) folds, in order: CONFIG_EVAL_VERSION (3 since 2026-09-24, item 701: an evaluation cached before configs had to be JSON data may hold what JSON made of a Map or a hole, which the rule now refuses; 2 since 2026-09-03: the key folds each closure file’s git blob id, not its bytes), Bun.version, the workspace fingerprint (lockfiles — covers package imports), the transpile inputs (the bunfig.toml Bun loaded at startup — the cwd’s and the global one — and the process’s flags and BUN_OPTIONS: a [define] is a bare identifier to the config that no deny word sees, and flipping one replayed the old evaluation, item 956; read once per process, as Bun reads them), then for the config and every file it transitively imports by relative specifier: the path and its git blob id (over the bytes read). Editing a shared preset the config imports moves the key even though the config’s own bytes did not change.

The key is null — evaluate live, store nothing — unless the whole closure is provably pure:

  • every import is relative, or exactly @vzn/vx taking only the values in PURE_CORE_EXPORTS (defineProject / defineWorkspace, identity functions, and a few pure helpers and constants) or types. Core also exports what reads the machine (machineParallelism, machineMemoryBytes, collectInfo): a config calling one was replayed from the store with another box’s answer (item 888). Any other name, a namespace or default import, or an export * of the package evaluates live;
  • every import the code holds is one the static scan read. The scan runs on stripLiterals output with each string kept as a placeholder, so a comment before an import on its line and a string-named binding (import { 'a-b' as x }) are seen; the regex it replaced ran on the raw source, and a preset imported after /* … */ was neither keyed nor gated (item 952). An import token the scan cannot place — a property named import is one — evaluates live rather than keying without it. A statement’s body never runs past the next import or export, and needs no line start: in a file without semicolons the scan once ran from export type Mode = … on into the next line’s import { machineParallelism } from '@vzn/vx' and passed it as a type import, and } export * from './side.ts' was never scanned (item 1036);
  • no relative import resolves into node_modules (a workspace symlink can move without the lockfile moving);
  • the closure has ≤ 32 files;
  • with string literals and comments removed (stripLiterals), no file mentions a global through which the environment can leak: process, Bun, globalThis, global, self (Bun’s two live aliases of globalThis — a computed global['proc' + 'ess'] never spells process), fetch, Date, Temporal, Intl, crypto, performance, navigator, require, eval, Function, constructor, localeCompare (a locale is the environment too), await, toLocale*, the reflective primitives that reach Function without naming it (Reflect, getPrototypeOf, setPrototypeOf, getOwnPropertyNames, getOwnPropertyDescriptor, getOwnPropertyDescriptors, __proto__, prototype, __defineGetter__, __defineSetter__, __lookupGetter__, __lookupSetter__; item 957), random (the word, not only Math.random: one destructured from Math was cached as pure, item 1036), prompt, confirm and alert (they read the user’s terminal; D-25), arguments (in a CommonJS config its second entry is the require function; D-36), import.meta, or a dynamic import(;
  • no string literal holds constructor, __proto__ or prototype: as a computed key (fn['constructor']) it is Function, and the literal was stripped before the words above were tested (item 957);
  • no backslash survives in code position: outside literals that is an identifier escape, and \u0070rocess IS process while matching no word in the list. Every spelling in the last two rules was cached as pure before it was listed (2026-09-03).

stripLiterals refuses (returns null) on a / in code position that is neither a comment nor a division it can prove: a regex literal can contain a quote, and a lexer that misread one would swallow real code as a string — a false SAFE, the one outcome this module must never produce. A false negative costs one evaluation, never a stale key. The gate is syntactic, so it stops ACCIDENTAL impurity: a config written to defeat it can still assemble a key at run time (fn['constru' + 'ctor']) and reach the environment. Such a config chooses its own commands anyway; it is not a boundary against its author.

  • A hit is served unvalidated: only validated configs are stored.
  • JSON is already the contract for a config object (hashTaskConfig and vx lock go through JSON.stringify), so a cached config derives the same task cache key as a live evaluation of the same bytes.
  • fresh: true (what vx lock uses) bypasses the cache in both directions, even when an evalCache is passed beside it.
  • The store honours the run’s local read/write axes: --cache=local: neither reads nor writes it.
  • Rows not written for 30 days are pruned on a writing handle’s Cache.close() (never a reading verb’s). A hit does not refresh a row (a write per config on every warm run), so a config that hit for thirty days is evaluated once more and stored again.

tests/config-cache.test.ts: key stability and closure sensitivity, the allowed-import set, each impurity token, impurity inside an imported file, the literal stripper (strings, templates, comments, the slash bail-out), the served-from-store proof (a store row replaced under the same key is what the loader returns), the read/write axes, the batched round (one lookup serves the hit and evaluates the miss; two broken configs name the first in the given order), and the warm fast path (a load served from the indexed closure with nothing evaluated, a preset edit that misses through the fast key, an extensionless import never indexed) — with the mutations that fold only the config or drop the extension rule each failing exactly their pin.

Reading and scanning 1,000 configs to key them cost 15 ms on a warm run; stat-hashing them costs 5. The store (Cache) keeps each config’s ordered closure — the config first, then every relative import in discovery order — in config_closures, written whenever the slow path keys a config whose relative imports all name their files outright. On the next load, loadProjectConfigs keys such a config from per-file identities alone (Cache.hashFile: the git blob id behind an mtime/size/ctime/inode memo — no read, no scan; a file changed within FILE_HASH_RACY_MS of its stat is hashed but not memoised, so a config edited moments ago is never served from a stale identity) with configEvalKeyFromClosure, whose fold is byte-identical to configEvalKey’s, so the two paths share entries. Every indexed closure’s files are identified in one call (hashFiles, one memo query per 500 paths; 1,000 point reads cost 7.7 ms of a warm run, 2026-09-09). A fast key that misses takes the slow path for that config, which re-indexes it.

Sound because closure membership can only change by editing a listed file (the config, or an import that gains or drops an import), which changes that file’s identity and so the key — provided each import names its file outright and the config sits where its path says. Four spellings do not, and a config with any of them is served by the slow path and never indexed (indexable: false):

  • an extensionless import: a new file could change what it resolves to without touching any listed file;
  • an explicit extension that is not the file there: Bun answers ./preset.js with preset.ts, and a preset.js created later takes over (item 950);
  • a symlink on the way: retargeting shared -> sharedA moves every import through it and edits no listed file (item 950);
  • a config whose directory is reached through a symlink below the workspace root: a member packages/app -> ../variants/a retargeted to variants/b, a config of the same bytes beside another preset, moved no listed path, and the warm path replayed a’s evaluation (item 1036). A link above the root (macOS’s /var) keeps the index: the root is real-pathed once and the config’s real directory compared against it.

An import is resolved from the importing file’s REAL path, as Bun does: a config linked in from elsewhere imports its neighbours there, and the key once folded a decoy beside the link instead (item 950; configImports, which vx watch arms, had the same flaw). A named file is taken as is, without Bun.resolveSync: its directory cache answers a retargeted link with the old target for the rest of the process. The other spellings still ask it, so a long-lived process (vx watch) can key them on a stale resolution. Both directions are pinned in tests/config-cache.test.ts; the mutations that fold only the config, or drop the extension rule, each fail exactly their pin.