Skip to content

Commit 11b80b6

Browse files
Cache admin deletion (#709)
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent bd91621 commit 11b80b6

1 file changed

Lines changed: 89 additions & 11 deletions

File tree

app/routes/cache.admin.tsx

Lines changed: 89 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,29 @@ import {
3535
import { requireAdminUser } from '#app/utils/session.server.ts'
3636
import { type Route } from './+types/cache.admin'
3737

38+
const deleteAllMatchingIntent = 'delete-all-matching-cache-values'
39+
const defaultCacheKeysLimit = 100
40+
const maxCacheKeysLimit = 10_000
41+
42+
function sanitizeCacheKeysLimit(limit: number) {
43+
if (!Number.isFinite(limit)) return defaultCacheKeysLimit
44+
return Math.min(maxCacheKeysLimit, Math.max(1, Math.floor(limit)))
45+
}
46+
47+
async function getMatchingCacheKeys({
48+
query,
49+
limit,
50+
}: {
51+
query: string | null
52+
limit: number
53+
}) {
54+
const sanitizedLimit = sanitizeCacheKeysLimit(limit)
55+
if (typeof query === 'string') {
56+
return searchCacheKeys(query, sanitizedLimit)
57+
}
58+
return getAllCacheKeys(sanitizedLimit)
59+
}
60+
3861
export async function loader({ request }: Route.LoaderArgs) {
3962
await requireAdminUser(request)
4063
const searchParams = new URL(request.url).searchParams
@@ -47,27 +70,43 @@ export async function loader({ request }: Route.LoaderArgs) {
4770
const instances = await getAllInstances()
4871
await ensureInstance(instance)
4972

50-
let cacheKeys: { sqlite: Array<string>; lru: Array<string> }
51-
if (typeof query === 'string') {
52-
cacheKeys = await searchCacheKeys(query, limit)
53-
} else {
54-
cacheKeys = await getAllCacheKeys(limit)
55-
}
73+
const cacheKeys = await getMatchingCacheKeys({ query, limit })
5674
return json({ cacheKeys, instance, instances, currentInstanceInfo })
5775
}
5876

5977
export async function action({ request }: Route.ActionArgs) {
6078
await requireAdminUser(request)
79+
const searchParams = new URL(request.url).searchParams
6180
const formData = await request.formData()
62-
const key = formData.get('cacheKey')
63-
const { currentInstance } = await getInstanceInfo()
81+
const { currentInstance, currentIsPrimary, primaryInstance } =
82+
await getInstanceInfo()
6483
const instance = formData.get('instance') ?? currentInstance
84+
invariantResponse(typeof instance === 'string', 'instance must be a string')
85+
await ensureInstance(instance)
86+
87+
const intent = formData.get('intent')
88+
if (intent === deleteAllMatchingIntent) {
89+
invariantResponse(
90+
currentIsPrimary,
91+
`Bulk delete must run on the primary instance (${primaryInstance})`,
92+
)
93+
const query = searchParams.get('query')
94+
const limit = Number(searchParams.get('limit') ?? 100)
95+
const { sqlite, lru } = await getMatchingCacheKeys({ query, limit })
96+
for (const cacheKey of sqlite) {
97+
await cache.delete(cacheKey)
98+
}
99+
for (const cacheKey of lru) {
100+
lruCache.delete(cacheKey)
101+
}
102+
return json({ success: true })
103+
}
104+
105+
const key = formData.get('cacheKey')
65106
const type = formData.get('type')
66107

67108
invariantResponse(typeof key === 'string', 'cacheKey must be a string')
68109
invariantResponse(typeof type === 'string', 'type must be a string')
69-
invariantResponse(typeof instance === 'string', 'instance must be a string')
70-
await ensureInstance(instance)
71110

72111
switch (type) {
73112
case 'sqlite': {
@@ -93,6 +132,8 @@ export default function CacheAdminRoute({
93132
const query = searchParams.get('query') ?? ''
94133
const limit = searchParams.get('limit') ?? '100'
95134
const instance = searchParams.get('instance') ?? data.instance
135+
const matchingCacheValuesCount =
136+
data.cacheKeys.sqlite.length + data.cacheKeys.lru.length
96137

97138
const handleFormChange = useDebounce((form: HTMLFormElement) => {
98139
void submit(form)
@@ -124,7 +165,7 @@ export default function CacheAdminRoute({
124165
/>
125166
<div className="absolute top-0 right-2 flex h-full w-14 items-center justify-between text-lg font-medium text-slate-500">
126167
<span title="Total results shown">
127-
{data.cacheKeys.sqlite.length + data.cacheKeys.lru.length}
168+
{matchingCacheValuesCount}
128169
</span>
129170
</div>
130171
</div>
@@ -170,6 +211,11 @@ export default function CacheAdminRoute({
170211
</div>
171212
</Form>
172213
<Spacer size="2xs" />
214+
<DeleteAllMatchingCacheValuesButton
215+
instance={instance}
216+
matchingCacheValuesCount={matchingCacheValuesCount}
217+
/>
218+
<Spacer size="2xs" />
173219
<div className="flex flex-col gap-4">
174220
<H3>LRU Cache:</H3>
175221
{data.cacheKeys.lru.map((key) => (
@@ -197,6 +243,38 @@ export default function CacheAdminRoute({
197243
)
198244
}
199245

246+
function DeleteAllMatchingCacheValuesButton({
247+
instance,
248+
matchingCacheValuesCount,
249+
}: {
250+
instance: string
251+
matchingCacheValuesCount: number
252+
}) {
253+
const fetcher = useFetcher()
254+
const dc = useDoubleCheck()
255+
if (matchingCacheValuesCount === 0) return null
256+
const isDeleting = fetcher.state !== 'idle'
257+
258+
return (
259+
<fetcher.Form method="POST">
260+
<input type="hidden" name="intent" value={deleteAllMatchingIntent} />
261+
<input type="hidden" name="instance" value={instance} />
262+
<Button
263+
size="small"
264+
variant="danger"
265+
disabled={isDeleting}
266+
{...dc.getButtonProps({ type: 'submit' })}
267+
>
268+
{isDeleting
269+
? `deleting ${matchingCacheValuesCount} matching cache values...`
270+
: dc.doubleCheck
271+
? `you sure? delete all ${matchingCacheValuesCount} matching cache values`
272+
: `delete all ${matchingCacheValuesCount} matching cache values`}
273+
</Button>
274+
</fetcher.Form>
275+
)
276+
}
277+
200278
function CacheKeyRow({
201279
cacheKey,
202280
instance,

0 commit comments

Comments
 (0)