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
5 changes: 5 additions & 0 deletions apps/dashboard/drizzle/0003_github_cache_namespace.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
CREATE TABLE `github_cache_namespace` (
`namespace_key` text PRIMARY KEY NOT NULL,
`version` integer NOT NULL,
`updated_at` integer NOT NULL
);
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@ import type {
PullDetail,
PullStatus,
} from "#/lib/github.types";
import { githubCachePolicy } from "#/lib/github-cache-policy";
import { checkPermissionWarning } from "#/lib/warning-store";

export function PullDetailActivitySection({
Expand Down Expand Up @@ -149,7 +148,6 @@ function MergeStatusSection({
const statusQuery = useQuery({
...githubPullStatusQueryOptions(scope, { owner, repo, pullNumber }),
refetchOnWindowFocus: "always",
refetchInterval: githubCachePolicy.status.staleTimeMs,
});

const status = statusQuery.data ?? null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ export function PullDetailPage() {
...githubPullPageQueryOptions(scope, { owner, repo, pullNumber }),
enabled: hasMounted,
});

const viewerQuery = useQuery({
...githubViewerQueryOptions(scope),
enabled: hasMounted,
Expand Down
6 changes: 6 additions & 0 deletions apps/dashboard/src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,9 @@ export const githubRevalidationSignal = sqliteTable(
updatedAt: integer("updated_at").notNull(),
},
);

export const githubCacheNamespace = sqliteTable("github_cache_namespace", {
namespaceKey: text("namespace_key").primaryKey(),
version: integer("version").notNull(),
updatedAt: integer("updated_at").notNull(),
});
13 changes: 13 additions & 0 deletions apps/dashboard/src/env.d.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,15 @@
// Env types are generated by `wrangler types` in worker-configuration.d.ts
// The cloudflare:workers module exports env: Cloudflare.Env from worker-configuration.d.ts

declare namespace Cloudflare {
interface Env {
GITHUB_APP_CLIENT_ID?: string;
GITHUB_APP_CLIENT_SECRET?: string;
GITHUB_APP_SLUG?: string;
GITHUB_WEBHOOK_SECRET?: string;
GITHUB_CLIENT_ID?: string;
GITHUB_CLIENT_SECRET?: string;
BETTER_AUTH_SECRET: string;
BETTER_AUTH_URL: string;
}
}
145 changes: 142 additions & 3 deletions apps/dashboard/src/lib/github-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
type GitHubCacheStore,
type GitHubCacheStoreEntry,
type GitHubFetchResult,
type GitHubPayloadCacheStore,
getOrRevalidateGitHubResource,
} from "./github-cache";

Expand Down Expand Up @@ -157,9 +158,9 @@ describe("getOrRevalidateGitHubResource", () => {
fetcher,
});

await Promise.resolve();

expect(fetcher).toHaveBeenCalledTimes(1);
await vi.waitFor(() => {
expect(fetcher).toHaveBeenCalledTimes(1);
});

resolveFetch?.({
kind: "success",
Expand Down Expand Up @@ -254,4 +255,142 @@ describe("getOrRevalidateGitHubResource", () => {
expect(result).toEqual({ title: "New title" });
expect(fetcher).toHaveBeenCalledTimes(1);
});

it("returns a fresh split-cache payload from the payload store", async () => {
const get = vi.fn(async () => buildEntry());
const put = vi.fn(async () => undefined);
const payloadStore = { get, put } satisfies GitHubPayloadCacheStore;
const fetcher =
vi.fn<
(parameters: {
etag?: string | null;
lastModified?: string | null;
}) => Promise<GitHubFetchResult<{ login: string }>>
>();

const result = await getOrRevalidateGitHubResource({
userId: "user-1",
resource: "viewer",
freshForMs: 60_000,
cacheMode: "split",
namespaceKeys: ["viewer"],
payloadStore,
getNamespaceVersions: async () => ({ viewer: 3 }),
now: () => 150,
fetcher,
});

expect(result).toEqual({ login: "adn" });
expect(fetcher).not.toHaveBeenCalled();
expect(get).toHaveBeenCalledTimes(1);
});

it("hydrates the split payload store from a fresh legacy entry on KV miss", async () => {
const store = createMemoryStore([buildEntry()]);
const storedPayloadEntries = new Map<string, GitHubCacheStoreEntry>();
const get = vi.fn(async () => null);
const put = vi.fn(
async (storageKey: string, entry: GitHubCacheStoreEntry) => {
storedPayloadEntries.set(storageKey, structuredClone(entry));
},
);
const payloadStore = { get, put } satisfies GitHubPayloadCacheStore;
const fetcher =
vi.fn<
(parameters: {
etag?: string | null;
lastModified?: string | null;
}) => Promise<GitHubFetchResult<{ login: string }>>
>();

const result = await getOrRevalidateGitHubResource({
userId: "user-1",
resource: "viewer",
freshForMs: 60_000,
cacheMode: "split",
namespaceKeys: ["viewer"],
store,
payloadStore,
getNamespaceVersions: async () => ({ viewer: 0 }),
now: () => 150,
fetcher,
});

expect(result).toEqual({ login: "adn" });
expect(fetcher).not.toHaveBeenCalled();
expect(get).toHaveBeenCalledTimes(1);
expect(put).toHaveBeenCalledTimes(1);
expect(storedPayloadEntries.size).toBe(1);
expect(Array.from(storedPayloadEntries.values())).toEqual([buildEntry()]);
});

it("extends freshness when GitHub budget is low", async () => {
const store = createMemoryStore([
buildEntry({
freshUntil: 50,
}),
]);
const fetcher = vi.fn<
(parameters: {
etag?: string | null;
lastModified?: string | null;
}) => Promise<GitHubFetchResult<{ login: string }>>
>(async () => ({
kind: "success",
data: { login: "adn" },
metadata: createGitHubResponseMetadata(200, {
etag: '"next"',
"x-ratelimit-remaining": "10",
"x-ratelimit-reset": "0",
}),
}));

const result = await getOrRevalidateGitHubResource({
userId: "user-1",
resource: "viewer",
freshForMs: 15_000,
store,
now: () => 500,
fetcher,
});

expect(result).toEqual({ login: "adn" });

const updatedEntry = await store.get("user-1::viewer::null");
expect(updatedEntry?.freshUntil).toBe(300_500);
expect(updatedEntry?.rateLimitRemaining).toBe(10);
});

it("serves stale cache when GitHub is rate limited", async () => {
const store = createMemoryStore([
buildEntry({
freshUntil: 50,
}),
]);

const result = await getOrRevalidateGitHubResource({
userId: "user-1",
resource: "viewer",
freshForMs: 1_000,
store,
now: () => 500,
fetcher: vi.fn(async () => {
throw {
status: 403,
response: {
headers: {
"x-ratelimit-remaining": "0",
"x-ratelimit-reset": "2",
},
},
};
}),
});

expect(result).toEqual({ login: "adn" });

const updatedEntry = await store.get("user-1::viewer::null");
expect(updatedEntry?.freshUntil).toBe(60_500);
expect(updatedEntry?.statusCode).toBe(403);
});
});
Loading
Loading