Skip to content

Commit 234d24f

Browse files
authored
Merge pull request #7 from LooseWireDev/fix/buffer-cached-responses
Replace Cache API with KV cache layer
2 parents c4f1979 + ea56bc2 commit 234d24f

6 files changed

Lines changed: 228 additions & 84 deletions

File tree

db/kv-cache.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { env } from "cloudflare:workers";
2+
3+
function getKV(): KVNamespace {
4+
return env.KV;
5+
}
6+
7+
export async function kvCached<T>(
8+
key: string,
9+
fn: () => Promise<T>,
10+
): Promise<T> {
11+
const kv = getKV();
12+
const cached = await kv.get(key, "json");
13+
if (cached !== null) return cached as T;
14+
15+
const result = await fn();
16+
// Fire-and-forget write — don't block the response
17+
kv.put(key, JSON.stringify(result)).catch(() => {});
18+
return result;
19+
}
20+
21+
export function cacheKey(
22+
name: string,
23+
params?: Record<string, unknown>,
24+
): string {
25+
if (!params || Object.keys(params).length === 0) return name;
26+
const sorted = Object.entries(params)
27+
.filter(([, v]) => v !== undefined)
28+
.sort(([a], [b]) => a.localeCompare(b))
29+
.map(([k, v]) => `${k}=${Array.isArray(v) ? v.join(",") : v}`)
30+
.join(":");
31+
return `${name}:${sorted}`;
32+
}

scripts/cache-purge.ts

Lines changed: 69 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@ import "dotenv/config";
22

33
const zoneId = process.env.CF_ZONE_ID;
44
const apiToken = process.env.CF_API_TOKEN;
5+
const kvNamespaceId = process.env.CF_KV_NAMESPACE_ID;
56

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

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

23-
const result = await response.json();
25+
const cacheResult = await cacheResponse.json();
2426

25-
if (result.success) {
26-
console.log("Cache purged successfully");
27+
if (cacheResult.success) {
28+
console.log("Edge cache purged");
2729
} else {
28-
console.error("Cache purge failed:", result.errors);
30+
console.error("Edge cache purge failed:", cacheResult.errors);
2931
process.exit(1);
3032
}
33+
34+
// Purge KV namespace
35+
if (!kvNamespaceId) {
36+
console.log("No CF_KV_NAMESPACE_ID set, skipping KV purge");
37+
} else {
38+
const accountId = process.env.CF_ACCOUNT_ID;
39+
if (!accountId) {
40+
console.error("Missing CF_ACCOUNT_ID for KV purge");
41+
process.exit(1);
42+
}
43+
44+
// List all keys
45+
let cursor: string | undefined;
46+
const allKeys: string[] = [];
47+
48+
do {
49+
const params = new URLSearchParams();
50+
if (cursor) params.set("cursor", cursor);
51+
52+
const listRes = await fetch(
53+
`https://api.cloudflare.com/client/v4/accounts/${accountId}/storage/kv/namespaces/${kvNamespaceId}/keys?${params}`,
54+
{ headers: { Authorization: `Bearer ${apiToken}` } },
55+
);
56+
57+
const listData: any = await listRes.json();
58+
if (!listData.success) {
59+
console.error("KV list failed:", listData.errors);
60+
process.exit(1);
61+
}
62+
63+
allKeys.push(...listData.result.map((k: { name: string }) => k.name));
64+
cursor = listData.result_info?.cursor;
65+
} while (cursor);
66+
67+
if (allKeys.length === 0) {
68+
console.log("KV namespace empty, nothing to purge");
69+
} else {
70+
// Bulk delete (max 10,000 per request)
71+
for (let i = 0; i < allKeys.length; i += 10000) {
72+
const batch = allKeys.slice(i, i + 10000);
73+
const delRes = await fetch(
74+
`https://api.cloudflare.com/client/v4/accounts/${accountId}/storage/kv/namespaces/${kvNamespaceId}/bulk`,
75+
{
76+
method: "DELETE",
77+
headers: {
78+
Authorization: `Bearer ${apiToken}`,
79+
"Content-Type": "application/json",
80+
},
81+
body: JSON.stringify(batch),
82+
},
83+
);
84+
85+
const delData: any = await delRes.json();
86+
if (!delData.success) {
87+
console.error("KV bulk delete failed:", delData.errors);
88+
process.exit(1);
89+
}
90+
}
91+
92+
console.log(`KV purged: ${allKeys.length} keys deleted`);
93+
}
94+
}

src/lib/server-fns.ts

Lines changed: 87 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { createServerFn } from "@tanstack/react-start";
22
import { nanoid } from "nanoid";
33
import { getDb, getTursoClient } from "../../db/client";
44
import { embedText } from "../../db/embed";
5+
import { cacheKey, kvCached } from "../../db/kv-cache";
56
import {
67
getAppAlternatives,
78
getAppBySlug,
@@ -34,37 +35,53 @@ export const fetchApps = createServerFn({ method: "GET" })
3435
)
3536
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Drizzle's AppSourceMetadata (Record<string, unknown>) doesn't satisfy TanStack's serialization check
3637
.handler(async ({ data }): Promise<any> => {
37-
const db = getDb();
38-
return listApps(db, data);
38+
return kvCached(cacheKey("listApps", data), () => {
39+
const db = getDb();
40+
return listApps(db, data);
41+
});
3942
});
4043

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

4954
export const fetchAppAlternatives = createServerFn({ method: "GET" })
5055
.inputValidator((input: { appId: string }) => input)
5156
.handler(async ({ data }) => {
52-
const db = getDb();
53-
return getAppAlternatives(db, data.appId);
57+
return kvCached(
58+
cacheKey("getAppAlternatives", { appId: data.appId }),
59+
() => {
60+
const db = getDb();
61+
return getAppAlternatives(db, data.appId);
62+
},
63+
);
5464
});
5565

5666
export const fetchProprietaryApps = createServerFn({ method: "GET" })
5767
.inputValidator((input: { page?: number; limit?: number }) => input)
5868
.handler(async ({ data }) => {
59-
const db = getDb();
60-
return listProprietaryApps(db, data);
69+
return kvCached(cacheKey("listProprietaryApps", data), () => {
70+
const db = getDb();
71+
return listProprietaryApps(db, data);
72+
});
6173
});
6274

6375
export const fetchProprietaryAppBySlug = createServerFn({ method: "GET" })
6476
.inputValidator((input: { slug: string }) => input)
6577
.handler(async ({ data }) => {
66-
const db = getDb();
67-
return getProprietaryAppBySlug(db, data.slug);
78+
return kvCached(
79+
cacheKey("getProprietaryAppBySlug", { slug: data.slug }),
80+
() => {
81+
const db = getDb();
82+
return getProprietaryAppBySlug(db, data.slug);
83+
},
84+
);
6885
});
6986

7087
export const fetchProprietaryAppAlternatives = createServerFn({
@@ -73,27 +90,40 @@ export const fetchProprietaryAppAlternatives = createServerFn({
7390
.inputValidator((input: { proprietaryAppId: string }) => input)
7491
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Drizzle's AppSourceMetadata (Record<string, unknown>) doesn't satisfy TanStack's serialization check
7592
.handler(async ({ data }): Promise<any> => {
76-
const db = getDb();
77-
return getProprietaryAppAlternatives(db, data.proprietaryAppId);
93+
return kvCached(
94+
cacheKey("getProprietaryAppAlternatives", {
95+
proprietaryAppId: data.proprietaryAppId,
96+
}),
97+
() => {
98+
const db = getDb();
99+
return getProprietaryAppAlternatives(db, data.proprietaryAppId);
100+
},
101+
);
78102
});
79103

80104
export const fetchTags = createServerFn({ method: "GET" }).handler(async () => {
81-
const db = getDb();
82-
return listTags(db);
105+
return kvCached(cacheKey("listTags"), () => {
106+
const db = getDb();
107+
return listTags(db);
108+
});
83109
});
84110

85111
export const fetchTagsByType = createServerFn({ method: "GET" })
86112
.inputValidator((input: { type: TagType }) => input)
87113
.handler(async ({ data }) => {
88-
const db = getDb();
89-
return listTagsByType(db, data.type);
114+
return kvCached(cacheKey("listTagsByType", { type: data.type }), () => {
115+
const db = getDb();
116+
return listTagsByType(db, data.type);
117+
});
90118
});
91119

92120
export const fetchCategoriesWithApps = createServerFn({
93121
method: "GET",
94122
}).handler(async () => {
95-
const db = getDb();
96-
return listCategoriesWithApps(db);
123+
return kvCached(cacheKey("listCategoriesWithApps"), () => {
124+
const db = getDb();
125+
return listCategoriesWithApps(db);
126+
});
97127
});
98128

99129
export const fetchSearchResults = createServerFn({ method: "GET" })
@@ -135,8 +165,10 @@ export const fetchSearchResults = createServerFn({ method: "GET" })
135165
export const fetchTagBySlug = createServerFn({ method: "GET" })
136166
.inputValidator((input: { slug: string; type?: TagType }) => input)
137167
.handler(async ({ data }) => {
138-
const db = getDb();
139-
return getTagBySlug(db, data.slug, data.type);
168+
return kvCached(cacheKey("getTagBySlug", data), () => {
169+
const db = getDb();
170+
return getTagBySlug(db, data.slug, data.type);
171+
});
140172
});
141173

142174
export const fetchAppsByTag = createServerFn({ method: "GET" })
@@ -145,21 +177,27 @@ export const fetchAppsByTag = createServerFn({ method: "GET" })
145177
)
146178
// eslint-disable-next-line @typescript-eslint/no-explicit-any
147179
.handler(async ({ data }): Promise<any> => {
148-
const db = getDb();
149-
return listAppsByTag(db, data.tagSlug, data);
180+
return kvCached(cacheKey("listAppsByTag", data), () => {
181+
const db = getDb();
182+
return listAppsByTag(db, data.tagSlug, data);
183+
});
150184
});
151185

152186
export const fetchTagsWithCounts = createServerFn({ method: "GET" })
153187
.inputValidator((input: { type?: TagType }) => input)
154188
.handler(async ({ data }) => {
155-
const db = getDb();
156-
return listTagsWithCounts(db, data.type);
189+
return kvCached(cacheKey("listTagsWithCounts", data), () => {
190+
const db = getDb();
191+
return listTagsWithCounts(db, data.type);
192+
});
157193
});
158194

159195
export const fetchLicenses = createServerFn({ method: "GET" }).handler(
160196
async () => {
161-
const db = getDb();
162-
return listLicenses(db);
197+
return kvCached(cacheKey("listLicenses"), () => {
198+
const db = getDb();
199+
return listLicenses(db);
200+
});
163201
},
164202
);
165203

@@ -169,38 +207,51 @@ export const fetchAppsByLicense = createServerFn({ method: "GET" })
169207
)
170208
// eslint-disable-next-line @typescript-eslint/no-explicit-any
171209
.handler(async ({ data }): Promise<any> => {
172-
const db = getDb();
173-
return listAppsByLicense(db, data.license, data);
210+
return kvCached(cacheKey("listAppsByLicense", data), () => {
211+
const db = getDb();
212+
return listAppsByLicense(db, data.license, data);
213+
});
174214
});
175215

176216
export const fetchDesktopApps = createServerFn({ method: "GET" })
177217
.inputValidator((input: { page?: number; limit?: number }) => input)
178218
// eslint-disable-next-line @typescript-eslint/no-explicit-any
179219
.handler(async ({ data }): Promise<any> => {
180-
const db = getDb();
181-
return listDesktopApps(db, data);
220+
return kvCached(cacheKey("listDesktopApps", data), () => {
221+
const db = getDb();
222+
return listDesktopApps(db, data);
223+
});
182224
});
183225

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

191235
export const fetchComparisonBySlug = createServerFn({ method: "GET" })
192236
.inputValidator((input: { slug: string }) => input)
193237
// eslint-disable-next-line @typescript-eslint/no-explicit-any
194238
.handler(async ({ data }): Promise<any> => {
195-
const db = getDb();
196-
return getComparisonBySlug(db, data.slug);
239+
return kvCached(
240+
cacheKey("getComparisonBySlug", { slug: data.slug }),
241+
() => {
242+
const db = getDb();
243+
return getComparisonBySlug(db, data.slug);
244+
},
245+
);
197246
});
198247

199248
export const fetchComparisonPairsForApp = createServerFn({ method: "GET" })
200249
.inputValidator((input: { appId: string; limit?: number }) => input)
201250
.handler(async ({ data }) => {
202-
const db = getDb();
203-
return listComparisonPairsForApp(db, data.appId, data.limit);
251+
return kvCached(cacheKey("listComparisonPairsForApp", data), () => {
252+
const db = getDb();
253+
return listComparisonPairsForApp(db, data.appId, data.limit);
254+
});
204255
});
205256

206257
export const trackDownload = createServerFn({ method: "POST" })

0 commit comments

Comments
 (0)