@stim-cli/cache 1.0.0-rc.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Janic Duplessis
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,141 @@
1
+ # @stim-cli/cache
2
+
3
+ The cache provider contract Stim uses for Metro transforms and native build
4
+ artifacts, plus the tier coordination, timeout, and warning policy that sit in
5
+ front of it.
6
+
7
+ The local filesystem is always the first tier. A project can add one optional
8
+ second tier by pointing `cache.provider` at a module that implements this
9
+ contract. Stim ships no network provider; writing one is an addition, not a
10
+ change to Stim.
11
+
12
+ If Stim is not installed globally, replace `stim` with `npx stim-cli`.
13
+
14
+ ## Selecting a provider
15
+
16
+ ```json
17
+ {
18
+ "cache": {
19
+ "provider": "./tools/cache-provider.cjs",
20
+ "options": { "bucket": "mobile-cache" }
21
+ }
22
+ }
23
+ ```
24
+
25
+ The reference is a package name or a path relative to the settings file that
26
+ declares it. Machine settings override committed `.stim.json` settings, and the
27
+ existing nested merge rules apply to `cache.options`. Keep secrets out of
28
+ committed settings; read them from the environment or from machine settings.
29
+
30
+ ## Writing a provider
31
+
32
+ ```js
33
+ export const apiVersion = 1;
34
+
35
+ export async function createCacheProvider({ projectRoot, options }) {
36
+ return {
37
+ metro: {
38
+ async get({ key, cacheName, signal }) {
39
+ return null;
40
+ },
41
+ async set({ key, value, cacheName, signal }) {},
42
+ },
43
+ builds: {
44
+ async resolve({ platform, key, destinationDir, signal }) {
45
+ return null;
46
+ },
47
+ async store({ platform, key, sourcePath, overwrite, signal }) {},
48
+ },
49
+ };
50
+ }
51
+ ```
52
+
53
+ A provider implements one or both capabilities. It owns transport,
54
+ serialization, archive format, authentication, and remote retention. Stim owns
55
+ fingerprints, cache keys, and local artifact paths.
56
+
57
+ `metro.get` returns the stored value or `null`.
58
+
59
+ `builds.resolve` returns an existing path to the artifact, or `null` for a
60
+ miss. `destinationDir` is a scratch directory Stim creates and owns: a provider
61
+ that fetches the artifact must materialize it there and return a path inside
62
+ it, and must leave the directory empty on a miss. The built-in filesystem tier
63
+ already holds the artifact, so it returns its own cache path instead.
64
+
65
+ `builds.store` receives the `.app` directory or `.apk` file that Stim just
66
+ built. `overwrite: false` must keep an entry that already exists for the key,
67
+ and `overwrite: true` must replace it.
68
+
69
+ Every call receives an `AbortSignal`. A provider must honor it: Stim abandons
70
+ the call at the deadline and keeps building or bundling with the local tier.
71
+
72
+ `stim gc` never deletes provider data, and the contract has no delete
73
+ operation, so shared team or CI data is never removed by a local command.
74
+
75
+ ## Failure rules
76
+
77
+ Provider failures are cache misses. A timeout, module error, authentication
78
+ error, or network error produces one warning per failure class per command or
79
+ supervisor run and never fails a bundle, an install, a launch, or a successful
80
+ build.
81
+
82
+ ## Contract tests
83
+
84
+ Run the shipped checks against your own module:
85
+
86
+ ```js
87
+ import { runCacheProviderContract } from '@stim-cli/cache';
88
+
89
+ const results = await runCacheProviderContract({
90
+ provider: await createCacheProvider({ projectRoot, options }),
91
+ projectRoot,
92
+ workDir,
93
+ });
94
+
95
+ for (const result of results) {
96
+ if (!result.passed) throw new Error(`${result.name}: ${result.error}`);
97
+ }
98
+ ```
99
+
100
+ Pass `providerModule` instead of `provider` to load the module the way Stim
101
+ does, which also checks `apiVersion` and the factory:
102
+
103
+ ```js
104
+ const results = await runCacheProviderContract({
105
+ providerModule: './tools/cache-provider.cjs',
106
+ projectRoot,
107
+ workDir,
108
+ });
109
+ ```
110
+
111
+ `cacheProviderContractChecks()` returns the same checks as individual cases for
112
+ a test runner that reports each one separately. Both helpers only check the
113
+ capabilities a provider advertises, bound every check with a deadline, and
114
+ verify that a call settles once its `AbortSignal` aborts.
115
+
116
+ ## Budgets
117
+
118
+ Every provider call is bounded. The defaults are 2s for a Metro read, 10s for a
119
+ Metro write, 30s for a build lookup, 60s for a build upload, and 10s to load the
120
+ module. Override any of them per run with an environment variable:
121
+
122
+ ```bash
123
+ STIM_CACHE_METRO_READ_TIMEOUT_MS=5000 stim start
124
+ STIM_CACHE_BUILD_RESOLVE_TIMEOUT_MS=60000 stim ios
125
+ ```
126
+
127
+ The variables are `STIM_CACHE_METRO_READ_TIMEOUT_MS`,
128
+ `STIM_CACHE_METRO_WRITE_TIMEOUT_MS`, `STIM_CACHE_BUILD_RESOLVE_TIMEOUT_MS`,
129
+ `STIM_CACHE_BUILD_UPLOAD_TIMEOUT_MS`, and `STIM_CACHE_LOAD_TIMEOUT_MS`. Each
130
+ takes whole milliseconds; any other value keeps the default.
131
+
132
+ The tiered Metro store also exposes `flush()`, which resolves once queued
133
+ provider writes have drained. Metro never calls it; every in-flight write holds
134
+ a referenced deadline, so a Metro process drains on its own within the write
135
+ budget. It exists for tests and embedders that own the process.
136
+
137
+ Metro reads are also capped: at most six can be in flight, and the tier turns
138
+ itself off for the rest of the run after five consecutive failures, so a broken
139
+ provider costs one round of warnings rather than a timeout per transform.
140
+
141
+ The npm scope remains `@stim-cli` until the `@stim` scope is available.
@@ -0,0 +1,252 @@
1
+ //#region provider.d.ts
2
+ declare const CACHE_PROVIDER_API_VERSION: 1;
3
+ declare const CACHE_PROVIDER_ENV = "STIM_CACHE_PROVIDER_CONFIG";
4
+ declare const CACHE_PROVIDER_ENV_NONE = "none";
5
+ declare const PROVIDER_LOAD_TIMEOUT_MS = 1e4;
6
+ declare const PROVIDER_LOAD_TIMEOUT_ENV = "STIM_CACHE_LOAD_TIMEOUT_MS";
7
+ /**
8
+ * Reads a positive integer millisecond override from the environment. Any other
9
+ * value falls back to the shipped default.
10
+ */
11
+ declare function timeoutFromEnv(name: string, fallback: number, env?: NodeJS.ProcessEnv): number;
12
+ /**
13
+ * One resolved provider selection: the module reference, the options passed to
14
+ * its factory, and the directory the reference resolves from.
15
+ */
16
+ interface CacheProviderConfig {
17
+ provider: string;
18
+ options: Record<string, unknown>;
19
+ baseDir: string;
20
+ }
21
+ interface MetroCacheContext {
22
+ projectRoot: string;
23
+ cacheName: string;
24
+ signal: AbortSignal;
25
+ }
26
+ interface MetroCacheGetInput extends MetroCacheContext {
27
+ key: Buffer;
28
+ }
29
+ interface MetroCacheSetInput extends MetroCacheContext {
30
+ key: Buffer;
31
+ value: unknown;
32
+ }
33
+ /**
34
+ * Metro transform cache. `get` returns `null` or `undefined` for a miss. `set`
35
+ * stores the value under the key. Both receive an `AbortSignal` the provider
36
+ * must honor.
37
+ */
38
+ interface MetroCacheCapability {
39
+ get(input: MetroCacheGetInput): unknown;
40
+ set(input: MetroCacheSetInput): void | Promise<void>;
41
+ }
42
+ interface BuildCacheTarget {
43
+ projectRoot: string;
44
+ platform: "ios" | "android";
45
+ key: string;
46
+ }
47
+ interface BuildCacheContext extends BuildCacheTarget {
48
+ signal: AbortSignal;
49
+ }
50
+ interface BuildResolveInput extends BuildCacheContext {
51
+ destinationDir: string;
52
+ }
53
+ interface BuildStoreInput extends BuildCacheContext {
54
+ sourcePath: string;
55
+ overwrite: boolean;
56
+ }
57
+ /**
58
+ * Native build artifacts.
59
+ *
60
+ * `resolve` returns an existing path to the artifact for the key, or `null` for
61
+ * a miss. `destinationDir` is a scratch directory Stim creates and owns: a
62
+ * capability that fetches the artifact must materialize it there and return a
63
+ * path inside it, and a capability that already holds a local copy (the
64
+ * built-in filesystem tier) returns that copy instead. A miss must leave
65
+ * `destinationDir` empty.
66
+ *
67
+ * `store` publishes the `.app` directory or `.apk` file at `sourcePath`.
68
+ * `overwrite: false` must keep an entry that already exists for the key;
69
+ * `overwrite: true` must replace it. A capability that owns a local path
70
+ * returns it, and any other capability returns nothing.
71
+ *
72
+ * Stim owns fingerprints and cache keys; the capability owns transport,
73
+ * archive format, authentication, and retention.
74
+ */
75
+ interface BuildCacheCapability {
76
+ resolve(input: BuildResolveInput): string | null | Promise<string | null>;
77
+ store(input: BuildStoreInput): string | null | void | Promise<string | null | void>;
78
+ }
79
+ interface CacheProvider {
80
+ metro?: MetroCacheCapability;
81
+ builds?: BuildCacheCapability;
82
+ }
83
+ /**
84
+ * The module shape a provider author exports. The loader rejects any other
85
+ * `apiVersion`.
86
+ */
87
+ interface CacheProviderModule {
88
+ apiVersion: typeof CACHE_PROVIDER_API_VERSION;
89
+ createCacheProvider(input: {
90
+ projectRoot: string;
91
+ options: Record<string, unknown>;
92
+ }): CacheProvider | Promise<CacheProvider>;
93
+ }
94
+ interface LoadCacheProviderResult {
95
+ provider?: CacheProvider;
96
+ name?: string;
97
+ none?: true;
98
+ unavailable?: string;
99
+ }
100
+ interface ProviderCallResult<T> {
101
+ value?: T;
102
+ failed?: string;
103
+ timedOut?: true;
104
+ }
105
+ type WarnOnce = (code: string, message: string) => void;
106
+ declare function loadCacheProvider({ projectRoot, config, timeoutMs }: {
107
+ projectRoot: string;
108
+ config?: CacheProviderConfig | null;
109
+ timeoutMs?: number;
110
+ }): Promise<LoadCacheProviderResult>;
111
+ declare function callWithTimeout<T>(call: (signal: AbortSignal) => T | Promise<T>, timeoutMs: number): Promise<ProviderCallResult<T>>;
112
+ declare function createWarnOnce(emit: (message: string) => void): WarnOnce;
113
+ declare function cacheProviderEnv(config: CacheProviderConfig | null): string;
114
+ /**
115
+ * True when a parent process decided the provider selection, including the
116
+ * `none` sentinel. A child must not run its own search once this is set.
117
+ */
118
+ declare function cacheProviderEnvIsSet(env?: NodeJS.ProcessEnv): boolean;
119
+ declare function cacheProviderConfigFromEnv(env?: NodeJS.ProcessEnv): CacheProviderConfig | null;
120
+ //#endregion
121
+ //#region metro.d.ts
122
+ declare const METRO_READ_TIMEOUT_MS = 2e3;
123
+ declare const METRO_WRITE_TIMEOUT_MS = 1e4;
124
+ declare const METRO_READ_CONCURRENCY = 6;
125
+ declare const METRO_READ_FAILURE_LIMIT = 5;
126
+ declare const METRO_UPLOAD_CONCURRENCY = 4;
127
+ declare const METRO_UPLOAD_MAX_ITEMS = 128;
128
+ declare const METRO_UPLOAD_MAX_BYTES: number;
129
+ declare const METRO_READ_TIMEOUT_ENV = "STIM_CACHE_METRO_READ_TIMEOUT_MS";
130
+ declare const METRO_WRITE_TIMEOUT_ENV = "STIM_CACHE_METRO_WRITE_TIMEOUT_MS";
131
+ /**
132
+ * Metro's structural cache-store contract: `get` returns the value or `null`,
133
+ * `set` stores it, and `clear` empties the store.
134
+ */
135
+ interface MetroCacheStore {
136
+ get(key: Buffer): unknown;
137
+ set(key: Buffer, value: unknown): unknown;
138
+ clear(): unknown;
139
+ }
140
+ interface TieredMetroStore extends MetroCacheStore {
141
+ get(key: Buffer): Promise<unknown>;
142
+ set(key: Buffer, value: unknown): Promise<void>;
143
+ clear(): unknown;
144
+ /**
145
+ * Resolves once queued provider writes have drained. Metro never calls it:
146
+ * every in-flight write already holds a referenced deadline, so the process
147
+ * drains on its own. Tests and embedders that own the process use it to wait
148
+ * without polling.
149
+ */
150
+ flush(): Promise<void>;
151
+ }
152
+ interface MetroTierLimits {
153
+ concurrency?: number;
154
+ maxItems?: number;
155
+ maxBytes?: number;
156
+ readConcurrency?: number;
157
+ failureLimit?: number;
158
+ }
159
+ interface MetroTierTimeouts {
160
+ readMs?: number;
161
+ writeMs?: number;
162
+ }
163
+ interface TieredMetroStoreOptions {
164
+ local: MetroCacheStore;
165
+ projectRoot: string;
166
+ cacheName: string;
167
+ loadProvider: () => Promise<LoadCacheProviderResult>;
168
+ warn?: WarnOnce;
169
+ limits?: MetroTierLimits;
170
+ timeouts?: MetroTierTimeouts;
171
+ }
172
+ declare function metroCapabilityFromStore(store: MetroCacheStore): MetroCacheCapability;
173
+ declare function createTieredMetroStore({ local, projectRoot, cacheName, loadProvider, warn: emit, limits, timeouts }: TieredMetroStoreOptions): TieredMetroStore;
174
+ //#endregion
175
+ //#region builds.d.ts
176
+ declare const BUILD_RESOLVE_TIMEOUT_MS = 3e4;
177
+ declare const BUILD_UPLOAD_TIMEOUT_MS = 6e4;
178
+ declare const BUILD_RESOLVE_TIMEOUT_ENV = "STIM_CACHE_BUILD_RESOLVE_TIMEOUT_MS";
179
+ declare const BUILD_UPLOAD_TIMEOUT_ENV = "STIM_CACHE_BUILD_UPLOAD_TIMEOUT_MS";
180
+ declare function buildResolveTimeoutMs(env?: NodeJS.ProcessEnv): number;
181
+ declare function buildUploadTimeoutMs(env?: NodeJS.ProcessEnv): number;
182
+ interface TieredBuildResolution {
183
+ path: string;
184
+ tier: "local" | "provider";
185
+ providerName?: string;
186
+ storedLocally?: boolean;
187
+ }
188
+ type LoadBuildProvider = () => Promise<LoadCacheProviderResult> | LoadCacheProviderResult;
189
+ interface ResolveTieredBuildOptions {
190
+ local: BuildCacheCapability;
191
+ loadProvider?: LoadBuildProvider | null;
192
+ target: BuildCacheTarget;
193
+ destinationDir: string;
194
+ /** Called right before a provider is asked, so a local hit creates nothing on disk. */
195
+ ensureDestination?: (destinationDir: string) => void;
196
+ skipRead?: boolean;
197
+ warn?: WarnOnce;
198
+ timeoutMs?: number;
199
+ }
200
+ interface StoreTieredBuildOptions {
201
+ local: BuildCacheCapability;
202
+ loadProvider?: LoadBuildProvider | null;
203
+ target: BuildCacheTarget;
204
+ sourcePath: string;
205
+ overwrite: boolean;
206
+ warn?: WarnOnce;
207
+ timeoutMs?: number;
208
+ }
209
+ interface TieredBuildStoreResult {
210
+ localPath: string | null;
211
+ providerUpload: Promise<ProviderCallResult<void>> | null;
212
+ providerName: string | null;
213
+ }
214
+ declare function resolveTieredBuild({ local, loadProvider, target, destinationDir, ensureDestination, skipRead, warn, timeoutMs }: ResolveTieredBuildOptions): Promise<TieredBuildResolution | null>;
215
+ declare function storeTieredBuild({ local, loadProvider, target, sourcePath, overwrite, warn, timeoutMs }: StoreTieredBuildOptions): Promise<TieredBuildStoreResult>;
216
+ //#endregion
217
+ //#region contract.d.ts
218
+ interface CacheContractCheck {
219
+ name: string;
220
+ capability: "metro" | "builds";
221
+ run(): Promise<void>;
222
+ }
223
+ interface CacheContractResult {
224
+ name: string;
225
+ capability: "metro" | "builds" | "module";
226
+ passed: boolean;
227
+ error?: string;
228
+ }
229
+ interface CacheContractOptions {
230
+ provider: CacheProvider;
231
+ projectRoot: string;
232
+ workDir: string;
233
+ cacheName?: string;
234
+ platform?: "ios" | "android";
235
+ checkTimeoutMs?: number;
236
+ abortSettleMs?: number;
237
+ }
238
+ /**
239
+ * Loads the provider the way Stim does, from a module reference, so a provider
240
+ * author exercises `apiVersion` and factory validation as well as the
241
+ * capability checks.
242
+ */
243
+ interface CacheContractModuleOptions extends Omit<CacheContractOptions, "provider"> {
244
+ providerModule: string;
245
+ options?: Record<string, unknown>;
246
+ baseDir?: string;
247
+ }
248
+ declare const CACHE_CONTRACT_CHECK_TIMEOUT_MS = 3e4;
249
+ declare function cacheProviderContractChecks(options: CacheContractOptions): CacheContractCheck[];
250
+ declare function runCacheProviderContract(options: CacheContractOptions | CacheContractModuleOptions): Promise<CacheContractResult[]>;
251
+ //#endregion
252
+ export { BUILD_RESOLVE_TIMEOUT_ENV, BUILD_RESOLVE_TIMEOUT_MS, BUILD_UPLOAD_TIMEOUT_ENV, BUILD_UPLOAD_TIMEOUT_MS, BuildCacheCapability, BuildCacheContext, BuildCacheTarget, BuildResolveInput, BuildStoreInput, CACHE_CONTRACT_CHECK_TIMEOUT_MS, CACHE_PROVIDER_API_VERSION, CACHE_PROVIDER_ENV, CACHE_PROVIDER_ENV_NONE, CacheContractCheck, CacheContractModuleOptions, CacheContractOptions, CacheContractResult, CacheProvider, CacheProviderConfig, CacheProviderModule, LoadBuildProvider, LoadCacheProviderResult, METRO_READ_CONCURRENCY, METRO_READ_FAILURE_LIMIT, METRO_READ_TIMEOUT_ENV, METRO_READ_TIMEOUT_MS, METRO_UPLOAD_CONCURRENCY, METRO_UPLOAD_MAX_BYTES, METRO_UPLOAD_MAX_ITEMS, METRO_WRITE_TIMEOUT_ENV, METRO_WRITE_TIMEOUT_MS, MetroCacheCapability, MetroCacheContext, MetroCacheGetInput, MetroCacheSetInput, MetroCacheStore, MetroTierLimits, MetroTierTimeouts, PROVIDER_LOAD_TIMEOUT_ENV, PROVIDER_LOAD_TIMEOUT_MS, ProviderCallResult, ResolveTieredBuildOptions, StoreTieredBuildOptions, TieredBuildResolution, TieredBuildStoreResult, TieredMetroStore, TieredMetroStoreOptions, WarnOnce, buildResolveTimeoutMs, buildUploadTimeoutMs, cacheProviderConfigFromEnv, cacheProviderContractChecks, cacheProviderEnv, cacheProviderEnvIsSet, callWithTimeout, createTieredMetroStore, createWarnOnce, loadCacheProvider, metroCapabilityFromStore, resolveTieredBuild, runCacheProviderContract, storeTieredBuild, timeoutFromEnv };
package/dist/index.mjs ADDED
@@ -0,0 +1,889 @@
1
+ import { createRequire } from "node:module";
2
+ import { join, relative, sep } from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
5
+ import { randomBytes, randomUUID } from "node:crypto";
6
+ //#region provider.ts
7
+ const CACHE_PROVIDER_API_VERSION = 1;
8
+ const CACHE_PROVIDER_ENV = "STIM_CACHE_PROVIDER_CONFIG";
9
+ const CACHE_PROVIDER_ENV_NONE = "none";
10
+ const PROVIDER_LOAD_TIMEOUT_MS = 1e4;
11
+ const PROVIDER_LOAD_TIMEOUT_ENV = "STIM_CACHE_LOAD_TIMEOUT_MS";
12
+ /**
13
+ * Reads a positive integer millisecond override from the environment. Any other
14
+ * value falls back to the shipped default.
15
+ */
16
+ function timeoutFromEnv(name, fallback, env = process.env) {
17
+ const raw = env[name];
18
+ if (typeof raw !== "string" || !/^[1-9]\d*$/.test(raw.trim())) return fallback;
19
+ const parsed = Number(raw.trim());
20
+ return Number.isSafeInteger(parsed) ? parsed : fallback;
21
+ }
22
+ function isRecord(value) {
23
+ return value !== null && typeof value === "object" && !Array.isArray(value);
24
+ }
25
+ function reason(error) {
26
+ return String(error?.message || error || "unknown error").split("\n")[0].trim() || "unknown error";
27
+ }
28
+ function moduleCandidate(namespace) {
29
+ if (!isRecord(namespace)) return namespace;
30
+ if ("apiVersion" in namespace) return namespace;
31
+ return "default" in namespace ? namespace.default : namespace;
32
+ }
33
+ function capabilityError(provider) {
34
+ const metro = provider.metro;
35
+ if (metro !== void 0 && (typeof metro?.get !== "function" || typeof metro?.set !== "function")) return "the metro capability must implement get() and set()";
36
+ const builds = provider.builds;
37
+ if (builds !== void 0 && (typeof builds?.resolve !== "function" || typeof builds?.store !== "function")) return "the builds capability must implement resolve() and store()";
38
+ if (metro === void 0 && builds === void 0) return "the provider advertises neither a metro nor a builds capability";
39
+ return null;
40
+ }
41
+ async function loadCacheProvider({ projectRoot, config, timeoutMs = timeoutFromEnv(PROVIDER_LOAD_TIMEOUT_ENV, PROVIDER_LOAD_TIMEOUT_MS) }) {
42
+ const reference = typeof config?.provider === "string" ? config.provider.trim() : "";
43
+ if (!config || reference === "") return { none: true };
44
+ const outcome = await callWithTimeout(() => importCacheProvider({
45
+ projectRoot,
46
+ config,
47
+ reference
48
+ }), timeoutMs);
49
+ if (outcome.timedOut) return {
50
+ name: reference,
51
+ unavailable: `the module did not load within ${timeoutMs}ms`
52
+ };
53
+ if (outcome.failed) return {
54
+ name: reference,
55
+ unavailable: outcome.failed
56
+ };
57
+ return outcome.value ?? {
58
+ name: reference,
59
+ unavailable: "the module produced no provider"
60
+ };
61
+ }
62
+ async function importCacheProvider({ projectRoot, config, reference }) {
63
+ const options = isRecord(config.options) ? config.options : {};
64
+ const baseDir = typeof config.baseDir === "string" && config.baseDir !== "" ? config.baseDir : projectRoot;
65
+ let namespace;
66
+ try {
67
+ const resolved = createRequire(join(baseDir, "package.json")).resolve(reference);
68
+ namespace = await import(pathToFileURL(resolved).href);
69
+ } catch (error) {
70
+ return {
71
+ name: reference,
72
+ unavailable: reason(error)
73
+ };
74
+ }
75
+ const candidate = moduleCandidate(namespace);
76
+ if (!isRecord(candidate) || candidate.apiVersion !== 1) return {
77
+ name: reference,
78
+ unavailable: `expected apiVersion 1, found ${JSON.stringify(isRecord(candidate) ? candidate.apiVersion : candidate)}`
79
+ };
80
+ if (typeof candidate.createCacheProvider !== "function") return {
81
+ name: reference,
82
+ unavailable: "the module does not export createCacheProvider()"
83
+ };
84
+ let provider;
85
+ try {
86
+ provider = await candidate.createCacheProvider({
87
+ projectRoot,
88
+ options
89
+ });
90
+ } catch (error) {
91
+ return {
92
+ name: reference,
93
+ unavailable: `createCacheProvider() failed: ${reason(error)}`
94
+ };
95
+ }
96
+ if (!isRecord(provider)) return {
97
+ name: reference,
98
+ unavailable: "createCacheProvider() did not return a provider object"
99
+ };
100
+ const invalid = capabilityError(provider);
101
+ if (invalid) return {
102
+ name: reference,
103
+ unavailable: invalid
104
+ };
105
+ return {
106
+ name: reference,
107
+ provider
108
+ };
109
+ }
110
+ async function callWithTimeout(call, timeoutMs) {
111
+ const controller = new AbortController();
112
+ let timer = null;
113
+ const expired = new Promise((resolve) => {
114
+ timer = setTimeout(() => {
115
+ controller.abort();
116
+ resolve("timeout");
117
+ }, timeoutMs);
118
+ });
119
+ try {
120
+ const outcome = await Promise.race([Promise.resolve().then(() => call(controller.signal)).then((value) => ({ value }), (error) => ({ failed: reason(error) })), expired]);
121
+ return outcome === "timeout" ? { timedOut: true } : outcome;
122
+ } finally {
123
+ if (timer) clearTimeout(timer);
124
+ }
125
+ }
126
+ function createWarnOnce(emit) {
127
+ const seen = /* @__PURE__ */ new Set();
128
+ return (code, message) => {
129
+ if (seen.has(code)) return;
130
+ seen.add(code);
131
+ emit(message);
132
+ };
133
+ }
134
+ function cacheProviderEnv(config) {
135
+ if (!config) return CACHE_PROVIDER_ENV_NONE;
136
+ return JSON.stringify({
137
+ provider: config.provider,
138
+ options: config.options ?? {},
139
+ baseDir: config.baseDir
140
+ });
141
+ }
142
+ /**
143
+ * True when a parent process decided the provider selection, including the
144
+ * `none` sentinel. A child must not run its own search once this is set.
145
+ */
146
+ function cacheProviderEnvIsSet(env = process.env) {
147
+ const raw = env[CACHE_PROVIDER_ENV];
148
+ return typeof raw === "string" && raw.trim() !== "";
149
+ }
150
+ function cacheProviderConfigFromEnv(env = process.env) {
151
+ const raw = env[CACHE_PROVIDER_ENV];
152
+ if (typeof raw !== "string" || raw.trim() === "" || raw.trim() === "none") return null;
153
+ let parsed;
154
+ try {
155
+ parsed = JSON.parse(raw);
156
+ } catch {
157
+ return null;
158
+ }
159
+ if (!isRecord(parsed)) return null;
160
+ const provider = parsed.provider;
161
+ const baseDir = parsed.baseDir;
162
+ if (typeof provider !== "string" || provider.trim() === "" || typeof baseDir !== "string" || baseDir === "") return null;
163
+ return {
164
+ provider,
165
+ options: isRecord(parsed.options) ? parsed.options : {},
166
+ baseDir
167
+ };
168
+ }
169
+ //#endregion
170
+ //#region metro.ts
171
+ const METRO_READ_TIMEOUT_MS = 2e3;
172
+ const METRO_WRITE_TIMEOUT_MS = 1e4;
173
+ const METRO_READ_CONCURRENCY = 6;
174
+ const METRO_READ_FAILURE_LIMIT = 5;
175
+ const METRO_UPLOAD_CONCURRENCY = 4;
176
+ const METRO_UPLOAD_MAX_ITEMS = 128;
177
+ const METRO_UPLOAD_MAX_BYTES = 33554432;
178
+ const METRO_READ_TIMEOUT_ENV = "STIM_CACHE_METRO_READ_TIMEOUT_MS";
179
+ const METRO_WRITE_TIMEOUT_ENV = "STIM_CACHE_METRO_WRITE_TIMEOUT_MS";
180
+ function metroCapabilityFromStore(store) {
181
+ return {
182
+ get: ({ key }) => store.get(key),
183
+ set: async ({ key, value }) => {
184
+ await store.set(key, value);
185
+ }
186
+ };
187
+ }
188
+ const defaultWarn = (_code, message) => {
189
+ process.stderr.write(`${message}\n`);
190
+ };
191
+ function onceByCode(warn) {
192
+ const seen = /* @__PURE__ */ new Set();
193
+ return (code, message) => {
194
+ if (seen.has(code)) return;
195
+ seen.add(code);
196
+ warn(code, message);
197
+ };
198
+ }
199
+ function valueBytes(value) {
200
+ if (Buffer.isBuffer(value)) return value.length;
201
+ try {
202
+ const json = JSON.stringify(value);
203
+ return json === void 0 ? null : Buffer.byteLength(json);
204
+ } catch {
205
+ return null;
206
+ }
207
+ }
208
+ function createUploadQueue({ concurrency, maxItems, maxBytes }) {
209
+ const pending = [];
210
+ const waiters = [];
211
+ let active = 0;
212
+ let items = 0;
213
+ let bytes = 0;
214
+ function settle() {
215
+ if (active > 0 || pending.length > 0) return;
216
+ while (waiters.length) waiters.shift()?.();
217
+ }
218
+ function pump() {
219
+ while (active < concurrency && pending.length > 0) {
220
+ const task = pending.shift();
221
+ active += 1;
222
+ task.run().catch(() => {}).finally(() => {
223
+ active -= 1;
224
+ items -= 1;
225
+ bytes -= task.bytes;
226
+ pump();
227
+ settle();
228
+ });
229
+ }
230
+ }
231
+ return {
232
+ add(size, run) {
233
+ if (items + 1 > maxItems || bytes + size > maxBytes) return false;
234
+ items += 1;
235
+ bytes += size;
236
+ pending.push({
237
+ bytes: size,
238
+ run
239
+ });
240
+ pump();
241
+ return true;
242
+ },
243
+ idle() {
244
+ if (active === 0 && pending.length === 0) return Promise.resolve();
245
+ return new Promise((resolve) => waiters.push(resolve));
246
+ }
247
+ };
248
+ }
249
+ function createTieredMetroStore({ local, projectRoot, cacheName, loadProvider, warn: emit = defaultWarn, limits = {}, timeouts = {} }) {
250
+ const warn = onceByCode(emit);
251
+ const localCapability = metroCapabilityFromStore(local);
252
+ const readMs = timeouts.readMs ?? timeoutFromEnv("STIM_CACHE_METRO_READ_TIMEOUT_MS", 2e3);
253
+ const writeMs = timeouts.writeMs ?? timeoutFromEnv("STIM_CACHE_METRO_WRITE_TIMEOUT_MS", 1e4);
254
+ const readConcurrency = limits.readConcurrency ?? 6;
255
+ const failureLimit = limits.failureLimit ?? 5;
256
+ const queue = createUploadQueue({
257
+ concurrency: limits.concurrency ?? 4,
258
+ maxItems: limits.maxItems ?? 128,
259
+ maxBytes: limits.maxBytes ?? 33554432
260
+ });
261
+ let loading = null;
262
+ let disabled = false;
263
+ let consecutiveFailures = 0;
264
+ let activeReads = 0;
265
+ function recordFailure() {
266
+ consecutiveFailures += 1;
267
+ if (consecutiveFailures < failureLimit) return;
268
+ disabled = true;
269
+ warn("provider-disabled", `the cache provider failed ${consecutiveFailures} times in a row; this run keeps its transforms local`);
270
+ }
271
+ function providerCapability() {
272
+ if (disabled) return Promise.resolve(null);
273
+ loading ??= loadProvider().then((loaded) => {
274
+ if (loaded?.unavailable) {
275
+ disabled = true;
276
+ warn("provider-load", `cache provider ${loaded.name} is not usable: ${loaded.unavailable}; using local transforms`);
277
+ return null;
278
+ }
279
+ return loaded?.provider?.metro ?? null;
280
+ });
281
+ return loading;
282
+ }
283
+ return {
284
+ async get(key) {
285
+ const hit = await localCapability.get({
286
+ key,
287
+ projectRoot,
288
+ cacheName,
289
+ signal: neverAborted$2()
290
+ });
291
+ if (hit !== null && hit !== void 0) return hit;
292
+ if (activeReads >= readConcurrency) {
293
+ warn("provider-read-busy", `the cache provider already has ${activeReads} reads in flight; further transforms read locally until it catches up`);
294
+ return null;
295
+ }
296
+ activeReads += 1;
297
+ let outcome;
298
+ try {
299
+ const capability = await providerCapability();
300
+ if (!capability) return null;
301
+ outcome = await callWithTimeout((signal) => capability.get({
302
+ key,
303
+ projectRoot,
304
+ cacheName,
305
+ signal
306
+ }), readMs);
307
+ } finally {
308
+ activeReads -= 1;
309
+ }
310
+ if (outcome.timedOut) {
311
+ recordFailure();
312
+ warn("provider-read", `the cache provider did not answer a transform read within ${readMs}ms; using local transforms`);
313
+ return null;
314
+ }
315
+ if (outcome.failed) {
316
+ recordFailure();
317
+ warn("provider-read", `the cache provider could not read a transform: ${outcome.failed}; using local transforms`);
318
+ return null;
319
+ }
320
+ consecutiveFailures = 0;
321
+ const value = outcome.value;
322
+ if (value === null || value === void 0) return null;
323
+ try {
324
+ await localCapability.set({
325
+ key,
326
+ value,
327
+ projectRoot,
328
+ cacheName,
329
+ signal: neverAborted$2()
330
+ });
331
+ } catch (error) {
332
+ warn("provider-backfill", `a provider transform could not be written locally: ${String(error?.message || error)}`);
333
+ }
334
+ return value;
335
+ },
336
+ async set(key, value) {
337
+ await localCapability.set({
338
+ key,
339
+ value,
340
+ projectRoot,
341
+ cacheName,
342
+ signal: neverAborted$2()
343
+ });
344
+ const capability = await providerCapability();
345
+ if (!capability) return;
346
+ const bytes = valueBytes(value);
347
+ if (bytes === null) {
348
+ warn("provider-write", "a transform could not be measured for the cache provider; it stays local");
349
+ return;
350
+ }
351
+ if (!queue.add(bytes, async () => {
352
+ if (disabled) return;
353
+ const outcome = await callWithTimeout((signal) => capability.set({
354
+ key,
355
+ value,
356
+ projectRoot,
357
+ cacheName,
358
+ signal
359
+ }), writeMs);
360
+ if (outcome.timedOut || outcome.failed) recordFailure();
361
+ else consecutiveFailures = 0;
362
+ if (outcome.timedOut) warn("provider-write", `the cache provider did not accept a transform within ${writeMs}ms; later transforms stay local until it answers`);
363
+ else if (outcome.failed) warn("provider-write", `the cache provider could not store a transform: ${outcome.failed}`);
364
+ })) warn("provider-queue", "the cache provider upload queue is full; those transforms stay local");
365
+ },
366
+ clear() {
367
+ return local.clear();
368
+ },
369
+ flush() {
370
+ return queue.idle();
371
+ }
372
+ };
373
+ }
374
+ function neverAborted$2() {
375
+ return new AbortController().signal;
376
+ }
377
+ //#endregion
378
+ //#region builds.ts
379
+ const BUILD_RESOLVE_TIMEOUT_MS = 3e4;
380
+ const BUILD_UPLOAD_TIMEOUT_MS = 6e4;
381
+ const BUILD_RESOLVE_TIMEOUT_ENV = "STIM_CACHE_BUILD_RESOLVE_TIMEOUT_MS";
382
+ const BUILD_UPLOAD_TIMEOUT_ENV = "STIM_CACHE_BUILD_UPLOAD_TIMEOUT_MS";
383
+ function buildResolveTimeoutMs(env) {
384
+ return timeoutFromEnv(BUILD_RESOLVE_TIMEOUT_ENV, BUILD_RESOLVE_TIMEOUT_MS, env);
385
+ }
386
+ function buildUploadTimeoutMs(env) {
387
+ return timeoutFromEnv(BUILD_UPLOAD_TIMEOUT_ENV, BUILD_UPLOAD_TIMEOUT_MS, env);
388
+ }
389
+ async function providerTier(loadProvider, warn) {
390
+ if (!loadProvider) return null;
391
+ const loaded = await loadProvider();
392
+ if (loaded?.unavailable) {
393
+ warn("provider-load", `provider not usable (${loaded.name ?? "the cache provider"}): ${loaded.unavailable}; using local cache`);
394
+ return null;
395
+ }
396
+ const capability = loaded?.provider?.builds;
397
+ if (!capability) return null;
398
+ return {
399
+ capability,
400
+ name: loaded.name ?? "the cache provider"
401
+ };
402
+ }
403
+ function neverAborted$1() {
404
+ return new AbortController().signal;
405
+ }
406
+ function ignore() {}
407
+ async function resolveTieredBuild({ local, loadProvider = null, target, destinationDir, ensureDestination, skipRead = false, warn = ignore, timeoutMs = buildResolveTimeoutMs() }) {
408
+ if (skipRead) return null;
409
+ const hit = await local.resolve({
410
+ ...target,
411
+ destinationDir,
412
+ signal: neverAborted$1()
413
+ });
414
+ if (hit) return {
415
+ path: hit,
416
+ tier: "local"
417
+ };
418
+ const tier = await providerTier(loadProvider, warn);
419
+ if (!tier) return null;
420
+ const { capability: provider, name: label } = tier;
421
+ ensureDestination?.(destinationDir);
422
+ const outcome = await callWithTimeout((signal) => provider.resolve({
423
+ ...target,
424
+ destinationDir,
425
+ signal
426
+ }), timeoutMs);
427
+ if (outcome.timedOut) {
428
+ warn("provider-resolve", `${label} did not answer within ${timeoutMs}ms; building instead`);
429
+ return null;
430
+ }
431
+ if (outcome.failed) {
432
+ warn("provider-resolve", `${label} could not be used: ${outcome.failed}; building instead`);
433
+ return null;
434
+ }
435
+ const path = typeof outcome.value === "string" ? outcome.value.trim() : "";
436
+ if (path === "") return null;
437
+ if (!existsSync(path)) {
438
+ warn("provider-resolve", `${label} returned ${path}, which does not exist; building instead`);
439
+ return null;
440
+ }
441
+ let stored = null;
442
+ try {
443
+ stored = await local.store({
444
+ ...target,
445
+ sourcePath: path,
446
+ overwrite: false,
447
+ signal: neverAborted$1()
448
+ }) || null;
449
+ } catch (error) {
450
+ warn("provider-backfill", `a ${label} hit could not be stored locally: ${String(error?.message || error)}`);
451
+ }
452
+ return {
453
+ path: stored || path,
454
+ tier: "provider",
455
+ providerName: label,
456
+ storedLocally: Boolean(stored)
457
+ };
458
+ }
459
+ async function storeTieredBuild({ local, loadProvider = null, target, sourcePath, overwrite, warn = ignore, timeoutMs = buildUploadTimeoutMs() }) {
460
+ const localPath = await local.store({
461
+ ...target,
462
+ sourcePath,
463
+ overwrite,
464
+ signal: neverAborted$1()
465
+ }) || null;
466
+ const tier = await providerTier(loadProvider, warn);
467
+ if (!tier) return {
468
+ localPath,
469
+ providerUpload: null,
470
+ providerName: null
471
+ };
472
+ return {
473
+ localPath,
474
+ providerUpload: callWithTimeout(async (signal) => {
475
+ await tier.capability.store({
476
+ ...target,
477
+ sourcePath,
478
+ overwrite,
479
+ signal
480
+ });
481
+ }, timeoutMs),
482
+ providerName: tier.name
483
+ };
484
+ }
485
+ //#endregion
486
+ //#region contract.ts
487
+ const CACHE_CONTRACT_CHECK_TIMEOUT_MS = 3e4;
488
+ const ABORT_SETTLE_MS = 1e3;
489
+ async function settlesAfterAbort(start, settleMs) {
490
+ const controller = new AbortController();
491
+ const call = Promise.resolve().then(() => start(controller.signal)).then(() => "settled", () => "settled");
492
+ controller.abort();
493
+ let timer;
494
+ const outcome = await Promise.race([call, new Promise((resolve) => {
495
+ timer = setTimeout(() => resolve("ignored"), settleMs);
496
+ })]);
497
+ clearTimeout(timer);
498
+ assert(outcome === "settled", `the call did not settle within ${settleMs}ms of its signal aborting`);
499
+ }
500
+ function isInside(parent, child) {
501
+ const rel = relative(parent, child);
502
+ return rel !== "" && !rel.startsWith(`..${sep}`) && rel !== "..";
503
+ }
504
+ function neverAborted() {
505
+ return new AbortController().signal;
506
+ }
507
+ function assert(condition, message) {
508
+ if (!condition) throw new Error(message);
509
+ }
510
+ function metroChecks({ provider, projectRoot, cacheName = "app", abortSettleMs = ABORT_SETTLE_MS }) {
511
+ const metro = provider.metro;
512
+ if (!metro) return [];
513
+ const context = {
514
+ projectRoot,
515
+ cacheName,
516
+ signal: neverAborted()
517
+ };
518
+ return [
519
+ {
520
+ name: "metro get returns null or undefined for an unknown key",
521
+ capability: "metro",
522
+ async run() {
523
+ const value = await metro.get({
524
+ ...context,
525
+ key: randomBytes(32)
526
+ });
527
+ assert(value === null || value === void 0, `expected a miss, received ${JSON.stringify(value)}`);
528
+ }
529
+ },
530
+ {
531
+ name: "metro set then get returns the stored buffer",
532
+ capability: "metro",
533
+ async run() {
534
+ const key = randomBytes(32);
535
+ const value = randomBytes(64);
536
+ await metro.set({
537
+ ...context,
538
+ key,
539
+ value
540
+ });
541
+ const stored = await metro.get({
542
+ ...context,
543
+ key
544
+ });
545
+ assert(Buffer.isBuffer(stored), `expected a Buffer, received ${typeof stored}`);
546
+ assert(Buffer.compare(stored, value) === 0, "the stored buffer does not match the written buffer");
547
+ }
548
+ },
549
+ {
550
+ name: "metro set then get returns the stored object",
551
+ capability: "metro",
552
+ async run() {
553
+ const key = randomBytes(32);
554
+ const value = {
555
+ code: `contract-${randomUUID()}`,
556
+ map: [
557
+ 1,
558
+ 2,
559
+ 3
560
+ ]
561
+ };
562
+ await metro.set({
563
+ ...context,
564
+ key,
565
+ value
566
+ });
567
+ const stored = await metro.get({
568
+ ...context,
569
+ key
570
+ });
571
+ assert(JSON.stringify(stored) === JSON.stringify(value), `expected ${JSON.stringify(value)}, received ${JSON.stringify(stored)}`);
572
+ }
573
+ },
574
+ {
575
+ name: "metro get settles when its signal aborts",
576
+ capability: "metro",
577
+ async run() {
578
+ await settlesAfterAbort((signal) => metro.get({
579
+ projectRoot,
580
+ cacheName,
581
+ key: randomBytes(32),
582
+ signal
583
+ }), abortSettleMs);
584
+ }
585
+ },
586
+ {
587
+ name: "metro keys do not collide",
588
+ capability: "metro",
589
+ async run() {
590
+ const first = randomBytes(32);
591
+ const second = randomBytes(32);
592
+ await metro.set({
593
+ ...context,
594
+ key: first,
595
+ value: Buffer.from("first")
596
+ });
597
+ await metro.set({
598
+ ...context,
599
+ key: second,
600
+ value: Buffer.from("second")
601
+ });
602
+ const stored = await metro.get({
603
+ ...context,
604
+ key: first
605
+ });
606
+ assert(Buffer.isBuffer(stored) && stored.toString() === "first", "the second write overwrote the value of an unrelated key");
607
+ }
608
+ }
609
+ ];
610
+ }
611
+ function buildChecks({ provider, projectRoot, workDir, platform = "android", abortSettleMs = ABORT_SETTLE_MS }) {
612
+ const builds = provider.builds;
613
+ if (!builds) return [];
614
+ const signal = neverAborted();
615
+ function artifact() {
616
+ const dir = join(workDir, `source-${randomUUID()}`);
617
+ mkdirSync(dir, { recursive: true });
618
+ const sourcePath = join(dir, platform === "ios" ? "Contract.app" : "contract.apk");
619
+ const contents = randomBytes(128);
620
+ if (platform === "ios") {
621
+ mkdirSync(sourcePath, { recursive: true });
622
+ writeFileSync(join(sourcePath, "contract.bin"), contents);
623
+ } else writeFileSync(sourcePath, contents);
624
+ return {
625
+ sourcePath,
626
+ contents
627
+ };
628
+ }
629
+ function destination() {
630
+ const dir = join(workDir, `destination-${randomUUID()}`);
631
+ mkdirSync(dir, { recursive: true });
632
+ return dir;
633
+ }
634
+ function storedContents(path) {
635
+ return readFileSync(platform === "ios" ? join(path, "contract.bin") : path);
636
+ }
637
+ return [
638
+ {
639
+ name: "builds resolve returns null for an unknown key",
640
+ capability: "builds",
641
+ async run() {
642
+ const found = await builds.resolve({
643
+ projectRoot,
644
+ platform,
645
+ key: `contract-miss-${randomUUID()}`,
646
+ destinationDir: destination(),
647
+ signal
648
+ });
649
+ assert(found === null || found === void 0, `expected a miss, received ${String(found)}`);
650
+ }
651
+ },
652
+ {
653
+ name: "builds store then resolve returns the same artifact",
654
+ capability: "builds",
655
+ async run() {
656
+ const key = `contract-${randomUUID()}`;
657
+ const { sourcePath, contents } = artifact();
658
+ await builds.store({
659
+ projectRoot,
660
+ platform,
661
+ key,
662
+ sourcePath,
663
+ overwrite: false,
664
+ signal
665
+ });
666
+ const found = await builds.resolve({
667
+ projectRoot,
668
+ platform,
669
+ key,
670
+ destinationDir: destination(),
671
+ signal
672
+ });
673
+ assert(typeof found === "string" && found !== "", "the stored key did not resolve");
674
+ assert(Buffer.compare(storedContents(found), contents) === 0, "the resolved artifact does not match the stored artifact");
675
+ }
676
+ },
677
+ {
678
+ name: "builds resolve settles when its signal aborts",
679
+ capability: "builds",
680
+ async run() {
681
+ await settlesAfterAbort((aborting) => builds.resolve({
682
+ projectRoot,
683
+ platform,
684
+ key: `contract-abort-${randomUUID()}`,
685
+ destinationDir: destination(),
686
+ signal: aborting
687
+ }), abortSettleMs);
688
+ }
689
+ },
690
+ {
691
+ name: "builds resolve leaves the destination directory empty on a miss",
692
+ capability: "builds",
693
+ async run() {
694
+ const dir = destination();
695
+ await builds.resolve({
696
+ projectRoot,
697
+ platform,
698
+ key: `contract-miss-${randomUUID()}`,
699
+ destinationDir: dir,
700
+ signal
701
+ });
702
+ assert(readdirSync(dir).length === 0, `a miss left ${readdirSync(dir).join(", ")} in the destination`);
703
+ }
704
+ },
705
+ {
706
+ name: "builds resolve returns a path it owns or one inside the destination",
707
+ capability: "builds",
708
+ async run() {
709
+ const key = `contract-${randomUUID()}`;
710
+ const { sourcePath, contents } = artifact();
711
+ await builds.store({
712
+ projectRoot,
713
+ platform,
714
+ key,
715
+ sourcePath,
716
+ overwrite: false,
717
+ signal
718
+ });
719
+ const dir = destination();
720
+ const found = await builds.resolve({
721
+ projectRoot,
722
+ platform,
723
+ key,
724
+ destinationDir: dir,
725
+ signal
726
+ });
727
+ assert(typeof found === "string" && found !== "", "the stored key did not resolve");
728
+ const path = found;
729
+ assert(isInside(dir, path) || Buffer.compare(storedContents(path), contents) === 0, `${path} is neither inside the destination nor a copy the capability owns`);
730
+ }
731
+ },
732
+ {
733
+ name: "builds store honors overwrite",
734
+ capability: "builds",
735
+ async run() {
736
+ const key = `contract-${randomUUID()}`;
737
+ const first = artifact();
738
+ const second = artifact();
739
+ await builds.store({
740
+ projectRoot,
741
+ platform,
742
+ key,
743
+ sourcePath: first.sourcePath,
744
+ overwrite: false,
745
+ signal
746
+ });
747
+ await builds.store({
748
+ projectRoot,
749
+ platform,
750
+ key,
751
+ sourcePath: second.sourcePath,
752
+ overwrite: false,
753
+ signal
754
+ });
755
+ const kept = await builds.resolve({
756
+ projectRoot,
757
+ platform,
758
+ key,
759
+ destinationDir: destination(),
760
+ signal
761
+ });
762
+ assert(typeof kept === "string", "the stored key did not resolve");
763
+ assert(Buffer.compare(storedContents(kept), first.contents) === 0, "overwrite: false replaced an entry that already existed");
764
+ await builds.store({
765
+ projectRoot,
766
+ platform,
767
+ key,
768
+ sourcePath: second.sourcePath,
769
+ overwrite: true,
770
+ signal
771
+ });
772
+ const replaced = await builds.resolve({
773
+ projectRoot,
774
+ platform,
775
+ key,
776
+ destinationDir: destination(),
777
+ signal
778
+ });
779
+ assert(typeof replaced === "string", "the overwritten key did not resolve");
780
+ assert(Buffer.compare(storedContents(replaced), second.contents) === 0, "overwrite: true kept the previous entry");
781
+ }
782
+ },
783
+ {
784
+ name: "builds store keeps unrelated keys separate",
785
+ capability: "builds",
786
+ async run() {
787
+ const first = `contract-${randomUUID()}`;
788
+ const second = `contract-${randomUUID()}`;
789
+ const one = artifact();
790
+ const two = artifact();
791
+ await builds.store({
792
+ projectRoot,
793
+ platform,
794
+ key: first,
795
+ sourcePath: one.sourcePath,
796
+ overwrite: false,
797
+ signal
798
+ });
799
+ await builds.store({
800
+ projectRoot,
801
+ platform,
802
+ key: second,
803
+ sourcePath: two.sourcePath,
804
+ overwrite: false,
805
+ signal
806
+ });
807
+ const found = await builds.resolve({
808
+ projectRoot,
809
+ platform,
810
+ key: first,
811
+ destinationDir: destination(),
812
+ signal
813
+ });
814
+ assert(typeof found === "string" && found !== "", "the first key stopped resolving after the second store");
815
+ assert(Buffer.compare(storedContents(found), one.contents) === 0, "the second store replaced the artifact of an unrelated key");
816
+ }
817
+ }
818
+ ];
819
+ }
820
+ const MODULE_CHECK = "the module loads through loadCacheProvider()";
821
+ async function loadContractProvider(options) {
822
+ const loaded = await loadCacheProvider({
823
+ projectRoot: options.projectRoot,
824
+ config: {
825
+ provider: options.providerModule,
826
+ options: options.options ?? {},
827
+ baseDir: options.baseDir ?? options.projectRoot
828
+ }
829
+ });
830
+ if (!loaded.provider) return { failure: {
831
+ name: MODULE_CHECK,
832
+ capability: "module",
833
+ passed: false,
834
+ error: loaded.unavailable ?? "the reference selected no provider"
835
+ } };
836
+ return {
837
+ ...options,
838
+ provider: loaded.provider
839
+ };
840
+ }
841
+ function cacheProviderContractChecks(options) {
842
+ return [...metroChecks({
843
+ provider: options.provider,
844
+ projectRoot: options.projectRoot,
845
+ ...options.cacheName ? { cacheName: options.cacheName } : {},
846
+ ...options.abortSettleMs ? { abortSettleMs: options.abortSettleMs } : {}
847
+ }), ...buildChecks({
848
+ provider: options.provider,
849
+ projectRoot: options.projectRoot,
850
+ workDir: options.workDir,
851
+ ...options.platform ? { platform: options.platform } : {},
852
+ ...options.abortSettleMs ? { abortSettleMs: options.abortSettleMs } : {}
853
+ })];
854
+ }
855
+ async function runCacheProviderContract(options) {
856
+ const resolved = "providerModule" in options ? await loadContractProvider(options) : options;
857
+ if ("failure" in resolved) return [resolved.failure];
858
+ const checkTimeoutMs = resolved.checkTimeoutMs ?? 3e4;
859
+ const results = [];
860
+ for (const check of cacheProviderContractChecks(resolved)) try {
861
+ await withDeadline(check.run(), checkTimeoutMs);
862
+ results.push({
863
+ name: check.name,
864
+ capability: check.capability,
865
+ passed: true
866
+ });
867
+ } catch (error) {
868
+ results.push({
869
+ name: check.name,
870
+ capability: check.capability,
871
+ passed: false,
872
+ error: String(error?.message || error)
873
+ });
874
+ }
875
+ return results;
876
+ }
877
+ async function withDeadline(work, timeoutMs) {
878
+ let timer;
879
+ const expired = new Promise((_resolve, reject) => {
880
+ timer = setTimeout(() => reject(/* @__PURE__ */ new Error(`the check did not finish within ${timeoutMs}ms`)), timeoutMs);
881
+ });
882
+ try {
883
+ await Promise.race([work, expired]);
884
+ } finally {
885
+ clearTimeout(timer);
886
+ }
887
+ }
888
+ //#endregion
889
+ export { BUILD_RESOLVE_TIMEOUT_ENV, BUILD_RESOLVE_TIMEOUT_MS, BUILD_UPLOAD_TIMEOUT_ENV, BUILD_UPLOAD_TIMEOUT_MS, CACHE_CONTRACT_CHECK_TIMEOUT_MS, CACHE_PROVIDER_API_VERSION, CACHE_PROVIDER_ENV, CACHE_PROVIDER_ENV_NONE, METRO_READ_CONCURRENCY, METRO_READ_FAILURE_LIMIT, METRO_READ_TIMEOUT_ENV, METRO_READ_TIMEOUT_MS, METRO_UPLOAD_CONCURRENCY, METRO_UPLOAD_MAX_BYTES, METRO_UPLOAD_MAX_ITEMS, METRO_WRITE_TIMEOUT_ENV, METRO_WRITE_TIMEOUT_MS, PROVIDER_LOAD_TIMEOUT_ENV, PROVIDER_LOAD_TIMEOUT_MS, buildResolveTimeoutMs, buildUploadTimeoutMs, cacheProviderConfigFromEnv, cacheProviderContractChecks, cacheProviderEnv, cacheProviderEnvIsSet, callWithTimeout, createTieredMetroStore, createWarnOnce, loadCacheProvider, metroCapabilityFromStore, resolveTieredBuild, runCacheProviderContract, storeTieredBuild, timeoutFromEnv };
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@stim-cli/cache",
3
+ "version": "1.0.0-rc.10",
4
+ "description": "Cache provider contract and tier coordination for Stim.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/appandflow/stim.git",
9
+ "directory": "packages/cache"
10
+ },
11
+ "files": [
12
+ "dist",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "type": "module",
17
+ "main": "dist/index.mjs",
18
+ "types": "dist/index.d.mts",
19
+ "exports": {
20
+ ".": {
21
+ "types": "./dist/index.d.mts",
22
+ "module-sync": "./dist/index.mjs",
23
+ "default": "./dist/index.mjs"
24
+ }
25
+ },
26
+ "engines": {
27
+ "node": "^20.19.4 || >=22.12.0"
28
+ },
29
+ "scripts": {
30
+ "build": "tsdown"
31
+ }
32
+ }