Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ The primitives [`stim-cli`](https://www.npmjs.com/package/stim-cli),
must agree on, implemented once: where the stim-cli config dir and the two
shared caches live (env override > machine config > default), how a build
cache key is derived, and how a cache registers itself for `stim-cli gc`.
Metro cache overrides name a parent directory; the sanitized app name is always
appended beneath it, just as it is beneath the default root.

This is an internal dependency of those packages, not a user-facing API --
install one of them instead. It is ESM-only and has no dependencies. The
Expand Down
33 changes: 28 additions & 5 deletions packages/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,9 +153,8 @@ export function buildCacheRoot(): string {
}

export function metroCacheRoot(name?: string | null): string {
const override = process.env.STIM_CLI_METRO_CACHE || cachePathSetting('metroCache');
if (override) return override;
const root = path.join(configDir(), 'metro-cache');
const root =
process.env.STIM_CLI_METRO_CACHE || cachePathSetting('metroCache') || path.join(configDir(), 'metro-cache');
return name === undefined || name === null || name === '' ? root : path.join(root, cacheNameSegment(name));
}

Expand Down Expand Up @@ -221,8 +220,20 @@ export interface RegisterOptions {
prune: string;
note: string;
entriesDepth?: number;
layout?: string;
replaces?: CacheRegistrationMatch[];
}

export interface CacheRegistrationMatch {
dir: string;
name?: string;
prune?: string;
entriesDepth?: number;
layout?: string | null;
}

export const METRO_NAMED_CACHE_LAYOUT = 'metro-named-v1';

export interface CacheManifest {
version: number;
caches: Array<Record<string, unknown>>;
Expand Down Expand Up @@ -264,14 +275,26 @@ export function updateCacheManifest(
);
}

export function registerCache({ dir, name, prune, note, entriesDepth }: RegisterOptions): void {
export function registerCache({ dir, name, prune, note, entriesDepth, layout, replaces = [] }: RegisterOptions): void {
try {
updateCacheManifest(path.join(configDir(), 'caches.json'), (caches) => {
const others = caches.filter((cache) => cache.dir !== dir);
const others = caches.filter(
(cache) => cache.dir !== dir && !replaces.some((match) => matchesCache(cache, match)),
);
const record: Record<string, unknown> = { dir, name, prune, note, registeredBy: process.cwd() };
if (entriesDepth) record.entriesDepth = entriesDepth;
if (layout) record.layout = layout;
others.push(record);
return others;
});
} catch {}
}

function matchesCache(cache: Record<string, unknown>, match: CacheRegistrationMatch): boolean {
if (cache.dir !== match.dir) return false;
if (match.name !== undefined && cache.name !== match.name) return false;
if (match.prune !== undefined && cache.prune !== match.prune) return false;
if (match.entriesDepth !== undefined && cache.entriesDepth !== match.entriesDepth) return false;
if (match.layout === null) return !Object.hasOwn(cache, 'layout');
return match.layout === undefined || cache.layout === match.layout;
}
15 changes: 14 additions & 1 deletion packages/metro/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,20 @@ only the entries nothing has touched, not the whole cache.
stim-cli is an optional peer. Without it the cache works exactly the same; it is
just invisible to housekeeping.

The location can be overridden by `STIM_CLI_METRO_CACHE`, or machine-wide by `caches.metroCache` in `~/.stim-cli/config.json` (an absolute path; the env var wins). The CLI and this package resolve both identically, so they always share one store.
The parent location can be overridden by `STIM_CLI_METRO_CACHE`, or
machine-wide by `caches.metroCache` in `~/.stim-cli/config.json` (an absolute
path; the env var wins). The sanitized name passed to `sharedCacheStores` is
always appended below that parent, so `sharedCacheStores('@scope/app')` uses
`<parent>/-scope-app`. The CLI and this package resolve both identically.

Earlier versions wrote a named store directly into an overridden parent. A new
registration marks the named layout and replaces an exact unmarked legacy
parent entry. If an older package registers the parent again later, current
`stim-cli gc` ignores that provably legacy entry while a marked child is
registered, so it cannot prune the child at the wrong depth. A marked named
store that later becomes another override parent remains visible but report-only
while its marked child exists. The old root-level cache files are left untouched
for manual cleanup.

## The NDJSON log reporter

Expand Down
37 changes: 34 additions & 3 deletions packages/metro/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import fs from 'node:fs';
import { createRequire } from 'node:module';
import path from 'node:path';
import { metroCacheRoot, registerCache, tagSharedStore, workspaceLogDir } from '@stim-cli/core';
import {
metroCacheRoot,
METRO_NAMED_CACHE_LAYOUT,
registerCache,
tagSharedStore,
workspaceLogDir,
} from '@stim-cli/core';

type FileStoreCtor = new (options: { root: string }) => object;

Expand Down Expand Up @@ -38,21 +44,46 @@ export function cacheRoot(name?: string | null): string {
return metroCacheRoot(name);
}

function registerOnce(dir: string): void {
function registerOnce(
dir: string,
replaces: Array<{
dir: string;
name: string;
prune: string;
entriesDepth: number;
layout: null;
}> = [],
): void {
registerCache({
dir,
name: 'Metro transform cache',
prune: 'entries',
entriesDepth: 2,
layout: METRO_NAMED_CACHE_LAYOUT,
note: 'shared Metro transforms; no eviction of its own',
replaces,
});
}

export function sharedCacheStores(name = 'app', { FileStore }: { FileStore?: FileStoreCtor } = {}): object[] {
// metro-cache is a peer dependency that resolves at call time.
const Store: FileStoreCtor = FileStore || (requireFromHere('metro-cache') as { FileStore: FileStoreCtor }).FileStore;
const parent = cacheRoot();
const root = cacheRoot(name);
registerOnce(root);
registerOnce(
root,
root === parent
? []
: [
{
dir: parent,
name: 'Metro transform cache',
prune: 'entries',
entriesDepth: 2,
layout: null,
},
],
);
return [tagSharedStore(new Store({ root }), root)];
}

Expand Down
2 changes: 2 additions & 0 deletions packages/stim-cli/skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@ Also normal: `npx` may re-download stim-cli on every invocation (`npm warn exec

These caches can grow substantially. Every `npx stim-cli gc` run reports what they have grown to -- each one tagged _registered_ or _detected_ -- and `gc --delete --older-than <days>` trims the entries nothing has used. Trim rather than empty: `gc --delete --all` empties them whole -- the only way to clear an index-backed cache like Xcode's CAS -- and costs the next build in every project the time the cache was saving. The Gradle build cache under `GRADLE_USER_HOME` (default `~/.gradle`) has Gradle's own retention policy and is report-only to stim-cli: stim-cli reports its size because `--build-cache` contributes to it, but never prunes or empties that directory under any `gc` flags because every Gradle build on the machine shares it.

`STIM_CLI_METRO_CACHE` and the machine-level `caches.metroCache` setting name a parent directory, not one flat Metro store. stim-cli appends the sanitized package name beneath that parent so each app stays independently reportable and prunable. A new registration marks the named layout and replaces an exact unmarked legacy parent entry. If an older cache package registers the parent again later, current `stim-cli gc` ignores that provably legacy entry while a marked child exists. A marked store that later becomes another override parent remains visible but report-only while its marked child exists. Root-level legacy files remain untouched for manual cleanup.

## Runtime workspace and fingerprint dependencies

Runtime state is stored outside the project tree under
Expand Down
56 changes: 49 additions & 7 deletions packages/stim-cli/src/__tests__/cache-packages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import assert from 'node:assert';
import { METRO_NAMED_CACHE_LAYOUT } from '@stim-cli/core';
import { readManifest } from '../cache-manifest.ts';
import { sharedBuildCache, sharedMetroCache } from '../paths.ts';
import { hasStoreAt } from '../supervisor/metro-store.ts';
Expand Down Expand Up @@ -46,9 +47,28 @@ test('the Expo build cache provider registers itself on this Node, at the right
test('the Metro cache store registers itself on this Node, at the shard depth', async () => {
const home = mkdtempSync(join(tmpdir(), 'stim-cli-pkg-home2-'));
const cacheRoot = join(tmpdir(), `stim-cli-pkg-metro-${process.pid}`);
const namedRoot = join(cacheRoot, 'demo');
mkdirSync(cacheRoot, { recursive: true });
process.env.STIM_CLI_HOME = home;
process.env.STIM_CLI_METRO_CACHE = cacheRoot;
writeFileSync(
join(home, 'caches.json'),
JSON.stringify({
version: 1,
caches: [
{ dir: cacheRoot, name: 'Metro transform cache', prune: 'entries', entriesDepth: 2 },
{
dir: cacheRoot,
name: 'Metro transform cache',
prune: 'entries',
entriesDepth: 2,
layout: METRO_NAMED_CACHE_LAYOUT,
},
{ dir: cacheRoot, name: 'Unrelated same-root cache', prune: 'entries' },
{ dir: join(home, 'unrelated'), name: 'Unrelated cache', prune: 'entries' },
],
}),
);
try {
const { sharedCacheStores } = await import('@stim-cli/metro');
class FakeStore {
Expand All @@ -58,14 +78,25 @@ test('the Metro cache store registers itself on this Node, at the shard depth',
}
}
const stores = sharedCacheStores('demo', { FileStore: FakeStore });
expect((stores[0] as { root: string }).root).toBe(cacheRoot);
expect(hasStoreAt(stores, cacheRoot)).toBe(true);
expect((stores[0] as { root: string }).root).toBe(namedRoot);
expect(hasStoreAt(stores, namedRoot)).toBe(true);

const record = await waitForRegistration(cacheRoot);
const record = await waitForRegistration(namedRoot);
expect(record).toBeTruthy();
assert(record);
expect(record.entriesDepth).toBe(2);
expect(record.prune).toBe('entries');
expect(record.layout).toBe(METRO_NAMED_CACHE_LAYOUT);
expect(
readManifest().caches.some(
(cache) => cache.dir === cacheRoot && cache.name === 'Metro transform cache' && cache.layout === undefined,
),
).toBe(false);
expect(
readManifest().caches.some((cache) => cache.dir === cacheRoot && cache.layout === METRO_NAMED_CACHE_LAYOUT),
).toBe(true);
expect(readManifest().caches.some((cache) => cache.name === 'Unrelated same-root cache')).toBe(true);
expect(readManifest().caches.some((cache) => cache.dir === join(home, 'unrelated'))).toBe(true);
} finally {
rmSync(home, { recursive: true, force: true });
rmSync(cacheRoot, { recursive: true, force: true });
Expand Down Expand Up @@ -105,17 +136,28 @@ test('both packages resolve the same cache roots the CLI does', async () => {
);
expect(provider.cacheRoot()).toBe(sharedBuildCache());
expect(provider.cacheRoot()).toBe(join(home, 'cfg-build'));
expect(metro.cacheRoot()).toBe(join(home, 'cfg-metro'));
expect(metro.cacheRoot('demo')).toBe(sharedMetroCache('demo'));
expect(metro.cacheRoot('demo')).toBe(join(home, 'cfg-metro'));
writeFileSync(join(home, 'config.json'), JSON.stringify({ caches: { buildCache: 'relative/nope' } }));
expect(provider.cacheRoot()).toBe(join(home, 'build-cache'));
expect(metro.cacheRoot('demo')).toBe(join(home, 'cfg-metro', 'demo'));
expect(metro.cacheRoot('@scope/app')).toBe(join(home, 'cfg-metro', '-scope-app'));

process.env.STIM_CLI_BUILD_CACHE = join(home, 'elsewhere-build');
process.env.STIM_CLI_METRO_CACHE = join(home, 'elsewhere-metro');
expect(provider.cacheRoot()).toBe(sharedBuildCache());
expect(provider.cacheRoot()).toBe(join(home, 'elsewhere-build'));
expect(metro.cacheRoot()).toBe(join(home, 'elsewhere-metro'));
expect(metro.cacheRoot('demo')).toBe(sharedMetroCache('demo'));
expect(metro.cacheRoot('demo')).toBe(join(home, 'elsewhere-metro'));
expect(metro.cacheRoot('demo')).toBe(join(home, 'elsewhere-metro', 'demo'));

delete process.env.STIM_CLI_BUILD_CACHE;
delete process.env.STIM_CLI_METRO_CACHE;
writeFileSync(
join(home, 'config.json'),
JSON.stringify({ caches: { buildCache: 'relative/nope', metroCache: 'relative/nope' } }),
);
expect(provider.cacheRoot()).toBe(join(home, 'build-cache'));
expect(metro.cacheRoot()).toBe(join(home, 'metro-cache'));
expect(metro.cacheRoot('demo')).toBe(join(home, 'metro-cache', 'demo'));
} finally {
rmSync(home, { recursive: true, force: true });
delete process.env.STIM_CLI_HOME;
Expand Down
72 changes: 72 additions & 0 deletions packages/stim-cli/src/__tests__/caches.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { register } from '../cache-manifest.ts';
import { makeCacheDescriptor } from './_factories.ts';
import { setProjectSetting, upsertProject } from '../config.ts';
import assert from 'node:assert';
import { METRO_NAMED_CACHE_LAYOUT } from '@stim-cli/core';

const LONG_AGO = new Date(Date.now() - 90 * 24 * 3600 * 1000);

Expand Down Expand Up @@ -350,3 +351,74 @@ test('a declared path that only differs in spelling dedups against the registrat
rmSync(dir, { recursive: true, force: true });
}
});

test('a named Metro store suppresses only its known legacy parent registration', () => {
const ancestor = join(tmpHome, 'cache-owner');
const parent = join(ancestor, 'metro');
const child = join(parent, 'demo');
mkdirSync(child, { recursive: true });
writeFileSync(
join(tmpHome, 'caches.json'),
JSON.stringify({
version: 1,
caches: [
{
dir: child,
name: 'Metro transform cache',
prune: 'entries',
entriesDepth: 2,
layout: METRO_NAMED_CACHE_LAYOUT,
},
{ dir: parent, name: 'Metro transform cache', prune: 'entries', entriesDepth: 2 },
{ dir: parent, name: 'Unrelated same-root cache', prune: 'entries', entriesDepth: 1 },
{ dir: ancestor, name: 'Unrelated ancestor cache', prune: 'entries', entriesDepth: 1 },
],
}),
);

const caches = discoverCaches();

expect(caches.some((cache) => cache.dir === parent && cache.name === 'Metro transform cache')).toBe(false);
expect(caches.some((cache) => cache.dir === child && cache.name === 'Metro transform cache')).toBe(true);
expect(caches.some((cache) => cache.dir === parent && cache.name === 'Unrelated same-root cache')).toBe(true);
expect(caches.some((cache) => cache.dir === ancestor && cache.name === 'Unrelated ancestor cache')).toBe(true);
});

test('current nested Metro stores preserve the parent as report-only and unmarked children prove no migration', () => {
const currentParent = join(tmpHome, 'current');
const currentChild = join(currentParent, 'child');
const unmarkedParent = join(tmpHome, 'unmarked');
const unmarkedChild = join(unmarkedParent, 'child');
for (const dir of [currentChild, unmarkedChild]) mkdirSync(dir, { recursive: true });
writeFileSync(
join(tmpHome, 'caches.json'),
JSON.stringify({
version: 1,
caches: [
{
dir: currentParent,
name: 'Metro transform cache',
prune: 'entries',
entriesDepth: 2,
layout: METRO_NAMED_CACHE_LAYOUT,
},
{
dir: currentChild,
name: 'Metro transform cache',
prune: 'entries',
entriesDepth: 2,
layout: METRO_NAMED_CACHE_LAYOUT,
},
{ dir: unmarkedParent, name: 'Metro transform cache', prune: 'entries', entriesDepth: 2 },
{ dir: unmarkedChild, name: 'Metro transform cache', prune: 'entries', entriesDepth: 2 },
],
}),
);

const caches = discoverCaches();

expect(caches.find((cache) => cache.dir === currentParent)?.prune).toBe('report-only');
expect(caches.find((cache) => cache.dir === currentChild)?.prune).toBe('entries');
expect(caches.some((cache) => cache.dir === unmarkedParent)).toBe(true);
expect(caches.some((cache) => cache.dir === unmarkedChild)).toBe(true);
});
Loading
Loading