Skip to content

fix(api): registry-diff sinceDate cursor is silently ignored #524

@galuis116

Description

@galuis116

Goal

Make /api/registry/diff?since=<ISO-date> actually filter the returned entries to changes since the cursor, instead of silently returning the full changelog regardless of sinceDate. Preserve the existing "edited entries are not missed" promise by continuing to surface every updated / removed entry, since those carry no per-entry timestamp from which a date filter could honor them.

Why this matters

apps/web/src/app/api/registry/diff/route.ts:28-31 has a literally-dead ternary in the entries computation:

const entries =
  since && since === currentSignature
    ? []
    : sinceDate
      ? changelog.entries
      : changelog.entries;

Both branches of the inner sinceDate ? ... : ... return changelog.entries verbatim. sinceDate is parsed (line 22 via parseSinceDate), checked in the ternary, used to choose between two note strings (line 47-49), and then has zero effect on the response payload. A client calling /api/registry/diff?since=2026-01-01&limit=5 gets all 386 entries from the static snapshot, the most recent of which has dateAdded = 2026-05-19. The cursor is documented as a polling input — the e2e smoke test at tests/e2e/site-regression.spec.ts:95 literally passes ?since=2026-01-01&limit=5 expecting the route to behave as a diff endpoint.

The note ("Date cursors return the latest static snapshot so edited entries are not missed; use currentSignature for precise polling") makes a real point — the registry-changelog.json schema only records dateAdded per entry and a type: "added" | "updated" | "removed" discriminant, so we cannot safely filter updated / removed entries by date (we have no updatedAt / removedAt). But that does not justify returning entries with type === "added" && dateAdded < sinceDate. Those are unambiguously older than the cursor, and a polling operator who passes a recent since does not want to re-process 380 stale added rows on every cycle.

Current behavior

// apps/web/src/app/api/registry/diff/route.ts
export const GET = createApiHandler(
  "registry.diff",
  async ({ request, query: parsedQuery }) => {
    const { since, limit } = parsedQuery;
    const sinceDate = parseSinceDate(since);
    const changelog = await getRegistryChangelog();
    const currentSignature = changelog.signature ?? "";

    const entries =
      since && since === currentSignature
        ? []
        : sinceDate
          ? changelog.entries          // <-- dead branch
          : changelog.entries;         // <-- dead branch

    return cachedJsonResponse(request, {
      schemaVersion: 1,
      kind: "registry-diff",
      generatedAt: changelog.generatedAt,
      since: since || null,
      currentSignature,
      hasChanges: entries.length > 0,
      count: Math.min(entries.length, limit),
      totalAvailable: entries.length,
      note: /* ... */,
      entries: entries.slice(0, limit),
    }, /* ... */);
  },
);

/api/registry/diff?since=2026-05-15&limit=10 today returns 10 entries from the very top of the static snapshot, oldest possibly dated 2025-09-15 (the changelog spans Sept 2025 → May 2026).

Existing changelog entry shape (apps/web/src/lib/content.ts:192-209):

entries: Array<{
  key: string;
  type: "added" | "updated" | "removed";
  category: string;
  slug: string;
  title: string;
  dateAdded: string;
  canonicalUrl: string;
  artifactHash: string;
}>;

Desired behavior

  • When since is omitted or unparseable, return the full changelog as today.
  • When since matches currentSignature, return [] as today.
  • When since parses to a date, the response splits the changelog two ways:
    • type === "added" entries are filtered to Date.parse(dateAdded) >= sinceDate. Older added rows are dropped.
    • type === "updated" or type === "removed" entries are returned in full. (We cannot date-filter them; the existing "edited entries are not missed" guarantee holds.)
  • The two streams are merged in stable order (changelog order), deduped by key to preserve the existing one-row-per-key invariant the changelog generator already enforces.
  • The note text is unchanged in spirit — it should still explain that date cursors include all updated/removed rows so edited entries are not missed.
  • count, totalAvailable, and hasChanges reflect the filtered entries (not the raw changelog).

Scope

  • apps/web/src/app/api/registry/diff/route.ts — add 3 small private helpers, replace the dead ternary, leave the schema and the cachedJsonResponse envelope unchanged.
  • tests/registry-diff-api.test.ts (new) — vitest cases covering: no since, hash since matching currentSignature, hash since not matching, ISO date since newer than every added entry, ISO date since older than every added entry, ISO date since mid-range, ISO date since with updated/removed mixed in (those should always pass through), limit truncation applies after filtering, malformed since falls back to the no-filter path.

Out of scope

  • The since accepted values (registryDiffQuerySchema.since already accepts any trimmed string up to 128 chars; that schema is unchanged).
  • The cache headers on the response (public, max-age=60, stale-while-revalidate=600).
  • The currentSignature polling pattern — that remains the recommended precise cursor; date filtering is for the human-friendly polling fallback the route already supports.
  • The changelog generator (apps/web/public/data/registry-changelog.json is a maintainer-built artifact; this PR only changes how the route consumes it).
  • Any other API route, OpenAPI schema fields, or tests/api-contracts.test.ts (the route remains advertised under /api/registry/diff with the same query shape).

Acceptance criteria

  • PR includes Closes #<issue>.
  • A vitest case with ?since=2026-01-01&limit=5 against a fixture that has both old-added, recent-added, and updated/removed entries returns only: (a) recent-added rows (dateAdded >= 2026-01-01) and (b) every updated/removed row, in stable changelog order, deduped by key.
  • A vitest case with the existing e2e shape ?since=2026-01-01&limit=5 still returns response.ok === true so tests/e2e/site-regression.spec.ts:95 keeps passing.
  • A vitest case with ?since=<currentSignature> continues to return count: 0, hasChanges: false exactly as today.
  • A vitest case with no since returns the full changelog as today.
  • pnpm exec vitest run tests/registry-diff-api.test.ts tests/api-contracts.test.ts tests/api-router-security.test.ts passes.

Quality evidence required in the PR

  • No visual impact (API-only fix; no UI / page changes).
  • PR description should list the three new private helpers + a one-line "what changed in the entries computation" summary.
  • Before/after for one date-cursor call: before returns 386 entries (5 visible after limit=5), after returns N entries where N is added since the cursor + all updated/removed. Concrete numbers from the production registry-changelog.json snapshot in the PR body.
  • Backward-compatibility note: response shape unchanged. The set of clients that actually pay attention to since get a smaller, correct payload; clients that ignore since see no difference.

Validation

pnpm install
pnpm exec vitest run tests/registry-diff-api.test.ts tests/api-contracts.test.ts tests/api-router-security.test.ts
pnpm type-check
pnpm validate:openapi
git diff --check

Metadata

Metadata

Assignees

No one assigned

    Labels

    help wantedExtra attention is neededrisk-highAutomated submission security/safety review found high-risk or critical signalsuser-facing

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions