Skip to content
Closed
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
8 changes: 7 additions & 1 deletion bin/gstack-brain-cache
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,13 @@ function loadMeta(scope: 'cross-project' | 'per-project', projectSlug: string |
return { schema_version: GSTACK_SCHEMA_PACK_VERSION, endpoint_hash: detectEndpointHash(), last_refresh: {}, last_attempt: {} };
}
try {
return JSON.parse(readFileSync(path, 'utf-8')) as CacheMeta;
const parsed = JSON.parse(readFileSync(path, 'utf-8')) as CacheMeta;
// A valid-but-partial _meta.json (missing maps) must be normalized, not
// returned verbatim — downstream consumers (isStale/cmdGet, cmdInvalidate,
// refreshEntity) dereference these maps and would otherwise throw.
parsed.last_refresh = parsed.last_refresh || {};
parsed.last_attempt = parsed.last_attempt || {};
return parsed;
} catch {
// Corrupt _meta — start fresh (entries will refresh on next access).
return { schema_version: GSTACK_SCHEMA_PACK_VERSION, endpoint_hash: detectEndpointHash(), last_refresh: {}, last_attempt: {} };
Expand Down
35 changes: 35 additions & 0 deletions test/brain-cache-roundtrip.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,41 @@ describe('brain-cache meta lifecycle', () => {
expect(meta.last_refresh.product).toBeUndefined();
expect(existsSync(join(TMP_HOME, 'projects', 'helsinki', 'brain-cache', '_meta.json'))).toBe(true);
});

// A _meta.json can be valid JSON yet still lack the last_refresh/last_attempt
// maps (external tooling, a hand-edit, or any partial-but-valid persisted
// state). loadMeta already starts fresh on a missing/corrupt file, so a
// partial file must be normalized too — otherwise consumers that dereference
// these maps crash with a TypeError instead of degrading gracefully.
test('cmdGet does not crash when _meta.json lacks last_refresh', async () => {
const mod = await importCache();
const cacheDir = join(TMP_HOME, 'projects', 'helsinki', 'brain-cache');
mkdirSync(cacheDir, { recursive: true });
writeFileSync(join(cacheDir, 'product.md'), '# Product: helsinki\n');
// Matching schema + endpoint (so the schema/endpoint rebuild path is not
// taken), but no last_refresh key.
writeFileSync(join(cacheDir, '_meta.json'), JSON.stringify({
schema_version: '1.0.0',
endpoint_hash: mod.detectEndpointHash(),
}));
let result: ReturnType<typeof mod.cmdGet> | undefined;
expect(() => { result = mod.cmdGet('product', 'helsinki'); }).not.toThrow();
// Treated as never-refreshed: falls through to cold-refresh, which fails
// (brain unreachable) and degrades to stale-fallback since the file exists.
expect(['stale-fallback', 'cold-refreshed', 'missing']).toContain(result!.state);
});

test('cmdInvalidate is a safe no-op when _meta.json lacks last_refresh', async () => {
const mod = await importCache();
const cacheDir = join(TMP_HOME, 'projects', 'helsinki', 'brain-cache');
mkdirSync(cacheDir, { recursive: true });
writeFileSync(join(cacheDir, '_meta.json'), JSON.stringify({
schema_version: '1.0.0',
endpoint_hash: mod.detectEndpointHash(),
}));
expect(() => mod.cmdInvalidate('product', 'helsinki')).not.toThrow();
expect(mod.cmdMeta('helsinki').last_refresh.product).toBeUndefined();
});
});

describe('brain-cache endpoint detection', () => {
Expand Down
Loading