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
Goal
Make
/api/registry/diff?since=<ISO-date>actually filter the returnedentriesto changes since the cursor, instead of silently returning the full changelog regardless ofsinceDate. Preserve the existing "edited entries are not missed" promise by continuing to surface everyupdated/removedentry, 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-31has a literally-dead ternary in theentriescomputation:Both branches of the inner
sinceDate ? ... : ...returnchangelog.entriesverbatim.sinceDateis parsed (line 22 viaparseSinceDate), checked in the ternary, used to choose between twonotestrings (line 47-49), and then has zero effect on the response payload. A client calling/api/registry/diff?since=2026-01-01&limit=5gets all 386 entries from the static snapshot, the most recent of which hasdateAdded = 2026-05-19. The cursor is documented as a polling input — the e2e smoke test attests/e2e/site-regression.spec.ts:95literally passes?since=2026-01-01&limit=5expecting 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.jsonschema only recordsdateAddedper entry and atype: "added" | "updated" | "removed"discriminant, so we cannot safely filterupdated/removedentries by date (we have noupdatedAt/removedAt). But that does not justify returning entries withtype === "added" && dateAdded < sinceDate. Those are unambiguously older than the cursor, and a polling operator who passes a recentsincedoes not want to re-process 380 staleaddedrows on every cycle.Current behavior
/api/registry/diff?since=2026-05-15&limit=10today returns 10 entries from the very top of the static snapshot, oldest possibly dated2025-09-15(the changelog spans Sept 2025 → May 2026).Existing changelog entry shape (
apps/web/src/lib/content.ts:192-209):Desired behavior
sinceis omitted or unparseable, return the full changelog as today.sincematchescurrentSignature, return[]as today.sinceparses to a date, the response splits the changelog two ways:type === "added"entries are filtered toDate.parse(dateAdded) >= sinceDate. Olderaddedrows are dropped.type === "updated"ortype === "removed"entries are returned in full. (We cannot date-filter them; the existing "edited entries are not missed" guarantee holds.)keyto preserve the existing one-row-per-key invariant the changelog generator already enforces.notetext is unchanged in spirit — it should still explain that date cursors include allupdated/removedrows so edited entries are not missed.count,totalAvailable, andhasChangesreflect 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 thecachedJsonResponseenvelope unchanged.tests/registry-diff-api.test.ts(new) — vitest cases covering: nosince, hashsincematchingcurrentSignature, hashsincenot matching, ISO datesincenewer than everyaddedentry, ISO datesinceolder than everyaddedentry, ISO datesincemid-range, ISO datesincewithupdated/removedmixed in (those should always pass through),limittruncation applies after filtering, malformedsincefalls back to the no-filter path.Out of scope
sinceaccepted values (registryDiffQuerySchema.sincealready accepts any trimmed string up to 128 chars; that schema is unchanged).public, max-age=60, stale-while-revalidate=600).currentSignaturepolling pattern — that remains the recommended precise cursor; date filtering is for the human-friendly polling fallback the route already supports.apps/web/public/data/registry-changelog.jsonis a maintainer-built artifact; this PR only changes how the route consumes it).tests/api-contracts.test.ts(the route remains advertised under/api/registry/diffwith the same query shape).Acceptance criteria
Closes #<issue>.?since=2026-01-01&limit=5against a fixture that has both old-added, recent-added, andupdated/removedentries returns only: (a) recent-addedrows (dateAdded >= 2026-01-01) and (b) everyupdated/removedrow, in stable changelog order, deduped bykey.?since=2026-01-01&limit=5still returnsresponse.ok === truesotests/e2e/site-regression.spec.ts:95keeps passing.?since=<currentSignature>continues to returncount: 0, hasChanges: falseexactly as today.sincereturns 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.tspasses.Quality evidence required in the PR
limit=5), after returns N entries where N isadded since the cursor+ allupdated/removed. Concrete numbers from the productionregistry-changelog.jsonsnapshot in the PR body.sinceget a smaller, correct payload; clients that ignoresincesee no difference.Validation