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
32 changes: 32 additions & 0 deletions db/kv-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { env } from "cloudflare:workers";

function getKV(): KVNamespace {
return env.KV;
}

export async function kvCached<T>(
key: string,
fn: () => Promise<T>,
): Promise<T> {
const kv = getKV();
const cached = await kv.get(key, "json");
if (cached !== null) return cached as T;

const result = await fn();
// Fire-and-forget write — don't block the response
kv.put(key, JSON.stringify(result)).catch(() => {});
return result;
}

export function cacheKey(
name: string,
params?: Record<string, unknown>,
): string {
if (!params || Object.keys(params).length === 0) return name;
const sorted = Object.entries(params)
.filter(([, v]) => v !== undefined)
.sort(([a], [b]) => a.localeCompare(b))
.map(([k, v]) => `${k}=${Array.isArray(v) ? v.join(",") : v}`)
.join(":");
return `${name}:${sorted}`;
}
74 changes: 69 additions & 5 deletions scripts/cache-purge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ import "dotenv/config";

const zoneId = process.env.CF_ZONE_ID;
const apiToken = process.env.CF_API_TOKEN;
const kvNamespaceId = process.env.CF_KV_NAMESPACE_ID;

if (!zoneId || !apiToken) {
console.error("Missing CF_ZONE_ID or CF_API_TOKEN environment variables");
process.exit(1);
}

const response = await fetch(
// Purge Cloudflare edge cache
const cacheResponse = await fetch(
`https://api.cloudflare.com/client/v4/zones/${zoneId}/purge_cache`,
{
method: "POST",
Expand All @@ -20,11 +22,73 @@ const response = await fetch(
},
);

const result = await response.json();
const cacheResult = await cacheResponse.json();

if (result.success) {
console.log("Cache purged successfully");
if (cacheResult.success) {
console.log("Edge cache purged");
} else {
console.error("Cache purge failed:", result.errors);
console.error("Edge cache purge failed:", cacheResult.errors);
process.exit(1);
}

// Purge KV namespace
if (!kvNamespaceId) {
console.log("No CF_KV_NAMESPACE_ID set, skipping KV purge");
} else {
const accountId = process.env.CF_ACCOUNT_ID;
if (!accountId) {
console.error("Missing CF_ACCOUNT_ID for KV purge");
process.exit(1);
}

// List all keys
let cursor: string | undefined;
const allKeys: string[] = [];

do {
const params = new URLSearchParams();
if (cursor) params.set("cursor", cursor);

const listRes = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${accountId}/storage/kv/namespaces/${kvNamespaceId}/keys?${params}`,
{ headers: { Authorization: `Bearer ${apiToken}` } },
);

const listData: any = await listRes.json();
if (!listData.success) {
console.error("KV list failed:", listData.errors);
process.exit(1);
}

allKeys.push(...listData.result.map((k: { name: string }) => k.name));
cursor = listData.result_info?.cursor;
} while (cursor);

if (allKeys.length === 0) {
console.log("KV namespace empty, nothing to purge");
} else {
// Bulk delete (max 10,000 per request)
for (let i = 0; i < allKeys.length; i += 10000) {
const batch = allKeys.slice(i, i + 10000);
const delRes = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${accountId}/storage/kv/namespaces/${kvNamespaceId}/bulk`,
{
method: "DELETE",
headers: {
Authorization: `Bearer ${apiToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(batch),
},
);

const delData: any = await delRes.json();
if (!delData.success) {
console.error("KV bulk delete failed:", delData.errors);
process.exit(1);
}
}

console.log(`KV purged: ${allKeys.length} keys deleted`);
}
}
123 changes: 87 additions & 36 deletions src/lib/server-fns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createServerFn } from "@tanstack/react-start";
import { nanoid } from "nanoid";
import { getDb, getTursoClient } from "../../db/client";
import { embedText } from "../../db/embed";
import { cacheKey, kvCached } from "../../db/kv-cache";
import {
getAppAlternatives,
getAppBySlug,
Expand Down Expand Up @@ -34,37 +35,53 @@ export const fetchApps = createServerFn({ method: "GET" })
)
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Drizzle's AppSourceMetadata (Record<string, unknown>) doesn't satisfy TanStack's serialization check
.handler(async ({ data }): Promise<any> => {
const db = getDb();
return listApps(db, data);
return kvCached(cacheKey("listApps", data), () => {
const db = getDb();
return listApps(db, data);
});
});

export const fetchAppBySlug = createServerFn({ method: "GET" })
.inputValidator((input: { slug: string }) => input)
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Drizzle's AppSourceMetadata (Record<string, unknown>) doesn't satisfy TanStack's serialization check
.handler(async ({ data }): Promise<any> => {
const db = getDb();
return getAppBySlug(db, data.slug);
return kvCached(cacheKey("getAppBySlug", { slug: data.slug }), () => {
const db = getDb();
return getAppBySlug(db, data.slug);
});
});

export const fetchAppAlternatives = createServerFn({ method: "GET" })
.inputValidator((input: { appId: string }) => input)
.handler(async ({ data }) => {
const db = getDb();
return getAppAlternatives(db, data.appId);
return kvCached(
cacheKey("getAppAlternatives", { appId: data.appId }),
() => {
const db = getDb();
return getAppAlternatives(db, data.appId);
},
);
});

export const fetchProprietaryApps = createServerFn({ method: "GET" })
.inputValidator((input: { page?: number; limit?: number }) => input)
.handler(async ({ data }) => {
const db = getDb();
return listProprietaryApps(db, data);
return kvCached(cacheKey("listProprietaryApps", data), () => {
const db = getDb();
return listProprietaryApps(db, data);
});
});

export const fetchProprietaryAppBySlug = createServerFn({ method: "GET" })
.inputValidator((input: { slug: string }) => input)
.handler(async ({ data }) => {
const db = getDb();
return getProprietaryAppBySlug(db, data.slug);
return kvCached(
cacheKey("getProprietaryAppBySlug", { slug: data.slug }),
() => {
const db = getDb();
return getProprietaryAppBySlug(db, data.slug);
},
);
});

export const fetchProprietaryAppAlternatives = createServerFn({
Expand All @@ -73,27 +90,40 @@ export const fetchProprietaryAppAlternatives = createServerFn({
.inputValidator((input: { proprietaryAppId: string }) => input)
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Drizzle's AppSourceMetadata (Record<string, unknown>) doesn't satisfy TanStack's serialization check
.handler(async ({ data }): Promise<any> => {
const db = getDb();
return getProprietaryAppAlternatives(db, data.proprietaryAppId);
return kvCached(
cacheKey("getProprietaryAppAlternatives", {
proprietaryAppId: data.proprietaryAppId,
}),
() => {
const db = getDb();
return getProprietaryAppAlternatives(db, data.proprietaryAppId);
},
);
});

export const fetchTags = createServerFn({ method: "GET" }).handler(async () => {
const db = getDb();
return listTags(db);
return kvCached(cacheKey("listTags"), () => {
const db = getDb();
return listTags(db);
});
});

export const fetchTagsByType = createServerFn({ method: "GET" })
.inputValidator((input: { type: TagType }) => input)
.handler(async ({ data }) => {
const db = getDb();
return listTagsByType(db, data.type);
return kvCached(cacheKey("listTagsByType", { type: data.type }), () => {
const db = getDb();
return listTagsByType(db, data.type);
});
});

export const fetchCategoriesWithApps = createServerFn({
method: "GET",
}).handler(async () => {
const db = getDb();
return listCategoriesWithApps(db);
return kvCached(cacheKey("listCategoriesWithApps"), () => {
const db = getDb();
return listCategoriesWithApps(db);
});
});

export const fetchSearchResults = createServerFn({ method: "GET" })
Expand Down Expand Up @@ -135,8 +165,10 @@ export const fetchSearchResults = createServerFn({ method: "GET" })
export const fetchTagBySlug = createServerFn({ method: "GET" })
.inputValidator((input: { slug: string; type?: TagType }) => input)
.handler(async ({ data }) => {
const db = getDb();
return getTagBySlug(db, data.slug, data.type);
return kvCached(cacheKey("getTagBySlug", data), () => {
const db = getDb();
return getTagBySlug(db, data.slug, data.type);
});
});

export const fetchAppsByTag = createServerFn({ method: "GET" })
Expand All @@ -145,21 +177,27 @@ export const fetchAppsByTag = createServerFn({ method: "GET" })
)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.handler(async ({ data }): Promise<any> => {
const db = getDb();
return listAppsByTag(db, data.tagSlug, data);
return kvCached(cacheKey("listAppsByTag", data), () => {
const db = getDb();
return listAppsByTag(db, data.tagSlug, data);
});
});

export const fetchTagsWithCounts = createServerFn({ method: "GET" })
.inputValidator((input: { type?: TagType }) => input)
.handler(async ({ data }) => {
const db = getDb();
return listTagsWithCounts(db, data.type);
return kvCached(cacheKey("listTagsWithCounts", data), () => {
const db = getDb();
return listTagsWithCounts(db, data.type);
});
});

export const fetchLicenses = createServerFn({ method: "GET" }).handler(
async () => {
const db = getDb();
return listLicenses(db);
return kvCached(cacheKey("listLicenses"), () => {
const db = getDb();
return listLicenses(db);
});
},
);

Expand All @@ -169,38 +207,51 @@ export const fetchAppsByLicense = createServerFn({ method: "GET" })
)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.handler(async ({ data }): Promise<any> => {
const db = getDb();
return listAppsByLicense(db, data.license, data);
return kvCached(cacheKey("listAppsByLicense", data), () => {
const db = getDb();
return listAppsByLicense(db, data.license, data);
});
});

export const fetchDesktopApps = createServerFn({ method: "GET" })
.inputValidator((input: { page?: number; limit?: number }) => input)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.handler(async ({ data }): Promise<any> => {
const db = getDb();
return listDesktopApps(db, data);
return kvCached(cacheKey("listDesktopApps", data), () => {
const db = getDb();
return listDesktopApps(db, data);
});
});

export const fetchRecentApps = createServerFn({ method: "GET" })
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Drizzle's AppSourceMetadata (Record<string, unknown>) doesn't satisfy TanStack's serialization check
.handler(async (): Promise<any> => {
const db = getDb();
return getRecentApps(db);
return kvCached(cacheKey("getRecentApps"), () => {
const db = getDb();
return getRecentApps(db);
});
});

export const fetchComparisonBySlug = createServerFn({ method: "GET" })
.inputValidator((input: { slug: string }) => input)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.handler(async ({ data }): Promise<any> => {
const db = getDb();
return getComparisonBySlug(db, data.slug);
return kvCached(
cacheKey("getComparisonBySlug", { slug: data.slug }),
() => {
const db = getDb();
return getComparisonBySlug(db, data.slug);
},
);
});

export const fetchComparisonPairsForApp = createServerFn({ method: "GET" })
.inputValidator((input: { appId: string; limit?: number }) => input)
.handler(async ({ data }) => {
const db = getDb();
return listComparisonPairsForApp(db, data.appId, data.limit);
return kvCached(cacheKey("listComparisonPairsForApp", data), () => {
const db = getDb();
return listComparisonPairsForApp(db, data.appId, data.limit);
});
});

export const trackDownload = createServerFn({ method: "POST" })
Expand Down
Loading
Loading