Make the cache tiers pluggable behind a provider contract - #131
Merged
Conversation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
The cache writes Stim owns were hard-wired to the local filesystem, and each
consumer carried its own copy of the lookup, store, timeout, and warning rules.
There was no contract a project could implement to add a second tier.
This adds
@stim-cli/cache: the public provider types, provider loading andvalidation, the timeout and warning policy, tier coordination, and
contract-test helpers for provider authors. Only the built-in local filesystem
provider ships. There is no network provider here.
What a reviewer cannot see in the diff:
The refactor is invisible on disk. Three separate pieces of evidence, each
stated for what it actually proves:
build-cache.tshas anempty deletion set against
origin/main(
git diff origin/main -- packages/stim-cli/src/build-cache.ts | grep -c '^-[^-]'->
0):resolveBuild,storeBuild, thecp -c -Rclone, the stagingrename, pruning and registration are the same lines they were. The new
capability wraps them; it does not reimplement them. This is what
establishes equivalence with
main.build-cache.test.tsis a branch-vs-branch comparison: a recursive tree(paths plus file contents) written through
storeTieredBuild+filesystemBuildCapabilityequals one written by a directstoreBuild,and the resolved path matches. A throwaway script that imports
build-cache.tsfromorigin/mainand from this branch reproduces the sameequality across checkouts (identical tree, key, resolved path, and
caches.jsonrecord); it is a one-off, not committed.already pin cache paths, keys, the exact
storeBuildoptions object, theclone arguments, registration, and
gcreporting;cache-packages.test.tsis untouched, as required.
test/e2e/cache-flow.e2e.js(real CLI, realfingerprint/key/store/resolve machinery, real single-flight race) stays
green.
With no provider configured,
sharedCacheStores()returns the same plaintagged
FileStoreobject it returned before, and the new scratch directory forprovider downloads is never created.
Where a remote tier plugs in later. Nothing in Stim needs to change: a
module exporting
apiVersion = 1andcreateCacheProvider()that implementsmetroand/orbuildsis loaded fromcache.provider, and the coordinatoralready handles bounded loading, timeouts, aborts, backfill, upload queueing,
read capping, the failure circuit breaker, and one-warning-per-failure-class.
Adding S3, GCS, or a company cache is a package, not a refactor.
Solution
@stim-cli/cache(new published workspace package, ESM, same engines andtsdown setup as its peers) owns:
MetroCacheCapability,BuildCacheCapability,CacheProviderModule,apiVersion: 1);loadCacheProvider(): resolves a package name or a relative path from thesettings layer that declared it, imports ESM or CJS under a deadline
(a module whose top level or factory never settles becomes
unavailable),validates the version and each advertised capability, and returns
{ unavailable }instead of throwing;callWithTimeout()andcreateWarnOnce(): every provider call is bounded bya referenced deadline plus an
AbortSignal, and each failure class warnsonce per command or supervisor run;
createTieredMetroStore(): local, provider, local backfill, return;setawaits the local write and queues the provider write under fixed item, byte,
and concurrency limits; reads are capped at six in flight and the tier turns
itself off after five consecutive failures;
clear()touches only the localtier;
resolveTieredBuild()/storeTieredBuild(): local first, provider second,provider hits stored locally before use, uploads started but never awaited in
the build path;
cacheProviderContractChecks()/runCacheProviderContract(): runner-agnostic checks a provider author can run against a provider object or a
module reference, covering capability behavior,
AbortSignalhonoring,overwritesemantics, and thedestinationDirrules.The built-in filesystem provider is the existing code, adapted:
metroCapabilityFromStore(new FileStore(...))in@stim-cli/metroandfilesystemBuildCapability()in the CLI, which still calls the sameresolveBuild/storeBuildwith the same options. Both are run against theshipped contract checks in this repo's suite.
Projects select one optional second tier with
cache.providerandcache.options, resolved through the existing settings layers.stim startserializes that decision (including an explicit
none) intoSTIM_CACHE_PROVIDER_CONFIG; the module itself is loaded later, in the Metrochild that already evaluates
metro.config.js, or instim ios/stim android. A Metro process outside Stim searches for a committed.stim.jsononly up to the repository root.
Native builds now look up local, then the project provider, then Expo
buildCacheProvider, then the build lock.engine/remote-cache.tsisuntouched: the Expo path stays a separate compatibility path, runs only when
the first two tiers miss, and uploads independently.
Every budget is tunable per run:
STIM_CACHE_METRO_READ_TIMEOUT_MS,STIM_CACHE_METRO_WRITE_TIMEOUT_MS,STIM_CACHE_BUILD_RESOLVE_TIMEOUT_MS,STIM_CACHE_BUILD_UPLOAD_TIMEOUT_MS,STIM_CACHE_LOAD_TIMEOUT_MS. No newcommands or flags.
Deviations from the plan docs
The plan doc predates recent repository changes; current
mainwins.npm install --package-lock-onlyandpackage-lock.jsondo not exist here. The workspace entry was added topnpm-lock.yamlby hand becausepnpm installotherwise rewrites unrelatedpeer-hash lines across the file.
dist/index.jsshape violates theESM-only rule and
test/runtime-floor.mjs. The package matches its peers:ESM only,
dist/index.mjs,engines^20.19.4 || >=22.12.0, version1.0.0-rc.4in lockstep, andtsdown.config.mtscopied fromcore.loaded
providerto the tier helpers. That would load a provider even on alocal hit, which the design forbids, so both helpers take
loadProviderandconsult it only after a local miss.
targetinstead ofcontext, plusensureDestination: the helpers takea
target(projectRoot,platform,key) and add theAbortSignalthemselves, and the download directory is created only when a provider is
actually about to be asked.
sharedCacheStores()returns the plain taggedFileStore, exactly asbefore; the composite appears only when a provider is selected. This keeps
cache-packages.test.tsand the store-root tagging contract untouched andmakes the second tier a pure addition.
because it is called once per transform. The build helpers are stateless per
call, so
stim iosandstim androidshare onecreateWarnOnceacross thelookup and the store; the commands, not the helpers, are what guarantee one
note per class per run.
guide lifecyclenow documents three cache levels andguide settingsdocuments the two keys, with contract tests.SKILL.mdisunchanged: the normal workflow, ownership rules, and topic routing did not
change (invariant 1).
cache-packages.mdis unchanged as requested; the settingsreference gained the two keys and the note that a committed
cache.provideris executable code.
Known limits, deliberately left out
sharedCacheStores(), which coversthe supervisor-run project config and standalone Metro. The store the
supervisor injects itself (the Expo config shim and the bare in-process
append) is still local-only.
guide settingsand the website say so.prebuild/pod installre-lookup under the mutated key stayslocal-only; a provider entry under the new key is a miss, never a wrong
artifact.
unit tests at every layer plus the command tests;
cache-flow.e2e.jsstillcovers the local machinery.
TieredMetroStore.flush()is a test and embedder hook, not a Metrolifecycle hook: in-flight provider writes hold referenced deadlines, so a
Metro process drains within the write budget on its own.
Test plan
pnpm build && pnpm typecheck && pnpm lint && pnpm format:check && pnpm knip: all exit 0.pnpm test: 70 files, 2,508 tests pass. New coverage includes a hangingmodule and a hanging factory, a subprocess test that fails against an
unreferenced deadline timer (it reproduced the early-exit bug and passes
now), the Metro read cap and failure breaker, the repository-root bound on
the committed search, the
nonesentinel, the invalid-setting note, theextended contract checks (abort, overwrite, destination) run against both
built-in filesystem providers, the summary naming the provider, and the
scratch directory staying absent on a local hit.
pnpm test:runtime: passes with@stim-cli/cacheadded to the publishedentry points and the ESM-only dist assertions.
node --test test/e2e/cache-flow.e2e.js: 8 tests pass.Fixes #130