Skip to content

gstack-brain-cache: cmdGet/cmdInvalidate crash on a valid _meta.json that lacks last_refresh #1879

@jbetala7

Description

@jbetala7

Observed problem

bin/gstack-brain-cache crashes with a TypeError when its _meta.json is valid JSON but is missing the last_refresh map. cmdGet and cmdInvalidate both throw instead of degrading gracefully.

loadMeta already defends against a missing file and against corrupt (unparseable) JSON — both return a fresh, fully-formed meta. But when the file parses successfully yet lacks last_refresh, loadMeta returns it verbatim:

function loadMeta(...): CacheMeta {
  const path = metaPath(scope, projectSlug);
  if (!existsSync(path)) { return { ...fresh with last_refresh: {} }; }
  try {
    return JSON.parse(readFileSync(path, 'utf-8')) as CacheMeta;   // <- returned verbatim, not normalized
  } catch {
    return { ...fresh with last_refresh: {} };
  }
}

Downstream, refreshEntity guards its sibling map (meta.last_attempt = meta.last_attempt || {}) but three consumers dereference meta.last_refresh unguarded:

  • isStale (reached via cmdGet's warm-path check): const last = meta.last_refresh[entityName];
  • cmdInvalidate: delete meta.last_refresh[entityName];
  • refreshEntity: meta.last_refresh[entityName] = Date.now();

The asymmetry is visible in the type itself: last_attempt? is optional (hence the || {} guard) while last_refresh is typed as required — so the guard was only applied to one of the two maps.

Current behavior on upstream main

With a _meta.json whose schema_version matches the current pack and whose endpoint_hash matches (so the schema/endpoint rebuild path is not taken), but with no last_refresh key:

  • cmdGet('product', 'helsinki') -> TypeError: undefined is not an object (evaluating 'meta.last_refresh[entityName]')
  • cmdInvalidate('product', 'helsinki') -> TypeError: undefined is not an object (evaluating 'delete meta.last_refresh[entityName]')

Reproduced against main with a 2-test bun test. Such a meta can arise from external tooling, a hand-edit, or any valid-but-partial persisted state — exactly the class of input the corrupt-JSON branch was added to survive.

Expected behavior

A valid-but-partial _meta.json should be treated like the corrupt-JSON case: normalized to a usable meta so cmdGet falls through to a normal cold-refresh/stale path and cmdInvalidate is a safe no-op. Neither should throw.

Duplicate searches performed

Candidate fix shape

Normalize once in loadMeta so all consumers receive a complete meta:

const parsed = JSON.parse(readFileSync(path, 'utf-8')) as CacheMeta;
parsed.last_refresh = parsed.last_refresh || {};
parsed.last_attempt = parsed.last_attempt || {};
return parsed;

Plus a regression test that seeds a _meta.json without last_refresh and asserts cmdGet/cmdInvalidate do not throw.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions