Skip to content
vxvx
GitHubBlueskydev.toRSS

cache

cache makes a task cached: outputs lists what it writes, which a hit restores. A task with no cache block always runs.

build: {
exec: { command: 'tsc -b' },
inputs: { files: ['src/**', 'tsconfig.json'] },
cache: { outputs: { files: ['dist/**'] } },
}
interface CacheConfig {
outputs: CacheOutputs // required when `cache` is provided
inputStability?: InputStability // optional
}
interface InputStability {
strategy?: 'immutable' | 'mutable' | 'unchecked' // default 'immutable'
severity?: 'warn' | 'error' // default 'warn'
}

inputStability says what a write to an input while the command runs means. strategy says which writes count:

  • 'immutable' (the default, on CI too): any write. An edit undone mid-run, or another task changing the file and putting it back, cannot be told from no edit.
  • 'mutable': a file rewritten to the bytes the key folded counts as unchanged, for a task that rewrites its own input identically (tsdown’s package.json); different bytes still count, and so does any write while another task that may write the file was running (one of the same project, or one whose sandbox grants writes elsewhere in the workspace; a server vx watch keeps from an earlier run counts), since it may have changed the file and put it back. The line names that task; order the two with dependsOn to save.
  • 'unchecked': none. The entry is saved under the key taken before the command, whatever the command did to its inputs; severity is ignored. A key that no longer held before the command started still withholds it.

A write that counts withholds the entry, and severity says what else happens: 'warn' (the default) keeps the result, says why on the status line, and the run goes on; 'error' fails the task, so the run fails, and its label names the file: failed (src/a.ts changed during the run, exit 1) (inputMoved in vx last --format json). Either way, every task that ran over the task’s outputs, down the graph, withholds its save too, and a task of any project that declares the moved file as an input withholds its own.

Each field is resolved on its own, highest first:

  1. VX_CACHE_INPUT_STABILITY_STRATEGY (immutable, mutable, unchecked) and VX_CACHE_INPUT_STABILITY_SEVERITY (warn, error), for the whole run (a CI job that wants error sets it without editing configs); any other value is refused before a task runs;
  2. the task’s own cache.inputStability;
  3. the workspace’s inputStability (.vx/workspace.ts);
  4. the default, 'immutable' and 'warn'.

Never folded into a cache key.

import { defineProject } from '@vzn/vx/config'
export default defineProject({
tasks: {
build: {
// tsdown rewrites package.json with the same bytes on every build;
// different bytes fail the run rather than rebuild next time.
exec: { command: 'tsdown' },
inputs: { files: ['src/**', 'package.json'] },
cache: {
outputs: { files: ['dist/**'] },
inputStability: { strategy: 'mutable', severity: 'error' },
},
},
},
})

Caching is opt-in. Omit cache and the task always runs (no read, no write). Provide it and caching is active for that task. The project loader requires exec, the task’s inputs and cache.outputs when cache is set: declaring “what does this read?” and “what does it produce?” is a deliberate, conscious choice, and a task with no command produces nothing to cache.

Why opt-in? Defaulting caching to ON with implicit “all files / no outputs” silently produces stale builds the moment a user forgets to revisit the config. The cost of a single forgotten cache miss (re-run a task) is much less than the cost of a stale cache hit (ship broken artifacts).

interface CacheOutputs {
files: readonly string[] // required
workspaceFiles?: readonly string[] // optional; workspace-root-relative
clean?: boolean // optional; false keeps outputs on a miss
}

Project-relative globs of files the task produces. Captured on cache write, restored on cache hit (overwriting any local modifications), and wiped before exec AND before restore so the project dir ends every run bit-identical to the cached snapshot. A literal entry names a file or a whole directory tree: dist and dist/ are dist/** (the turbo.json shape "outputs": ["dist"] migrates as it is); a leading ./ is accepted and dropped; . alone is refused.

outputs: {
files: ['dist/**']
} // typical build output
outputs: {
files: []
} // tasks with no file output (lint, test)
outputs: {
files: ['dist/**', 'coverage/**']
}

Outputs are NOT filtered through gitignore — typical artifact dirs like dist/, coverage/, .next/, pkg/ are captured normally even when gitignored (they usually are).

A symlink the globs match is an output: it is cached and restored as the link, its target as written (a link to a file, to a directory, or to nothing yet), and the clean unlinks it (never following it). No byte of its target is read. A link must be relative and lead somewhere inside the workspace: an absolute one, or ../../../etc/hosts, fails the save by name and caches nothing, so the next run executes again. An output DIRECTORY that is a symlink (dist -> real-out) is followed: its target is cleaned, saved and restored as the output; one that resolves outside the project refuses the task, naming the link. The clean also prunes the directories it emptied, so an output that is a directory one run and a file the next restores either way.

Empty [] is valid for tasks that produce no files (e.g. lint, typecheck, test); you still cache the no-op success so the next run is a no-op too.

clean: false keeps the outputs on a miss instead of wiping them first, for a tool that reads its own last output (tsc’s .tsbuildinfo). It opts out of the clean-tree guarantee: the save stores every file the outputs match after the run, so a file an older build left behind is cached and restored on every machine. A retry still starts clean, and a hit still restores bit-identically. Omitted → the workspace’s cache.outputs.clean, else true.

A ! entry takes a path back from what the positive globs select (A-44; a scratch dir under dist stays out of the clean this way; a tracked file needs no !, below):

outputs: {
files: ['dist/**', '!**/*.tmp']
}

A path taken back is not cleaned before a run or a restore, not saved, not accepted from a remote artifact, and not hidden from vx watch; it stays an input, so an edit to it moves the key. A list of only ! entries selects nothing and is refused, and !!x is refused as it is for inputs. The directory short-circuit on a warm hit reads the positive globs, so a task with a ! entry keeps it (A-46). workspaceFiles takes ! the same way.

Cleaning semantics (one of vx’s strict-output-ownership rules):

  • Before exec on a cache miss: the declared output globs are resolved and matching files / dirs are wiped. So a leftover dist/old.js from a prior build can’t survive into a fresh build that doesn’t rewrite it.
  • Before restore on a cache hit: same wipe, then files are copied from the cache entry into the project dir. The post-restore tree is the cached snapshot, byte-for-byte.
  • A file git tracks is never wiped (2026-10-14). It is still saved and restored, and stays in the task’s key, so an edit to it moves the key.

Skipped when cache.outputs.files is empty (nothing declared as output) and when no cache-write axis is enabled (e.g. --no-cache, or a read-only --cache-policy=local:r) — the user is debugging and managing the tree themselves. --force keeps writes on, so it does clean.

outputs.workspaceFiles (optional, default [])

Section titled “outputs.workspaceFiles (optional, default [])”

Workspace-root-relative globs for outputs the task writes OUTSIDE its project dir (e.g. a root-level generated file). Same capture / restore / wipe semantics as files, anchored at the workspace root; packed into the artifact under a separate workspace-outputs/<rel-to-root> namespace so project and workspace outputs never collide. A project outputs.files glob under a top-level workspace-outputs/ is refused at load: that name is the namespace. A file another glob matches there (**/*.js) is refused at save, and the task is not cached.

outputs: {
files: ['out.txt'],
workspaceFiles: ['generated/**'],
}

No boundary rule — deliberate escape hatch. These globs may capture (and on restore, overwrite; on clean, wipe) files anywhere in the workspace, including inside other projects’ dirs. Prefer project-relative files whenever the task can write inside its own dir. Two tasks whose workspace outputs provably overlap (equal literals, or a literal a glob matches) are refused at graph build, like overlapping files: vx cleans declared outputs before a run and before a restore, so the second would delete the first’s. The comparison is by PATH, not by spelling — ./dist/** and dist/** are one declaration, and so are dist//** and dist/./app.js (item 441) — and a literal entry is read as the file OR its whole tree, the same rule the resolver uses, so dist collides with dist/app.js (item 442). A whole subtree collides with any glob whose literal head lies inside it: dist/** with dist/extra/** or dist/*.js (item 941). Two globs a path built from either one matches are one too: **/* and **/dist/** both match x/dist/x. A pair is no overlap where one task’s ! output entry takes the other’s glob back whole: ['**/dist', '!types/**'] beside types/** (a ! entry is never cleaned or captured). Globs that only might overlap (dist/* and dist/sub/**) are let through; there, last restore wins. A workspace output is also compared with every other project’s files outputs, read from the root: packages/b/dist/a.txt collides with b’s dist/** (item 1088). An overlap between two tasks one of which depends on the other is refused too, unless the workspace sets run.rules.exclusiveOutputs: false (X-53); then the dependant is additive and owns only what its run adds to the tree (caching.md § Additive outputs, item 588). Across the two namespaces an upstream files task is told the dependant’s globs in its own project’s terms, so a workspace glob that does not start inside that project (**/a.txt) is refused even with the edge.

Turborepo, Nx and other product names are trademarks of their owners. vx is not affiliated with or endorsed by them.