Skip to content

Make the cache tiers pluggable behind a provider contract - #131

Merged
janicduplessis merged 18 commits into
mainfrom
feat/cache-provider-package
Aug 31, 2026
Merged

Make the cache tiers pluggable behind a provider contract#131
janicduplessis merged 18 commits into
mainfrom
feat/cache-provider-package

Conversation

@janicduplessis

@janicduplessis janicduplessis commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

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 and
validation, 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:

  1. Nothing was removed from the local cache path. build-cache.ts has an
    empty deletion set against origin/main
    (git diff origin/main -- packages/stim-cli/src/build-cache.ts | grep -c '^-[^-]'
    -> 0): resolveBuild, storeBuild, the cp -c -R clone, the staging
    rename, 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.
  2. The coordinator adds nothing on top of that path. The in-repo test in
    build-cache.test.ts is a branch-vs-branch comparison: a recursive tree
    (paths plus file contents) written through storeTieredBuild +
    filesystemBuildCapability equals one written by a direct storeBuild,
    and the resolved path matches. A throwaway script that imports
    build-cache.ts from origin/main and from this branch reproduces the same
    equality across checkouts (identical tree, key, resolved path, and
    caches.json record); it is a one-off, not committed.
  3. Every existing test passes unmodified. The iOS and Android command tests
    already pin cache paths, keys, the exact storeBuild options object, the
    clone arguments, registration, and gc reporting; cache-packages.test.ts
    is untouched, as required. test/e2e/cache-flow.e2e.js (real CLI, real
    fingerprint/key/store/resolve machinery, real single-flight race) stays
    green.

With no provider configured, sharedCacheStores() returns the same plain
tagged FileStore object it returned before, and the new scratch directory for
provider downloads is never created.

Where a remote tier plugs in later. Nothing in Stim needs to change: a
module exporting apiVersion = 1 and createCacheProvider() that implements
metro and/or builds is loaded from cache.provider, and the coordinator
already 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 and
tsdown setup as its peers) owns:

  • the capability contract (MetroCacheCapability, BuildCacheCapability,
    CacheProviderModule, apiVersion: 1);
  • loadCacheProvider(): resolves a package name or a relative path from the
    settings 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() and createWarnOnce(): every provider call is bounded by
    a referenced deadline plus an AbortSignal, and each failure class warns
    once per command or supervisor run;
  • createTieredMetroStore(): local, provider, local backfill, return; set
    awaits 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 local
    tier;
  • 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, AbortSignal honoring,
    overwrite semantics, and the destinationDir rules.

The built-in filesystem provider is the existing code, adapted:
metroCapabilityFromStore(new FileStore(...)) in @stim-cli/metro and
filesystemBuildCapability() in the CLI, which still calls the same
resolveBuild/storeBuild with the same options. Both are run against the
shipped contract checks in this repo's suite.

Projects select one optional second tier with cache.provider and
cache.options, resolved through the existing settings layers. stim start
serializes that decision (including an explicit none) into
STIM_CACHE_PROVIDER_CONFIG; the module itself is loaded later, in the Metro
child that already evaluates metro.config.js, or in stim ios / stim android. A Metro process outside Stim searches for a committed .stim.json
only 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.ts is
untouched: 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 new
commands or flags.

Deviations from the plan docs

The plan doc predates recent repository changes; current main wins.

  • npm to pnpm. The plan's npm install --package-lock-only and
    package-lock.json do not exist here. The workspace entry was added to
    pnpm-lock.yaml by hand because pnpm install otherwise rewrites unrelated
    peer-hash lines across the file.
  • Package shape. The plan's CJS + ESM dist/index.js shape violates the
    ESM-only rule and test/runtime-floor.mjs. The package matches its peers:
    ESM only, dist/index.mjs, engines ^20.19.4 || >=22.12.0, version
    1.0.0-rc.4 in lockstep, and tsdown.config.mts copied from core.
  • Lazy provider loading in the build tier. The plan passes an already
    loaded provider to the tier helpers. That would load a provider even on a
    local hit, which the design forbids, so both helpers take loadProvider and
    consult it only after a local miss.
  • target instead of context, plus ensureDestination: the helpers take
    a target (projectRoot, platform, key) and add the AbortSignal
    themselves, and the download directory is created only when a provider is
    actually about to be asked.
  • No composite Metro store when no provider is configured.
    sharedCacheStores() returns the plain tagged FileStore, exactly as
    before; the composite appears only when a provider is selected. This keeps
    cache-packages.test.ts and the store-root tagging contract untouched and
    makes the second tier a pure addition.
  • Warning dedup. The Metro tier dedups per failure class internally,
    because it is called once per transform. The build helpers are stateless per
    call, so stim ios and stim android share one createWarnOnce across the
    lookup and the store; the commands, not the helpers, are what guarantee one
    note per class per run.
  • Guide and skill. guide lifecycle now documents three cache levels and
    guide settings documents the two keys, with contract tests. SKILL.md is
    unchanged: the normal workflow, ownership rules, and topic routing did not
    change (invariant 1).
  • Website. cache-packages.md is unchanged as requested; the settings
    reference gained the two keys and the note that a committed cache.provider
    is executable code.

Known limits, deliberately left out

  • The provider tier reaches Metro through sharedCacheStores(), which covers
    the 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 settings and the website say so.
  • The post-prebuild/pod install re-lookup under the mutated key stays
    local-only; a provider entry under the new key is a miss, never a wrong
    artifact.
  • No end-to-end provider fixture (plan task 8). The provider path is covered by
    unit tests at every layer plus the command tests; cache-flow.e2e.js still
    covers the local machinery.
  • TieredMetroStore.flush() is a test and embedder hook, not a Metro
    lifecycle 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 hanging
    module 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 none sentinel, the invalid-setting note, the
    extended 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/cache added to the published
    entry points and the ESM-only dist assertions.
  • node --test test/e2e/cache-flow.e2e.js: 8 tests pass.

Fixes #130

@janicduplessis
janicduplessis marked this pull request as ready for review August 31, 2026 14:44
@janicduplessis
janicduplessis merged commit fadc2ce into main Aug 31, 2026
5 checks passed
@janicduplessis
janicduplessis deleted the feat/cache-provider-package branch August 31, 2026 14:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Make the Stim cache tiers pluggable behind a provider contract

1 participant