@@ -35,6 +35,29 @@ import {
3535import { requireAdminUser } from '#app/utils/session.server.ts'
3636import { 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+
3861export 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
5977export 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+
200278function CacheKeyRow ( {
201279 cacheKey,
202280 instance,
0 commit comments