Skip to Content

Internals: Unplugin Invalidation

This page is for maintainers of @ttsc/unplugin. It maps the adapter’s invalidation model onto the source under packages/unplugin/src/core, so a change to one unit can be placed before it is made. What the model guarantees to a bundler user is in the setup guide; this page says where each guarantee lives and which unit owns each decision.

The one rule

Only the compiler’s verdict on the project as it is on disk may be served. Everything below exists to answer one question per module delivery, transform/transformTtsc.ts: is the generation the cache holds still that verdict, and if not, which inputs must the host hear about so it asks again.

Four principles decide every case, and each names its owner:

PrincipleOwner
A. A path is compared by filesystem identity, never by spelling; a host is handed one spelling per project.transform/filesystem/* (pathIdentityKey, relativeToProject), transform/envelope/hostSpelling.ts, watch/handWatchInputs.ts
B. A watcher’s silence proves nothing until one delivery has proven the recorded state by reading it.transform/tracker/*, validation/notificationsProveProgramUnchanged.ts, transform/tracker/broker/*
C. A signal to a host is owed until a registration whose delivery read the post-signal state answers it.bridge/openHostWatchBridge.ts, observer/createInputObserver.ts (replace)
D. A generation is served only while every input it recorded is provably unchanged, or replaced.validation/matchesCachedSource.ts and the three paths below
E. A build host is handed the project’s state as one file, never the compiler’s inputs.bridge/projectRecordFile.ts, watch/notifyProjectRecord.ts, bridge/refreshProjectRecordFile.ts

Objects

  • Generation, cache/TtscCachedProjectTransform.ts: one whole-project compile and everything recorded around it: the envelope (result), the project walk (inputHashes, inputSignatures, projectDirectories, membershipPolicy), the out-of-walk inputs (externalInput*), the universal host inputs (hostInputValidation), among them each plugin’s Go source directory by its state, the sources with the Go build environment (envelope/selectPluginSourceInputs.ts), the trackers, and the pass it was settled for (deliveryEpoch).
  • Cache, cache/TtscTransformCache.ts: one entry per project key, holding the in-flight or settled generation Promise. cache/sharedBuildTransformCache.ts gives every plugin instance of one process with the same options the same cache; cache/createTtscTransformCache.ts gives a long-lived worker its own.
  • Pass, cache/beginTtscTransformBuild.ts, cache/transformCacheEpoch.ts: a host build boundary. It never discards a generation; it lets the pass’s first delivery prove the generation once and every later module’s first delivery be settled by its source alone. A host without a boundary (the Turbopack loader, Metro, a watching Vite server) delivers with epoch === undefined and validates persistently.
  • Session, session/*: the pooled hosts’ shared compile store (TTSC_UNPLUGIN_TRANSFORM_SESSION). claimSharedCompile.ts keys a compile by identity and project state and publishes a success or a diagnostics failure, never an exception.
  • Record, bridge/TtscProjectRecord.ts, bridge/projectRecordFile.ts: one file per project below .ttsc/records in the host’s root (bridge/hostToolDirectory.ts: the directory the host runs in, Farm’s configured root, esbuild’s absWorkingDir, the root Turbopack resolved for its loader), the project’s state as a build host sees it: the evidence of every input the generation consulted, the walk’s membership digest and policy, and a signal counter. A build host watches the module and this file, and nothing else (principle E).
  • Observer, observer/createInputObserver.ts: the adapter’s own bounded observer of compiler inputs, keyed by owner: one pinned scope on the project root and at most 16 external ones, each event re-checked against the input’s recorded condition. A plugin’s Go source directory is one entry whose scope admits every directory below it but those the plugin build passes over (pluginSourceCovers from ttsc/plugin-source, the rule ttsc --watch applies too), and whose check proves its state (inputs/pluginSourceHolds.ts). It tells its owner which owners’ inputs changed, reload for a changed input and invalidate for a project that only gained or lost a root file, and nothing else. It has two owners, the Vite dev server (vite/createViteServeInputWatch.ts), whose owners are importers, and the bridge, whose owners are records. On Windows and macOS its scopes live in the broker child, registered as the trackers’ watches are (tracker/broker/openBrokeredWatch.ts) with a sink of their own (tracker/broker/WatchBrokerSink.ts).
  • Bridge, bridge/openHostWatchBridge.ts: a watching build session’s owner of the observer, with one owner per record, observing every input of every generation. It signals the host by moving the record (bridge/signalProjectRecordFile.ts), at once and again with a growing delay, and owes(record) until a delivery that read the post-signal state registers the record again. A host that reports what each compile depended on, webpack and Rspack through webpack/reportCompiledProjectRecords.ts, has the later moves only for the records its watcher observes (compiled).
  • Trackers, transform/tracker/*: the notification observers a generation retains: the project walk’s directories (createProjectMutationTracker.ts), the out-of-walk inputs and, as a second instance, the absent resolution candidates (createHostInputMutationTracker.ts). A plugin’s Go source directory is tracked as a tree, watched as a whole subtree wherever it lies, below its pruned directories excepted (pluginSourceCovers from ttsc/plugin-source, the rule ttsc --watch applies too). Windows and macOS watches live in the broker child (tracker/broker/watchBrokerSource.ts), whose messages reach a tracker as its sink decides (tracker/broker/brokeredTrackerSink.ts). Linux watches live in ttsc’s platform helper, one per directory shared by every observer of the process, and a subscriber that joins a live one hears only what follows the helper’s answer to a sync it sent on joining (tracker/linux/subscribeLinuxDirectoryWatch.ts), as a watch of its own would.

A delivery

transform/transformTtsc.ts, one loop iteration per attempt:

  1. Read the pass (transformCacheEpoch) and the cached generation.
  2. A terminal generation (cache/TERMINAL_TRANSFORM_GENERATIONS.ts) is replayed or evicted by cache/replaysTerminalGeneration.ts: a pass verdict (errors/TtscPassVerdictError.ts) only inside the pass that produced it; an unstable generation (errors/TtscUnstableGenerationError.ts) until generation/failedGenerationEnvironmentChanged.ts sees its recorded environment move.
  3. A cached generation is awaited (cache/awaitOrEvict.ts), settled against pending notifications when there is no pass (settleProjectMutationEvents), and validated by validation/matchesCachedSource.ts. Valid: its output is selected (cache/selectOrEvict.ts over envelope/selectTransformedSource.ts) and its inputs registered (watch/notifyWatchInputs.ts). Invalid: evicted (cache/evictGeneration.ts), and the loop retries with whatever another caller installed meanwhile.
  4. No generation: generation/transformProject.ts captures one, with bounded retries for a project that moved under the compile.
  5. A failed envelope: selectOrEvict retains it as the pass verdict or evicts it, and watch/notifyFailedGenerationInputs.ts registers the recovery inputs before the error reaches the host.

Capture

generation/captureTransformGeneration.ts, in order: the configuration state (tsconfig/readTransformTsconfigState.ts), the walk before the compile (project/collectProjectInputSnapshot.ts, project/walkProjectInputs.ts), the project tracker opened before the compile, the session claim (adopt a publication of this exact state, or take the lock), the compile on a worker thread (ttsc’s transformAsync), the walk after, the external-input snapshot (validation/captureExternalInputSnapshot.ts), the universal host-input capture (validation/captureUniversalHostInputValidation.ts), the graph proofs (validation/compilerGraphInputProofFailures.ts), and the stability verdict.

Stability, generation/projectWalkStable.ts: the project held still only when the configuration did, both walks are complete for the declared inputs, those inputs kept content and metadata, every directory that can hold a program input kept its membership, and the tracker opened before the compile saw nothing. Only that tracker decides; the host-input and candidate trackers open after the compile. An unstable success is retried; the second failure that says the project moved becomes the terminal unstable verdict: a compile here that fails its proof, or an adoption whose own window moved. An adoption whose publication failed its proof here never counts toward it (see Session). A failed compile needs no proven snapshot, only a project that held still (projectHeldStill).

A path a plugin reports in dependencies, outside the walk and proven by no graph, carries no compiler-time state, so the reading the external-input snapshot takes after the compile certifies it only against a witness read before the compile (validation/witnessExternalDependencies.ts, samchon/ttsc#1541): the same bytes, physical target, and metadata, the way the walks before and after prove the project. The witness reads the paths the last generation of the same cache key reported (externalDependencyInputs), kept past the generation itself, since a host ending its build resets the cache (cache/TRANSFORM_CACHE_DEPENDENCY_WITNESSES.ts). A compile learns such a path only from its own envelope, so a path it reports for the first time has no witness: the attempt is retried with the path witnessed, without spending the bound, since learning a dependency says nothing about the project moving (generation/onlyLearnedCompileFacts.ts), while a witnessed path that moved during the compile is a moved attempt like any other. Either leaves even a failed compile unheld. A spelling that selects a walked project file, as through a linked root, needs none either: the walks before and after compare that file among the declared inputs. An adopted compile needs no witness: its publisher published only a certified state, and the adopter matches that state against its own disk. Host inputs keep their own evaluation-time fingerprints, and resolver inputs and configs are the compiler’s observations, not a plugin’s reads.

Root specs match under the compiler’s case policy (tsconfig/policyUsesCaseSensitiveFileNames.ts, samchon/ttsc#1545). TypeScript-Go takes it from the filesystem its executable lives on, not from the platform, and reports it in the graph (useCaseSensitiveFileNames). Everything after the compile, the walk, the recorded policy, and the watchers and records built from it, takes the reported policy. The walk before the compile can only be primed: with the policy the cache key’s last generation reported (cache/TRANSFORM_CACHE_CASE_POLICIES.ts), or, before any compile has reported one, with the answer the compiler will give by TypeScript-Go’s own rule, which ttsc computes from the plugin cache root the compiler executable lives in (compilerUsesCaseSensitiveFileNames, samchon/ttsc#1563). The Metro adapter’s key and the referenced-project selection, which run before any compile, take the same answer. A walk primed with another policy described another membership, so the attempt is taken again under the reported one, without spending the bound, as an unwitnessed dependency is.

The declared inputs, envelope/selectDeclaredProjectInputKeys.ts: the graph’s edges, globals, configs, resolution inputs and candidates, the host inputs, and the plugin-reported dependencies, intersected with the walk. Validation compares hashes over that set alone; membership is compared over every directory that can hold a program input (validation/sameProjectDirectories.ts, signature of the walk’s admitted entries). An envelope without a graph declares no set and keeps whole-walk comparison.

Validation paths

validation/matchesCachedSource.ts orders them:

  1. Source: the delivered text against the generation’s recorded hash of that file; a text that differs while the disk still holds the compiled bytes is reported once (diagnostics/reportDivergentDelivery.ts) and served.
  2. Pass proof: the pass’s first delivery proves the whole generation once by the complete snapshot; later first deliveries of other modules in the pass are settled by source alone.
  3. Narrow: validation/matchesNarrowPersistentInputs.ts, a graph-bearing generation validated over the inputs that can affect the delivered file, with membership proven once per event-loop turn. Its proof is the retained trackers’ silence, validation/notificationsProveProgramUnchanged.ts, trusted only while contentAuthoritative holds, which every tracker withdraws until one delivery has proven the recorded state by reading it (validation/restrictNotificationCoverageToProvenInputs.ts). Unavailable proof falls through; evidence of a change returns false.
  4. Complete: validation/matchesCompleteInputSnapshot.ts, the universal host inputs by identity, the walk by declared hashes and directory membership, and the out-of-walk inputs by their recorded state.

A plugin’s Go source is universal to every module and read by no walk (samchon/ttsc#1487). The envelope reports each directory a plugin binary was keyed on with the build’s own state (pluginSources): the digest of its files together with the environment a build there is keyed on, the Go compiler, go env, and cgo’s toolchain, since another GOFLAGS or Go toolchain builds another binary as surely as an edit does (samchon/ttsc#1493). Every proof checks that state with ttsc’s rule (inputs/pluginSourceHolds.ts, pluginSourceStateHolds from ttsc/plugin-source): the capture’s and an adopter’s (validation/captureUniversalHostInputValidation.ts), each delivery’s unless the tracker heard nothing below the directory and the environment is still the one it was last proven under (validation/matchesUniversalHostInputTrees.ts), a failed generation’s replay (generation/failedGenerationEnvironmentChanged.ts), a record’s at a build start, the observer’s, and Metro’s key. No one path’s metadata stands for the files below a directory, so a state is never skipped on one path’s signature. The metadata of every file the digest reads does: a proof reads the files’ bytes again only when that metadata moved since the digest last read under it, or is no longer separable from the clock reference minted now, as after a rollback, and keeps a digest only when the metadata taken before and after its read agree and every stamp provably left its clock tick before it (inputs/pluginSourceFilesDigest.ts; pluginSourceFilesSignature and pluginSourceDigest from ttsc/plugin-source, the signature over exactly the files the digest reads), the rule the universal entries’ signatures follow. That is what a proof costs where no tracker can vouch for the directory, a macOS stream outside the project root among them, whose delivery no probe proves (samchon/ttsc#1453): one metadata walk per delivery instead of reading the plugin’s source, measured at 169 ms for typia’s 622-file module root. Each universal input is proven on its own there (validation/matchesUniversalHostInputs.ts), so a directory its watch cannot prove sends nothing else back to the disk. The environment part costs a go env run and a GOROOT walk, so ttsc keeps its reading per process, directory, and set of variables (processPluginBuildEnvironment), together with the metadata of every path it depended on that no variable carries, each taken before the read that depends on it: the Go tool, the file go env -w writes, the executables the C toolchain commands name, and GOROOT. A kept reading is reused only while each of those holds, so a toolchain replaced in place or a go env -w is read at the next proof rather than accepted under the old reading (samchon/ttsc#1516). A proof that still finds a state it cannot match reads the environment again before it refutes the state, and a compile keys its binaries on a fresh read, so the adapter never keeps refuting the output its own compiler just produced. Captures, an observer’s baseline, a failed generation’s input state, and Metro’s key take the process’s reading (inputs/pluginSourceState.ts). A tracker that heard nothing below a directory proves it with no read at all: capture proved the state before it recorded the directory among the manifest’s trees, so its coverage is kept like a readable entry’s (validation/restrictNotificationCoverageToProvenInputs.ts).

Signatures, clock/* and inputs/inputMetadataEvidence.ts: an input whose nanosecond metadata signature still equals the one captured around the read that proved it is not read again, once the filesystem’s own clock has provably left the stamp’s tick (clock/stampSeparable.ts). The tick is proven against a probe the proof itself rewrites before it reads, so a write a clock rollback put into a recorded stamp’s tick is read (samchon/ttsc#1344): a generation’s capture and deliveries rewrite the probe it retains outside the project (clock/refreshFilesystemClockReference.ts), and a proof that holds no generation, a failed generation’s replay, a record’s at a build start, and the observer’s proof of a plugin source, mints in one probe directory the process keeps below this user’s state root, named by its process id so a crashed process’s is removed by the next session (clock/refreshProcessClockReference.ts). Such a proof runs at any moment, beside compiles that read the metadata of the directories around them, so after that directory exists a mint only rewrites its probe: creating and removing a directory per proof would change the shared temporary directory, whose metadata proves a package.json absent there while a plugin descriptor is evaluated. Proofs share one reference per filesystem and may interleave, which is sound because separability is decided when an input’s metadata is taken before its read.

Registration

Every delivery hands the host what it depends on, and a host takes one of two shapes (watch/TtscTransformHooks.ts).

A host keyed on the module’s own inputs, the Vite dev server’s module graph and @ttsc/metro’s fingerprint, takes the inputs derived for the file (addWatchFile, addWatchFiles): envelope/selectWatchInputs.ts (deriveWatchInputs.ts), the plugin-reported dependencies, the reference closure, globals, configs, resolution inputs and candidates, each with its recorded evidence (watch/evidencedWatchInput.ts), plus the membership input (watch/projectMembershipInput.ts) when asked and the config selection (watch/selectionInputs.ts). Each plugin source directory is handed too, with its state as tree evidence. watch/handWatchInputs.ts hands each spelling once, keeping the registration that says most: membership over an evidenced input over a plain one. A failed delivery hands the recovery inputs, watch/notifyFailedGenerationInputs.ts: the walk’s files, the retained external inputs, files named by diagnostics, and the selection, with no evidence. The observer takes them for the Vite dev server (vite/createViteServeInputWatch.ts): replace(importer, inputs, failed, startedAt) re-checks each input against changes since startedAt and signals again at once when the delivery read a state already gone; a failed registration keeps the importer’s earlier inputs observed.

A build host takes the project’s record (project), watch/notifyProjectRecord.ts: once per generation and process, the generation’s inputs (watch/generationWatchInputs.ts: every realized or resolver-input path of the graph, every plugin-reported dependency, the host inputs, each plugin source directory, and the selection) are read with their evidence, an input the generation recorded no state for read now, and written to the record below the root of each host the generation is delivered to, which is handed over with those inputs; two hosts with different roots in one process that share a generation each take their own. bridge/registerProjectRecord.ts puts the record on the host’s own file channel, the one that watches the module itself, and registers the inputs with the session’s bridge when the host is watching. A failed delivery writes the recovery inputs, read now, so the repair moves the record wherever it lands; a rejected one registers the record the last generation wrote with the selection alone. A record lives below the host’s tool directory. When that cannot be written, it lives below the fallback the host accepts (bridge/fallbackToolDirectory.ts, samchon/ttsc#1480): one directory per host root below the user’s own temporary directory (filesystem/userStateDirectory.ts), in its long spelling. Every adapter but the Turbopack loader accepts it, and Farm only on its root’s drive (farm/farmRecordFallback.ts). The first place a write lands is the one handed over, since only a record the adapter can write can move. When no write lands, a record that is there is handed over all the same, since a module handed over without it is restored from a persistent cache on its own bytes, and the next delivery of the generation writes it again from the inputs already read. One that is not there is not handed over, since what a host does with a dependency on a missing path differs per host. The user is then warned once (bridge/warnUnwritableProjectRecord.ts), the module is marked uncacheable (markVolatile), and a watching session’s successful delivery fails instead (errors/TtscProjectRecordUnwritableError.ts), since its watcher’s silence would serve it after a type-only edit. Farm offers no per-module cache opt-out, so where neither place can be written its config hook turns the persistent cache off (farm/farmPersistentCacheWithoutRecords.ts, measured on Farm 1.7.11). The build start’s proof (refreshProjectRecordFiles) and a watching session’s bridge take the records of both places. A rejected delivery likewise hands over only a record that is there, in either place, and marks its module uncacheable otherwise.

The compiler’s inputs never reach a host’s own channels. Each host observes them imprecisely, or not at all, and each differently (Farm reports no creation and no new entry; Rollup opens one watcher per path; webpack, Rspack, and Turbopack watch a directory recursively; Turbopack refuses a dependency outside its root and fails a module on a directory it reads as a file, which crosses its worker pool’s results; Rspack, Rolldown, and Turbopack lose a change that lands in a window of their own; esbuild keeps one watch state per path), and every such flaw was once compensated host by host. The record is one file, spelled as the project is, that every host watches and snapshots like any other.

Session

session/claimSharedCompile.ts: identity (project, options, plugins) plus state (session/sharedCompileState.ts: the walk’s directories and hashes and the tsconfig signature) name a publication. The first worker takes the lock; the others adopt what it publishes, then prove the adopted envelope against their own disk (adoptedExternalInputMismatch). An adopted envelope that fails its proof fails for one of two reasons, recorded per result (session/TtscAdoptionVerdict.ts, session/TRANSFORM_ADOPTED_RESULTS.ts). When the publication itself does not hold on this disk (an external input moved since its publisher read it, or a graph proof or universal input fails here), the retry refuses that state’s publication, which it would find unchanged, and compiles it under the lock instead; a retry whose project moved to another state adopts that state’s publication like any other claim (generation/transformProject.ts). Refusing every publication after one failed adoption compiled, on a Turbopack pool, a state another worker had just published, and that compile was still running when the next edit landed. When the publication held and only this worker’s own window moved around it (a declared input’s metadata, a tracker event, the configuration), the retry claims whatever state it then reads and adopts the same publication again when no content changed: refusing it compiled, on a Turbopack pool, a state the pool had already compiled and proven, for an event that changed nothing (samchon/ttsc#1479). The bound on attempts counts the failures that say the project moved: every compile here that fails its proof, and every adoption whose own window moved. A refuted adoption is followed by a compile of that same state or by a claim of the state the project moved to, so it says nothing about the project moving and never spends the bound, which a pool would otherwise exhaust on publications while its project moved once. The attempts are capped at twice the bound all the same.

The store is one directory per user below the system temporary directory, owned by the user and writable by no one else, and it outlives every process that uses it, so a restarted dev server’s workers adopt what the last one compiled (samchon/ttsc#1483). A publication is named by what the compile is down to the compiler that ran it (session/sharedCompileIdentity.ts: the tsconfig, the overlays, the plugin list, the ttsc and TypeScript-Go versions by ttsc’s own rule, this adapter’s version, and the platform); the plugins’ Go sources and build environment are proven by their state at adoption instead. The store bounds itself in session/claimSharedCompile.ts: an adoption marks its publication used, each publish keeps the four most recently used publications of its identity and the 32 of the whole store, and removes locks and partial writes whose writer is gone.

The publication rule, in generation/captureTransformGeneration.ts: a success is published only when its whole snapshot is reusable as captured, the graph’s own proofs included, since an envelope whose graph proof fails here fails it in every adopter too and a pool would then compile nothing else until its attempts ran out; a compile that ended in diagnostics only when the project held still; an exception never.

The graph’s directory listings follow the walk (envelope/envelopeGraphIndexes.ts). The compiler’s observer keeps every predicate anyone asked of a path, so the project root, which the config’s file list is expanded from, arrives with its whole listing once it is also a module resolution candidate, and a framework writing .next or AGENTS.md beside the project, or ttsc’s own tool directory appearing there, failed every proof of it: on a Turbopack pool, each worker then compiled the project again. The listing of a directory the walk enumerates is the program’s membership there, which the walk proves under the same policy, so the index drops it and keeps the directory’s other predicates (project/walkEnumeratesDirectory.ts, with the capture’s policy registered per result in cache/TRANSFORM_RESULT_MEMBERSHIP.ts). A directory the walk never enters, a universal resolution input such as a type root, whose subdirectories automatic type discovery reads, and a path whose listing is its only predicate keep theirs.

When the record moves, and what each host does with it

The record moves at four places and nowhere else: a delivery writes its generation’s state (watch/notifyProjectRecord.ts), a watching session’s bridge signals at once and, while the host’s watcher observes the record, again with a growing delay (bridge/openHostWatchBridge.ts), and a proof before a host restores a module signals a record whose state moved or that it cannot read (bridge/refreshProjectRecordFile.ts). It is read at four: that proof, the signal’s own read before it increments, the writer’s byte comparison, and Rollup’s cache answer, which compares the record’s bytes with those a cached module’s delivery wrote (rollup/createRollupCachedModuleProof.ts).

A host sees a move through one of five mechanisms, each with its own moment for taking a file’s state as known. The table is the whole surface; a change to either column is a change to this model rather than to one host.

HostMechanismTakes its baselineAcross processes
Rollupone chokidar watcher per transform dependencythe watch files of the last buildthe cache a build is handed, which asks the adapter before it serves each module
Rolldownone chokidar watcher per transform dependencythe watch files of the last buildnothing kept
webpack, Rspackwatchpack on the dependency’s directorythe build’s start time, per module snapshota pack whose snapshots carry that start time
Turbopackits own watcher below its project rootafter the loader returnsits own cache, proven at the loader’s first run
Farmits watcher over the paths the compiler resolvedthe compiler’s updatea store written inside the compile
esbuildone watch state per paththe last loader result that named itnothing kept, every module loads again

Five moments a move can land in, and what answers each:

  • Between builds. Every mechanism above reports it, and the host builds. Nothing else is needed.
  • At a build start. The proof signals before the host validates a module against its cache, so a project whose state moved while nothing ran is heard by a host that would otherwise run no module of it at all. Rollup’s proof runs as the build asks about the project’s first cached module instead, which comes before it serves any of them.
  • During the build, before or after a module’s own transform. The host may apply the move to the build in progress and then keep that build’s cache. Rollup is asked before it serves a cached module (shouldTransformCachedModule: owes, and the record’s bytes against the module’s delivery), and every other host is answered by the bridge’s repeated signal, which lands after whatever moment the host takes its baseline at.
  • After the build, while the session runs. Reported as between builds.
  • After the last build of a session. A host with a persistent cache stores module snapshots that predate the move, and its next session rebuilds those modules once. The host hears the move itself and runs one more pass whose snapshots begin after it, so a session that keeps running settles; one stopped inside that window pays the one rebuild. The adapter cannot avoid it: a generation exists only during the build that produced it, so the record is written there.

Next runs three webpack compilers over one project, and the wrapper gives each its own plugin instance, so each opens a bridge of its own and each takes every record of the tool directory at its first pass. The moves after a signal’s first defend the host’s watcher, and a compiler’s watcher observes only what its last compile depended on, so after every compile the webpack and Rspack adapters report the compilation’s file dependencies to the bridge (HostWatchBridge.compiled), which moves on the growing schedule only the records that watcher observes. Next’s edge compiler holds no module of a page project and never delivers for it, so its bridge would otherwise move that record for the rest of the session and run the client and server compilers again with each move. The bridge still observes a record no compile depends on, and moves it once per change, because next dev compiles a page on its first request: no compiler depends on the record at its first compile, and the page’s later compile reads a move made meanwhile through its cache’s snapshot. A record the watcher comes to observe with a signal still owed is moved again at once, on the schedule.

Two properties keep the table finite. The record is written into the file the host watches, never beside it and renamed over it, because replacing a path’s file detaches a watcher holding the one behind it. And a record that can never be written again, whose tsconfig is gone, is removed rather than moved, because the rule that ends every other move, a delivery writing the state a proof found, has no delivery left to end it.

Persistent caches

A host with a persistent cache restores a module by the dependencies it recorded, without running the adapter. The module’s dependencies are itself and the record, so the record must move exactly when the project’s state does, and only a delivery, a proof at a build start, or the bridge those records were handed to can move it.

  • bridge/writeProjectRecordFile.ts writes a record only when its bytes would change, with keys in one order, so two generations of one state produce one byte sequence and no delivery moves a record for nothing. The bytes go into the file the host watches, never beside it and over it: a host watches the record by its path, and replacing the path’s file detaches a watcher holding the one behind it. Rollup’s does, on Linux, where its own source says a file “unlinked and immediately recreated would create a change event but then no longer any further events”; measured, six moves of one record over seventeen seconds reached it as none. A reader can therefore catch the file mid-write, and so can a second writer; two processes holding one generation write the same bytes, and two holding different ones can leave a mix. A record that cannot be read is therefore moved at a build start, as a proof that found a change is, since a host may hold exactly those bytes: a signal that could not read the record writes a bare one, the host runs the project’s modules on that move and records those bytes, and a delivery of a generation its process already recorded hands the file over without writing it again. Only the move runs those modules again, whose deliveries write the record whole. A writer caught mid-write loses its bytes to the move, which its own host hears and runs on, and a record another process removed stays removed, since a signal only ever rewrites a file that is there.
  • bridge/refreshProjectRecordFile.ts proves one record before a host restores a module of its project. It proves every recorded input against the disk the way a delivery proves a generation (watch/watchInputEvidenceMatchesDisk.ts: a predicate observation by every predicate it holds, a graph input by its state hash and physical target, a host input by its bytes’ hash), walks the project again under the recorded policy (bridge/membershipRecordDigest.ts), and moves a record whose state changed while nothing ran. A moved record keeps moving at each proof until a delivery writes the state the proof found, which is why the one project that can never deliver again, whose tsconfig is gone, has its record removed instead: removal is the move every host hears, and it ends there.
  • bridge/refreshProjectRecordFiles.ts proves every record of a tool directory before a host whose own cache the adapter cannot answer module by module validates anything against it: webpack’s, Rspack’s, and Farm’s buildStart in unplugin.ts (restoresUnanswered), withTtsc before Next starts either compiler, and the Turbopack loader’s first run in a process, which proves the records below the root Turbopack resolved whatever the wrapper proved below the directory Next was started in. Every record, not only the instance’s own project, since these caches key a module by neither the tsconfig nor the options it was compiled under: measured with a counting plugin over one fixture, webpack 5.107.1’s filesystem cache and Rspack 2.2.6’s persistent cache each restored, in a process started with project B, the module the last process had transformed under project A, without running the plugin (samchon/ttsc#1481). unplugin names each loader it installs after the plugin, not after its options. Rspack 1.7.11 restored nothing in the same probe, even under unchanged options, so the probe says nothing of it. Which projects such a cache holds is not the adapter’s to know, and the cost is one proof per record of the directory, however many tsconfigs were ever built from that root.
  • Rollup, and a Vite build on Rollup, serve a module from the cache a build is handed only after asking shouldTransformCachedModule, and that is where the adapter proves the module’s record (rollup/createRollupCachedModuleProof.ts, samchon/ttsc#1491). Rollup clears the cache from its input options before buildStart, so a plugin learns which modules the cache holds only as Rollup asks about each. Rollup keys a cached module by its source alone, so a delivery leaves in the module’s meta (rollup/TtscRollupDelivery.ts) the options it was compiled under (rollup/rollupDeliveryOptions.ts: what a generation’s key holds but the tsconfig, which the record stands for) and its record with the digest of the bytes its process wrote to it (TtscProjectRegistration.digest). Rollup keeps meta with the module, a module passed through untransformed included. A build without a bridge proves each record once, as the first cached module names it, and a module runs again unless it was compiled under the build’s options and its delivery’s digest is the record’s bytes after that proof. A delivery that consulted no project, with the plugins disabled, is held to its options alone. A volatile module, or one handed no record this process wrote, carries no delivery and always runs. Rollup’s own watcher runs a cached module again when its record moves, but a build handed a cache has no watcher, and Rollup reads no watch file of a cached module. Rolldown and a Vite build on it keep no cache, and a dev server keeps no module across a restart, so none of them proves a record. esbuild, which keeps no loader result across builds, proves nothing either.
  • A watching host’s bridge opens for the session’s first pass (unplugin.ts buildStart, the Turbopack loader’s first watching delivery) and, for a host whose cache the adapter cannot answer module by module, the refresh hands it every record instead (bridge/projectRecordWatchInputs.ts: each recorded input with its evidence, and the membership as the input watch/projectMembershipInput.ts hands). A watching Rollup, Rolldown, or Vite build holds only modules its own deliveries registered with the bridge, and its bridge takes no record at the start. The bridge proves them as it does a delivery’s registration, moves a stale record and again until a delivery answers, and observes the project from then on. A project the host restored whole from its cache runs no delivery in the session, so nothing else would observe an input of it edited while the host runs; the restart contract edits the tsconfig inside a session served whole from the cache for exactly this.
  • Turbopack re-runs every webpack loader that starts a child process on every start, whatever its dependencies: measured with a counting loader that Turbopack restores across restarts until it calls execFile or spawnSync. ttsc’s loader starts the compiler, so Turbopack runs it after every restart, in fresh workers with nothing in memory. The session’s store outlives the process (session/openTtscTransformSession.ts), and the first delivery of a fresh worker adopts what the last process compiled after proving it against its own disk, as any adopter does, so a restart over an unchanged project compiles nothing (samchon/ttsc#1483).
  • The tool directory holds the records alone, and nothing in it is named after a process. The broker’s macOS probes, which may not outlive their process, live below the probed project’s node_modules/.cache/ttsc in a directory named after it (tracker/broker/WATCH_PROBE_DIRECTORY_PREFIX.ts), which the process removes when it exits and a later one sweeps once its owner is gone. Only the FSEvents backend names a probe, and a location whose probe directory cannot be prepared is left unproven rather than failing its watch (tracker/broker/openBrokeredWatch.ts).
Last updated on