src/workspace/config-cache.ts — config evaluation cache
Purpose
Section titled “Purpose”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.
Public surface
Section titled “Public surface”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 importexport function stripLiterals(source: string, strings?: string[]): string | nullexport function blobOidOf(bytes: Uint8Array): stringexport function transpileInputs(): string // the bunfig, flags and BUN_OPTIONS the key folds; once per processexport 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.
Purity gate
Section titled “Purity gate”The key is null — evaluate live, store nothing — unless the whole
closure is provably pure:
- every import is relative, or exactly
@vzn/vxtaking only the values inPURE_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 anexport *of the package evaluates live; - every
importthe code holds is one the static scan read. The scan runs onstripLiteralsoutput 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). Animporttoken the scan cannot place — a property namedimportis one — evaluates live rather than keying without it. A statement’s body never runs past the nextimportorexport, and needs no line start: in a file without semicolons the scan once ran fromexport type Mode = …on into the next line’simport { 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 ofglobalThis— a computedglobal['proc' + 'ess']never spellsprocess),fetch,Date,Temporal,Intl,crypto,performance,navigator,require,eval,Function,constructor,localeCompare(a locale is the environment too),await,toLocale*, the reflective primitives that reachFunctionwithout naming it (Reflect,getPrototypeOf,setPrototypeOf,getOwnPropertyNames,getOwnPropertyDescriptor,getOwnPropertyDescriptors,__proto__,prototype,__defineGetter__,__defineSetter__,__lookupGetter__,__lookupSetter__; item 957),random(the word, not onlyMath.random: one destructured from Math was cached as pure, item 1036),prompt,confirmandalert(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 dynamicimport(; - no string literal holds
constructor,__proto__orprototype: as a computed key (fn['constructor']) it isFunction, 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
\u0070rocessISprocesswhile 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.
Invariants
Section titled “Invariants”- A hit is served unvalidated: only validated configs are stored.
- JSON is already the contract for a config object (
hashTaskConfigandvx lockgo throughJSON.stringify), so a cached config derives the same task cache key as a live evaluation of the same bytes. fresh: true(whatvx lockuses) bypasses the cache in both directions, even when anevalCacheis 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.
The warm fast path (2026-09-03)
Section titled “The warm fast path (2026-09-03)”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.jswithpreset.ts, and apreset.jscreated later takes over (item 950); - a symlink on the way: retargeting
shared -> sharedAmoves 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/aretargeted tovariants/b, a config of the same bytes beside another preset, moved no listed path, and the warm path replayeda’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.