From 283b125369e816bf5c7e67453eed51455c739e6b Mon Sep 17 00:00:00 2001 From: Charis <26616127+charislam@users.noreply.github.com> Date: Wed, 17 Sep 2025 01:55:38 -0400 Subject: [PATCH 1/6] fix: failing type errors in build (#38761) * fix(www): types for awaited params * fix(docs): no extraneous exports from route page * fix(studio): api handler types, no non-handlers allowed in pages --- apps/docs/app/api/revalidate/route.test.ts | 2 +- apps/docs/app/api/revalidate/route.ts | 102 +---------------- apps/docs/app/api/revalidate/route.utils.ts | 103 ++++++++++++++++++ apps/studio/lib/api/apiWrapper.ts | 6 +- .../api/constants.ts => lib/constants/api.ts} | 0 apps/studio/next-env.d.ts | 1 + .../pages/api/platform/profile/index.ts | 2 +- .../[ref]/analytics/endpoints/[name].ts | 2 +- .../api/platform/projects/[ref]/databases.ts | 2 +- .../api/platform/projects/[ref]/index.ts | 2 +- .../api/platform/projects/[ref]/settings.ts | 2 +- .../pages/api/platform/projects/index.ts | 2 +- .../api/platform/props/project/[ref]/api.ts | 2 +- .../api/platform/props/project/[ref]/index.ts | 2 +- .../app/blog/categories/[category]/page.tsx | 16 ++- apps/www/app/blog/tags/[tag]/page.tsx | 12 +- 16 files changed, 141 insertions(+), 117 deletions(-) create mode 100644 apps/docs/app/api/revalidate/route.utils.ts rename apps/studio/{pages/api/constants.ts => lib/constants/api.ts} (100%) diff --git a/apps/docs/app/api/revalidate/route.test.ts b/apps/docs/app/api/revalidate/route.test.ts index b6ffc1d94be85..89eebe4508794 100644 --- a/apps/docs/app/api/revalidate/route.test.ts +++ b/apps/docs/app/api/revalidate/route.test.ts @@ -6,7 +6,7 @@ import { headers } from 'next/headers' import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' -import { _handleRevalidateRequest } from './route' +import { _handleRevalidateRequest } from './route.utils' // Mock Next.js modules vi.mock('next/cache', () => ({ diff --git a/apps/docs/app/api/revalidate/route.ts b/apps/docs/app/api/revalidate/route.ts index c1470414469fb..8309d32cccce4 100644 --- a/apps/docs/app/api/revalidate/route.ts +++ b/apps/docs/app/api/revalidate/route.ts @@ -1,109 +1,9 @@ -import { createClient } from '@supabase/supabase-js' -import { type Database } from 'common' -import { revalidateTag } from 'next/cache' -import { headers } from 'next/headers' import { type NextRequest } from 'next/server' -import { z } from 'zod' -import { VALID_REVALIDATION_TAGS } from '~/features/helpers.fetch' -enum AuthorizationLevel { - Unauthorized, - Basic, - Override, -} - -const requestBodySchema = z.object({ - tags: z.array(z.enum(VALID_REVALIDATION_TAGS)), -}) +import { _handleRevalidateRequest } from './route.utils' export const POST = handleError(_handleRevalidateRequest) -export async function _handleRevalidateRequest(request: NextRequest) { - const requestHeaders = await headers() - const authorization = requestHeaders.get('Authorization') - if (!authorization) { - return new Response('Missing Authorization header', { status: 401 }) - } - - const basicKeys = process.env.DOCS_REVALIDATION_KEYS?.split(/\s*,\s*/) ?? [] - const overrideKeys = process.env.DOCS_REVALIDATION_OVERRIDE_KEYS?.split(/\s*,\s*/) ?? [] - if (basicKeys.length === 0 && overrideKeys.length === 0) { - console.error('No keys configured for revalidation') - return new Response('Internal server error', { - status: 500, - }) - } - - let authorizationLevel = AuthorizationLevel.Unauthorized - const token = authorization.replace(/^Bearer /, '') - if (overrideKeys.includes(token)) { - authorizationLevel = AuthorizationLevel.Override - } else if (basicKeys.includes(token)) { - authorizationLevel = AuthorizationLevel.Basic - } - if (authorizationLevel === AuthorizationLevel.Unauthorized) { - return new Response('Invalid Authorization header', { status: 401 }) - } - - let result: z.infer - try { - result = requestBodySchema.parse(await request.json()) - } catch (error) { - console.error(error) - return new Response( - 'Malformed request body: should be a JSON object with a "tags" array of strings.', - { status: 400 } - ) - } - - const supabaseAdmin = createClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.SUPABASE_SECRET_KEY! - ) - - if (authorizationLevel === AuthorizationLevel.Basic) { - const { data: lastRevalidation, error } = await supabaseAdmin.rpc( - 'get_last_revalidation_for_tags', - { - tags: result.tags, - } - ) - if (error) { - console.error(error) - return new Response('Internal server error', { status: 500 }) - } - - const sixHoursAgo = new Date() - sixHoursAgo.setHours(sixHoursAgo.getHours() - 6) - if (lastRevalidation?.some((revalidation) => new Date(revalidation.created_at) > sixHoursAgo)) { - return new Response( - 'Your request includes a tag that has been revalidated within the last 6 hours. You can override this limit by authenticating with Override permissions.', - { - status: 429, - } - ) - } - } - - const { error } = await supabaseAdmin - .from('validation_history') - .insert(result.tags.map((tag) => ({ tag }))) - if (error) { - console.error('Failed to update revalidation table: %o', error) - } - - result.tags.forEach((tag) => { - revalidateTag(tag) - }) - - return new Response(null, { - status: 204, - headers: { - 'Cache-Control': 'no-cache', - }, - }) -} - function handleError(handleRequest: (request: NextRequest) => Promise) { return async function (request: NextRequest) { try { diff --git a/apps/docs/app/api/revalidate/route.utils.ts b/apps/docs/app/api/revalidate/route.utils.ts new file mode 100644 index 0000000000000..98fabd45ad312 --- /dev/null +++ b/apps/docs/app/api/revalidate/route.utils.ts @@ -0,0 +1,103 @@ +import { createClient } from '@supabase/supabase-js' +import { type Database } from 'common' +import { revalidateTag } from 'next/cache' +import { headers } from 'next/headers' +import { type NextRequest } from 'next/server' +import { z } from 'zod' +import { VALID_REVALIDATION_TAGS } from '~/features/helpers.fetch' + +enum AuthorizationLevel { + Unauthorized, + Basic, + Override, +} + +const requestBodySchema = z.object({ + tags: z.array(z.enum(VALID_REVALIDATION_TAGS)), +}) + +export async function _handleRevalidateRequest(request: NextRequest) { + const requestHeaders = await headers() + const authorization = requestHeaders.get('Authorization') + if (!authorization) { + return new Response('Missing Authorization header', { status: 401 }) + } + + const basicKeys = process.env.DOCS_REVALIDATION_KEYS?.split(/\s*,\s*/) ?? [] + const overrideKeys = process.env.DOCS_REVALIDATION_OVERRIDE_KEYS?.split(/\s*,\s*/) ?? [] + if (basicKeys.length === 0 && overrideKeys.length === 0) { + console.error('No keys configured for revalidation') + return new Response('Internal server error', { + status: 500, + }) + } + + let authorizationLevel = AuthorizationLevel.Unauthorized + const token = authorization.replace(/^Bearer /, '') + if (overrideKeys.includes(token)) { + authorizationLevel = AuthorizationLevel.Override + } else if (basicKeys.includes(token)) { + authorizationLevel = AuthorizationLevel.Basic + } + if (authorizationLevel === AuthorizationLevel.Unauthorized) { + return new Response('Invalid Authorization header', { status: 401 }) + } + + let result: z.infer + try { + result = requestBodySchema.parse(await request.json()) + } catch (error) { + console.error(error) + return new Response( + 'Malformed request body: should be a JSON object with a "tags" array of strings.', + { status: 400 } + ) + } + + const supabaseAdmin = createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.SUPABASE_SECRET_KEY! + ) + + if (authorizationLevel === AuthorizationLevel.Basic) { + const { data: lastRevalidation, error } = await supabaseAdmin.rpc( + 'get_last_revalidation_for_tags', + { + tags: result.tags, + } + ) + if (error) { + console.error(error) + return new Response('Internal server error', { status: 500 }) + } + + const sixHoursAgo = new Date() + sixHoursAgo.setHours(sixHoursAgo.getHours() - 6) + if (lastRevalidation?.some((revalidation) => new Date(revalidation.created_at) > sixHoursAgo)) { + return new Response( + 'Your request includes a tag that has been revalidated within the last 6 hours. You can override this limit by authenticating with Override permissions.', + { + status: 429, + } + ) + } + } + + const { error } = await supabaseAdmin + .from('validation_history') + .insert(result.tags.map((tag) => ({ tag }))) + if (error) { + console.error('Failed to update revalidation table: %o', error) + } + + result.tags.forEach((tag) => { + revalidateTag(tag) + }) + + return new Response(null, { + status: 204, + headers: { + 'Cache-Control': 'no-cache', + }, + }) +} diff --git a/apps/studio/lib/api/apiWrapper.ts b/apps/studio/lib/api/apiWrapper.ts index daa5cd6d141f5..fb9d8bea32704 100644 --- a/apps/studio/lib/api/apiWrapper.ts +++ b/apps/studio/lib/api/apiWrapper.ts @@ -1,4 +1,4 @@ -import type { NextApiHandler, NextApiRequest, NextApiResponse } from 'next' +import type { NextApiRequest, NextApiResponse } from 'next' import { ResponseError, ResponseFailure } from 'types' import { IS_PLATFORM } from '../constants' @@ -26,9 +26,9 @@ export function isResponseOk(response: T | ResponseFailure | undefined): resp export default async function apiWrapper( req: NextApiRequest, res: NextApiResponse, - handler: NextApiHandler, + handler: (req: NextApiRequest, res: NextApiResponse) => Promise, options?: { withAuth: boolean } -) { +): Promise { try { const { withAuth } = options || {} diff --git a/apps/studio/pages/api/constants.ts b/apps/studio/lib/constants/api.ts similarity index 100% rename from apps/studio/pages/api/constants.ts rename to apps/studio/lib/constants/api.ts diff --git a/apps/studio/next-env.d.ts b/apps/studio/next-env.d.ts index 52e831b434248..254b73c165d90 100644 --- a/apps/studio/next-env.d.ts +++ b/apps/studio/next-env.d.ts @@ -1,5 +1,6 @@ /// /// +/// // NOTE: This file should not be edited // see https://nextjs.org/docs/pages/api-reference/config/typescript for more information. diff --git a/apps/studio/pages/api/platform/profile/index.ts b/apps/studio/pages/api/platform/profile/index.ts index 440323dcbc772..4344069167142 100644 --- a/apps/studio/pages/api/platform/profile/index.ts +++ b/apps/studio/pages/api/platform/profile/index.ts @@ -1,7 +1,7 @@ import { NextApiRequest, NextApiResponse } from 'next' import apiWrapper from 'lib/api/apiWrapper' -import { DEFAULT_PROJECT } from '../../constants' +import { DEFAULT_PROJECT } from 'lib/constants/api' export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler) diff --git a/apps/studio/pages/api/platform/projects/[ref]/analytics/endpoints/[name].ts b/apps/studio/pages/api/platform/projects/[ref]/analytics/endpoints/[name].ts index eee1aff5d8eba..46c2fd1400c3d 100644 --- a/apps/studio/pages/api/platform/projects/[ref]/analytics/endpoints/[name].ts +++ b/apps/studio/pages/api/platform/projects/[ref]/analytics/endpoints/[name].ts @@ -1,6 +1,6 @@ import apiWrapper from 'lib/api/apiWrapper' import { NextApiRequest, NextApiResponse } from 'next' -import { PROJECT_ANALYTICS_URL } from 'pages/api/constants' +import { PROJECT_ANALYTICS_URL } from 'lib/constants/api' export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler) diff --git a/apps/studio/pages/api/platform/projects/[ref]/databases.ts b/apps/studio/pages/api/platform/projects/[ref]/databases.ts index 02c03361d8c88..0db33113968fa 100644 --- a/apps/studio/pages/api/platform/projects/[ref]/databases.ts +++ b/apps/studio/pages/api/platform/projects/[ref]/databases.ts @@ -2,7 +2,7 @@ import { NextApiRequest, NextApiResponse } from 'next' import { paths } from 'api-types' import apiWrapper from 'lib/api/apiWrapper' -import { PROJECT_REST_URL } from 'pages/api/constants' +import { PROJECT_REST_URL } from 'lib/constants/api' export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler) diff --git a/apps/studio/pages/api/platform/projects/[ref]/index.ts b/apps/studio/pages/api/platform/projects/[ref]/index.ts index 72412cd4c71b8..c0ecb82de44b0 100644 --- a/apps/studio/pages/api/platform/projects/[ref]/index.ts +++ b/apps/studio/pages/api/platform/projects/[ref]/index.ts @@ -1,7 +1,7 @@ import { NextApiRequest, NextApiResponse } from 'next' import apiWrapper from 'lib/api/apiWrapper' -import { DEFAULT_PROJECT, PROJECT_REST_URL } from 'pages/api/constants' +import { DEFAULT_PROJECT, PROJECT_REST_URL } from 'lib/constants/api' export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler) diff --git a/apps/studio/pages/api/platform/projects/[ref]/settings.ts b/apps/studio/pages/api/platform/projects/[ref]/settings.ts index 192d8506cd9ac..a241ec2856785 100644 --- a/apps/studio/pages/api/platform/projects/[ref]/settings.ts +++ b/apps/studio/pages/api/platform/projects/[ref]/settings.ts @@ -2,7 +2,7 @@ import { NextApiRequest, NextApiResponse } from 'next' import { components } from 'api-types' import apiWrapper from 'lib/api/apiWrapper' -import { PROJECT_ENDPOINT, PROJECT_ENDPOINT_PROTOCOL } from 'pages/api/constants' +import { PROJECT_ENDPOINT, PROJECT_ENDPOINT_PROTOCOL } from 'lib/constants/api' type ProjectAppConfig = components['schemas']['ProjectSettingsResponse']['app_config'] & { protocol?: string diff --git a/apps/studio/pages/api/platform/projects/index.ts b/apps/studio/pages/api/platform/projects/index.ts index f81b3a919f50a..e70e1cf817f06 100644 --- a/apps/studio/pages/api/platform/projects/index.ts +++ b/apps/studio/pages/api/platform/projects/index.ts @@ -1,7 +1,7 @@ import { NextApiRequest, NextApiResponse } from 'next' import apiWrapper from 'lib/api/apiWrapper' -import { DEFAULT_PROJECT } from '../../constants' +import { DEFAULT_PROJECT } from 'lib/constants/api' export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler) diff --git a/apps/studio/pages/api/platform/props/project/[ref]/api.ts b/apps/studio/pages/api/platform/props/project/[ref]/api.ts index 2fbf6cfc63d87..463e9c8f25d3c 100644 --- a/apps/studio/pages/api/platform/props/project/[ref]/api.ts +++ b/apps/studio/pages/api/platform/props/project/[ref]/api.ts @@ -6,7 +6,7 @@ import { PROJECT_ENDPOINT, PROJECT_ENDPOINT_PROTOCOL, PROJECT_REST_URL, -} from 'pages/api/constants' +} from 'lib/constants/api' export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler) diff --git a/apps/studio/pages/api/platform/props/project/[ref]/index.ts b/apps/studio/pages/api/platform/props/project/[ref]/index.ts index c7f217a2e2b45..c04f3bc80f10f 100644 --- a/apps/studio/pages/api/platform/props/project/[ref]/index.ts +++ b/apps/studio/pages/api/platform/props/project/[ref]/index.ts @@ -1,7 +1,7 @@ import { NextApiRequest, NextApiResponse } from 'next' import apiWrapper from 'lib/api/apiWrapper' -import { DEFAULT_PROJECT } from 'pages/api/constants' +import { DEFAULT_PROJECT } from 'lib/constants/api' export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler) diff --git a/apps/www/app/blog/categories/[category]/page.tsx b/apps/www/app/blog/categories/[category]/page.tsx index b0c8f9ab3be2f..78dbaf4610949 100644 --- a/apps/www/app/blog/categories/[category]/page.tsx +++ b/apps/www/app/blog/categories/[category]/page.tsx @@ -18,7 +18,13 @@ export async function generateStaticParams() { export const revalidate = 30 export const dynamic = 'force-static' -export async function generateMetadata({ params }: { params: Params }): Promise { +export async function generateMetadata({ + params: paramsPromise, +}: { + params: Promise +}): Promise { + const params = await paramsPromise + const capitalizedCategory = capitalize(params?.category.replaceAll('-', ' ')) return { title: `Blog | ${capitalizedCategory}`, @@ -26,7 +32,13 @@ export async function generateMetadata({ params }: { params: Params }): Promise< } } -export default async function CategoriesPage({ params }: { params: Params }) { +export default async function CategoriesPage({ + params: paramsPromise, +}: { + params: Promise +}) { + const params = await paramsPromise + const staticPosts = getSortedPosts({ directory: '_blog', limit: 0, diff --git a/apps/www/app/blog/tags/[tag]/page.tsx b/apps/www/app/blog/tags/[tag]/page.tsx index edda447f4a136..db30a65a2d8f0 100644 --- a/apps/www/app/blog/tags/[tag]/page.tsx +++ b/apps/www/app/blog/tags/[tag]/page.tsx @@ -18,7 +18,13 @@ export async function generateStaticParams() { export const revalidate = 30 export const dynamic = 'force-static' -export async function generateMetadata({ params }: { params: Params }): Promise { +export async function generateMetadata({ + params: paramsPromise, +}: { + params: Promise +}): Promise { + const params = await paramsPromise + const capitalizedTag = capitalize(params?.tag.replaceAll('-', ' ')) return { title: `Blog | ${capitalizedTag}`, @@ -26,7 +32,9 @@ export async function generateMetadata({ params }: { params: Params }): Promise< } } -export default async function TagPage({ params }: { params: Params }) { +export default async function TagPage({ params: paramsPromise }: { params: Promise }) { + const params = await paramsPromise + const staticPosts = getSortedPosts({ directory: '_blog', limit: 0, tags: [params.tag] }) const cmsPosts = await getAllCMSPosts({ tags: [params.tag] }) const blogs = [...(staticPosts as any[]), ...(cmsPosts as any[])] as unknown as PostTypes[] From f2ba7f0270543a24b2133f55f00df487e7c85ed9 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Wed, 17 Sep 2025 14:07:51 +0800 Subject: [PATCH 2/6] Add self-remediation for orphan prefixes and refactor delete objects to be a singular request (#38753) * Add self-remediation for orphan prefixes and refactor delete objects to be a singular request * Update comment * Update delete prefix * Smol fix --- .../storage/bucket-prefix-delete-mutation.ts | 36 ++++++++++++ apps/studio/state/storage-explorer.tsx | 55 ++++++++----------- 2 files changed, 59 insertions(+), 32 deletions(-) create mode 100644 apps/studio/data/storage/bucket-prefix-delete-mutation.ts diff --git a/apps/studio/data/storage/bucket-prefix-delete-mutation.ts b/apps/studio/data/storage/bucket-prefix-delete-mutation.ts new file mode 100644 index 0000000000000..d2b7d37f1aee0 --- /dev/null +++ b/apps/studio/data/storage/bucket-prefix-delete-mutation.ts @@ -0,0 +1,36 @@ +import { executeSql } from 'data/sql/execute-sql-query' + +/** + * [Joshen] JFYI this is solely being used in storage-explorer.tsx and hence doesn't have a useMutation hook + * It's solely supposed to aid users to self-remediate a known issue with orphan prefixes so the condition to + * clean the prefix from the storage.prefixes is intentionally strict here (e.g only level 1) + * + * We can make this a bit more loose if needed, but ideal case is that this issue is addressed at the storage level + */ + +type DeleteBucketPrefixParams = { + projectRef?: string + connectionString?: string + bucketId?: string + prefix?: string +} +export const deleteBucketPrefix = async ( + { projectRef, connectionString, bucketId, prefix }: DeleteBucketPrefixParams, + signal?: AbortSignal +) => { + if (!projectRef) throw new Error('projectRef is required') + if (!connectionString) throw new Error('connectionString is required') + if (!bucketId) throw new Error('bucketId is required') + if (!prefix) throw new Error('prefix is required') + + const sql = /* SQL */ ` +select storage.delete_prefix('${bucketId}', '${prefix}'); +`.trim() + + const { result } = await executeSql( + { projectRef, connectionString, sql, queryKey: ['delete-bucket-prefix'] }, + signal + ) + + return result +} diff --git a/apps/studio/state/storage-explorer.tsx b/apps/studio/state/storage-explorer.tsx index ece6faa3fb44e..fbeb9a547bee8 100644 --- a/apps/studio/state/storage-explorer.tsx +++ b/apps/studio/state/storage-explorer.tsx @@ -44,6 +44,7 @@ import { getQueryClient } from 'data/query-client' import { deleteBucketObject } from 'data/storage/bucket-object-delete-mutation' import { downloadBucketObject } from 'data/storage/bucket-object-download-mutation' import { listBucketObjects, StorageObject } from 'data/storage/bucket-objects-list-mutation' +import { deleteBucketPrefix } from 'data/storage/bucket-prefix-delete-mutation' import { Bucket } from 'data/storage/buckets-query' import { moveStorageObject } from 'data/storage/object-move-mutation' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' @@ -79,10 +80,12 @@ if (typeof window !== 'undefined') { function createStorageExplorerState({ projectRef, + connectionString, resumableUploadUrl, supabaseClient, }: { projectRef: string + connectionString: string resumableUploadUrl: string supabaseClient?: () => Promise> }) { @@ -93,6 +96,7 @@ function createStorageExplorerState({ const state = proxy({ projectRef, + connectionString, supabaseClient, resumableUploadUrl, uploadProgresses: [] as UploadProgress[], @@ -591,7 +595,18 @@ function createStorageExplorerState({ try { const isDeleteFolder = true const files = await state.getAllItemsAlongFolder(folder) - await state.deleteFiles({ files: files as any[], isDeleteFolder }) + + if (files.length === 0) { + // [Joshen] This is to self-remediate orphan prefixes + await deleteBucketPrefix({ + projectRef: state.projectRef, + connectionString: state.connectionString, + bucketId: state.selectedBucket.id, + prefix: folder.path, + }) + } else { + await state.deleteFiles({ files: files as any[], isDeleteFolder }) + } state.popColumnAtIndex(folder.columnIndex) state.popOpenedFoldersAtIndex(folder.columnIndex - 1) @@ -607,7 +622,6 @@ function createStorageExplorerState({ await state.refetchAllOpenedFolders() state.setSelectedItemsToDelete([]) - toast.success(`Successfully deleted ${folder.name}`) } catch (error: any) { toast.error(`Failed to delete folder: ${error.message}`) @@ -1411,7 +1425,6 @@ function createStorageExplorerState({ isDeleteFolder?: boolean }) => { state.setSelectedFilePreview(undefined) - let progress = 0 // If every file has the 'prefix' property, then just construct the prefix // directly (from delete folder). Otherwise go by the opened folders. @@ -1429,38 +1442,14 @@ function createStorageExplorerState({ state.clearSelectedItems() - const toastId = toast( - , - { closeButton: false, position: 'top-right' } - ) + const toastId = toast.loading(`Deleting ${prefixes.length} file(s)...`) - // batch BATCH_SIZE prefixes per request - const batches = chunk(prefixes, BATCH_SIZE).map((batch) => () => { - progress = progress + batch.length / prefixes.length - return deleteBucketObject({ - projectRef: state.projectRef, - bucketId: state.selectedBucket.id, - paths: batch as string[], - }) + await deleteBucketObject({ + projectRef: state.projectRef, + bucketId: state.selectedBucket.id, + paths: prefixes, }) - // make BATCH_SIZE requests at the same time - await chunk(batches, BATCH_SIZE).reduce(async (previousPromise, nextBatch) => { - await previousPromise - await Promise.all(nextBatch.map((batch) => batch())) - toast( - , - { - id: toastId, - closeButton: false, - position: 'top-right', - } - ) - }, Promise.resolve()) - if (!isDeleteFolder) { // If parent folders are empty, reinstate .emptyFolderPlaceholder to persist them const parentFolderPrefixes = uniq( @@ -1790,6 +1779,7 @@ type StorageExplorerState = ReturnType const DEFAULT_STATE_CONFIG = { projectRef: '', + connectionString: '', resumableUploadUrl: '', supabaseClient: undefined, } @@ -1823,6 +1813,7 @@ export const StorageExplorerStateContextProvider = ({ children }: PropsWithChild setState( createStorageExplorerState({ projectRef: project?.ref ?? '', + connectionString: project.connectionString ?? '', supabaseClient: async () => { try { const data = await getTemporaryAPIKey({ projectRef: project.ref }) From efd8f161ccd72a232b191607bc6bea4ca477c587 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Wed, 17 Sep 2025 18:31:13 +1000 Subject: [PATCH 3/6] =?UTF-8?q?feat:=20select=20=E2=80=9825=20banner=20(#3?= =?UTF-8?q?8773)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * first push * select branding * banner data * fix: type errors --- apps/www/components/Nav/index.tsx | 3 +- .../public/images/select/supabase-select.svg | 4 +++ .../src/Banners/AnnouncementBanner.tsx | 6 ++-- .../ui-patterns/src/Banners/LW15Banner.tsx | 6 ++-- .../ui-patterns/src/Banners/SelectBanner.tsx | 30 +++++++++++++++++++ packages/ui-patterns/src/Banners/data.json | 11 +++---- .../ui-patterns/src/PromoToast/PromoToast.tsx | 2 +- .../ui/src/layout/banners/Announcement.tsx | 6 ++-- 8 files changed, 52 insertions(+), 16 deletions(-) create mode 100644 apps/www/public/images/select/supabase-select.svg create mode 100644 packages/ui-patterns/src/Banners/SelectBanner.tsx diff --git a/apps/www/components/Nav/index.tsx b/apps/www/components/Nav/index.tsx index a8493733124d7..7e631d3717042 100644 --- a/apps/www/components/Nav/index.tsx +++ b/apps/www/components/Nav/index.tsx @@ -7,7 +7,7 @@ import { useWindowSize } from 'react-use' import { useIsLoggedIn, useUser } from 'common' import { Button, buttonVariants, cn } from 'ui' -import { AuthenticatedDropdownMenu } from 'ui-patterns' +import { AnnouncementBanner, AuthenticatedDropdownMenu } from 'ui-patterns' import { useSendTelemetryEvent } from 'lib/telemetry' import GitHubButton from './GitHubButton' @@ -88,6 +88,7 @@ const Nav = ({ hideNavbar, stickyNavbar = true }: Props) => { return ( <> +
+ + + diff --git a/packages/ui-patterns/src/Banners/AnnouncementBanner.tsx b/packages/ui-patterns/src/Banners/AnnouncementBanner.tsx index fdee27fac09b9..73795aeaf71b8 100644 --- a/packages/ui-patterns/src/Banners/AnnouncementBanner.tsx +++ b/packages/ui-patterns/src/Banners/AnnouncementBanner.tsx @@ -1,13 +1,13 @@ import { Announcement } from 'ui/src/layout/banners' -import LW15Banner from './LW15Banner' +import SelectBanner from './SelectBanner' import announcementJSON from './data.json' export const announcement = announcementJSON export const AnnouncementBanner = () => { return ( - - + + ) } diff --git a/packages/ui-patterns/src/Banners/LW15Banner.tsx b/packages/ui-patterns/src/Banners/LW15Banner.tsx index 8e87d463ebb33..84268c24c4449 100644 --- a/packages/ui-patterns/src/Banners/LW15Banner.tsx +++ b/packages/ui-patterns/src/Banners/LW15Banner.tsx @@ -53,11 +53,11 @@ export function LW15Banner() {

- {announcement.text} + {announcement.title}

-

{announcement.launch}

+

{announcement.desc}

diff --git a/packages/ui-patterns/src/Banners/SelectBanner.tsx b/packages/ui-patterns/src/Banners/SelectBanner.tsx new file mode 100644 index 0000000000000..32afcf91928bf --- /dev/null +++ b/packages/ui-patterns/src/Banners/SelectBanner.tsx @@ -0,0 +1,30 @@ +import Link from 'next/link' +import { Button } from 'ui/src/components/Button' +import announcement from './data.json' + +export function SelectBanner() { + return ( +
+
+
+ + Supabase Select + + +

+ {announcement.desc} + {announcement.verbose} +

+ + +
+
+
+ ) +} + +export default SelectBanner diff --git a/packages/ui-patterns/src/Banners/data.json b/packages/ui-patterns/src/Banners/data.json index a7f1e2fee1432..bd3f41eb0f4ea 100644 --- a/packages/ui-patterns/src/Banners/data.json +++ b/packages/ui-patterns/src/Banners/data.json @@ -1,7 +1,8 @@ { - "text": "", - "launch": "", - "launchDate": "2025-07-17T08:00:00.000-07:00", - "link": "#", - "cta": "Learn more" + "title": "Supabase Select", + "desc": "Our first user conference · October 3, San Francisco", + "verbose": " · Guillermo Rauch, Dylan Field, and more", + "link": "https://select.supabase.com/", + "target": "_blank", + "button": "Save your seat" } diff --git a/packages/ui-patterns/src/PromoToast/PromoToast.tsx b/packages/ui-patterns/src/PromoToast/PromoToast.tsx index af51c5e351544..6c7c49049d277 100644 --- a/packages/ui-patterns/src/PromoToast/PromoToast.tsx +++ b/packages/ui-patterns/src/PromoToast/PromoToast.tsx @@ -46,7 +46,7 @@ const PromoToast = () => { poster={`${process.env.NEXT_PUBLIC_SUPABASE_URL}/storage/v1/object/public/images/launch-week/lw15/assets/lw15-galaxy.png`} />
- {announcement.text} + {announcement.title}

diff --git a/packages/ui/src/layout/banners/Announcement.tsx b/packages/ui/src/layout/banners/Announcement.tsx index 364280e505035..60dc358c34a44 100644 --- a/packages/ui/src/layout/banners/Announcement.tsx +++ b/packages/ui/src/layout/banners/Announcement.tsx @@ -9,8 +9,8 @@ import { X } from 'lucide-react' export interface AnnouncementProps { show: boolean - text: string - launchDate: string + title: string + launchDate: string | null link: string badge?: string } @@ -66,7 +66,7 @@ const Announcement = ({
{dismissable && !isLaunchWeekSection && (
From 11b6f77db45ed8a2dab8b6f1feacd9b70f4f1d37 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Wed, 17 Sep 2025 16:51:14 +0800 Subject: [PATCH 4/6] Support for projects pagination (Part 2) (#38654) * Create OrganizationProjectSelector which handles infinite scrolling in Popover * Use OrganizationProjectSelector in InviteMemberButton * Refactor OrganizationProjectSelector to optimize UX * Use OrganizationProjectSelector in SupportFormV2 * Update apps/studio/data/projects/projects-infinite-query.ts * Nit --------- Co-authored-by: Jordi Enric <37541088+jordienr@users.noreply.github.com> --- .../TeamSettings/InviteMemberButton.tsx | 114 +-------- .../interfaces/Support/SupportFormV2.tsx | 81 ++++-- .../layouts/AppLayout/ProjectDropdown.tsx | 152 ++++------- .../ui/OrganizationProjectSelector.tsx | 241 ++++++++++++++++++ 4 files changed, 363 insertions(+), 225 deletions(-) create mode 100644 apps/studio/components/ui/OrganizationProjectSelector.tsx diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx index 244c40cd725c4..a1c756df52dff 100644 --- a/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx +++ b/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx @@ -1,6 +1,5 @@ import { zodResolver } from '@hookform/resolvers/zod' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { Check, ChevronsUpDown } from 'lucide-react' import Link from 'next/link' import { useEffect, useState } from 'react' import { useForm } from 'react-hook-form' @@ -10,10 +9,10 @@ import * as z from 'zod' import { useParams } from 'common' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import InformationBox from 'components/ui/InformationBox' +import { OrganizationProjectSelector } from 'components/ui/OrganizationProjectSelector' import { useOrganizationCreateInvitationMutation } from 'data/organization-members/organization-invitation-create-mutation' import { useOrganizationRolesV2Query } from 'data/organization-members/organization-roles-query' import { useOrganizationMembersQuery } from 'data/organizations/organization-members-query' -import { useProjectsQuery } from 'data/projects/projects-query' import { useHasAccessToProjectLevelPermissions } from 'data/subscriptions/org-subscription-query' import { doPermissionsCheck, useGetPermissions } from 'hooks/misc/useCheckPermissions' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' @@ -21,12 +20,6 @@ import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization import { useProfile } from 'lib/profile' import { Button, - CommandEmpty_Shadcn_, - CommandGroup_Shadcn_, - CommandInput_Shadcn_, - CommandItem_Shadcn_, - CommandList_Shadcn_, - Command_Shadcn_, Dialog, DialogContent, DialogHeader, @@ -38,17 +31,12 @@ import { FormField_Shadcn_, Form_Shadcn_, Input_Shadcn_, - PopoverContent_Shadcn_, - PopoverTrigger_Shadcn_, - Popover_Shadcn_, - ScrollArea, SelectContent_Shadcn_, SelectGroup_Shadcn_, SelectItem_Shadcn_, SelectTrigger_Shadcn_, Select_Shadcn_, Switch, - cn, } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { useGetRolesManagementPermissions } from './TeamSettings.utils' @@ -66,15 +54,10 @@ export const InviteMemberButton = () => { const [isOpen, setIsOpen] = useState(false) const [projectDropdownOpen, setProjectDropdownOpen] = useState(false) - const { data } = useProjectsQuery() const { data: members } = useOrganizationMembersQuery({ slug }) const { data: allRoles, isSuccess } = useOrganizationRolesV2Query({ slug }) const orgScopedRoles = allRoles?.org_scoped_roles ?? [] - const orgProjects = (data?.projects ?? []) - .filter((project) => project.organization_id === organization?.id) - .sort((a, b) => a.name.localeCompare(b.name)) - const currentPlan = organization?.plan const hasAccessToProjectLevelPermissions = useHasAccessToProjectLevelPermissions(slug as string) @@ -118,7 +101,7 @@ export const InviteMemberButton = () => { defaultValues: { email: '', role: '', applyToOrg: true, projectRef: '' }, }) - const { applyToOrg } = form.watch() + const { applyToOrg, projectRef } = form.watch() const onInviteMember = async (values: z.infer) => { if (!slug) return console.error('Slug is required') @@ -164,17 +147,9 @@ export const InviteMemberButton = () => { const developerRole = orgScopedRoles.find((role) => role.name === 'Developer') if (developerRole !== undefined) form.setValue('role', developerRole.id.toString()) } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [isSuccess, isOpen]) - useEffect(() => { - if (!applyToOrg) { - const firstProject = orgProjects?.[0] - if (firstProject !== undefined) form.setValue('projectRef', firstProject.ref) - } else { - form.setValue('projectRef', '') - } - }, [applyToOrg]) - return ( @@ -278,81 +253,16 @@ export const InviteMemberButton = () => { description="You can assign roles to multiple projects once the invite is accepted" > - - - - - - { - const project = orgProjects.find((project) => project.ref === value) - const projectName = project?.name.toLowerCase() - if ( - projectName !== undefined && - projectName.includes(search.toLowerCase()) - ) { - return 1 - } else if (value.includes(search)) { - return 1 - } else { - return 0 - } - }} - > - - - No projects found... - - 7 && - 'max-h-[210px] overflow-y-auto' - )} - > - {orgProjects.map((project) => { - return ( - { - form.setValue('projectRef', value) - setProjectDropdownOpen(false) - }} - > - - {project.name} - - ) - })} - - - - - - + setOpen={setProjectDropdownOpen} + searchPlaceholder="Search project..." + onSelect={(project) => field.onChange(project.ref)} + onInitialLoad={(projects) => field.onChange(projects[0]?.ref ?? '')} + /> )} diff --git a/apps/studio/components/interfaces/Support/SupportFormV2.tsx b/apps/studio/components/interfaces/Support/SupportFormV2.tsx index 7246e96887c9b..c0c1c7ef988ee 100644 --- a/apps/studio/components/interfaces/Support/SupportFormV2.tsx +++ b/apps/studio/components/interfaces/Support/SupportFormV2.tsx @@ -1,6 +1,17 @@ import { zodResolver } from '@hookform/resolvers/zod' import * as Sentry from '@sentry/nextjs' -import { Book, ChevronRight, ExternalLink, Github, Loader2, Mail, Plus, X } from 'lucide-react' +import { + Book, + Check, + ChevronRight, + ChevronsUpDown, + ExternalLink, + Github, + Loader2, + Mail, + Plus, + X, +} from 'lucide-react' import Link from 'next/link' import { ChangeEvent, useEffect, useMemo, useRef, useState } from 'react' import { SubmitHandler, useForm } from 'react-hook-form' @@ -9,6 +20,7 @@ import * as z from 'zod' import { useDocsSearch, useParams, type DocsSearchResult as Page } from 'common' import { CLIENT_LIBRARIES } from 'common/constants' +import { OrganizationProjectSelector } from 'components/ui/OrganizationProjectSelector' import { getProjectAuthConfig } from 'data/auth/auth-config-query' import { useSendSupportTicketMutation } from 'data/feedback/support-ticket-send' import { useOrganizationsQuery } from 'data/organizations/organizations-query' @@ -24,6 +36,8 @@ import { Collapsible_Shadcn_, CollapsibleContent_Shadcn_, CollapsibleTrigger_Shadcn_, + CommandGroup_Shadcn_, + CommandItem_Shadcn_, Form_Shadcn_, FormControl_Shadcn_, FormField_Shadcn_, @@ -41,6 +55,7 @@ import { import { Admonition } from 'ui-patterns' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { MultiSelectV2 } from 'ui-patterns/MultiSelectDeprecated/MultiSelectV2' +import ShimmeringLoader from 'ui-patterns/ShimmeringLoader' import { IPV4SuggestionAlert } from './IPV4SuggestionAlert' import { LibrarySuggestions } from './LibrarySuggestions' import { PlanExpectationInfoBox } from './PlanExpectationInfoBox' @@ -388,31 +403,53 @@ export const SupportFormV2 = ({ render={({ field }) => ( - { - if (val.length > 0) field.onChange(val) - }} - > - - - - - - {projects?.map((project) => ( - - {project.name} - - ))} - - - + field.onChange(projects[0]?.ref ?? 'no-project')} + onSelect={(project) => field.onChange(project.ref)} + renderTrigger={({ isLoading, project }) => ( + + )} + renderActions={(setOpen) => ( + + { + field.onChange('no-project') + setOpen(false) + }} + > + {field.value === 'no-project' && } +

+ No specific project +

+
+
+ )} + />
)} /> + {organizationSlug && subscriptionPlanId !== 'enterprise' && category !== 'Login_issues' && ( diff --git a/apps/studio/components/layouts/AppLayout/ProjectDropdown.tsx b/apps/studio/components/layouts/AppLayout/ProjectDropdown.tsx index afabafbead6ec..de923374f34a2 100644 --- a/apps/studio/components/layouts/AppLayout/ProjectDropdown.tsx +++ b/apps/studio/components/layouts/AppLayout/ProjectDropdown.tsx @@ -5,35 +5,14 @@ import { ParsedUrlQuery } from 'querystring' import { useState } from 'react' import { useParams } from 'common' +import { OrganizationProjectSelector } from 'components/ui/OrganizationProjectSelector' import ShimmeringLoader from 'components/ui/ShimmeringLoader' -import { ProjectInfo, useProjectsQuery } from 'data/projects/projects-query' +import { useProjectsQuery } from 'data/projects/projects-query' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { IS_PLATFORM } from 'lib/constants' -import type { Organization } from 'types' -import { - Button, - CommandEmpty_Shadcn_, - CommandGroup_Shadcn_, - CommandInput_Shadcn_, - CommandItem_Shadcn_, - CommandList_Shadcn_, - CommandSeparator_Shadcn_, - Command_Shadcn_, - PopoverContent_Shadcn_, - PopoverTrigger_Shadcn_, - Popover_Shadcn_, - ScrollArea, - cn, -} from 'ui' - -// [Fran] the idea is to let users change projects without losing the current page, -// but at the same time we need to redirect correctly between urls that might be -// unique to a project e.g. '/project/projectRef/editor/tableId' -// Right now, I'm gonna assume that any router query after the projectId, -// is a unique project id/marker so we'll redirect the user to the -// highest common route with just projectRef in the router queries. +import { Button, CommandGroup_Shadcn_, CommandItem_Shadcn_, cn } from 'ui' export const sanitizeRoute = (route: string, routerQueries: ParsedUrlQuery) => { const queryArray = Object.entries(routerQueries) @@ -53,40 +32,6 @@ export const sanitizeRoute = (route: string, routerQueries: ParsedUrlQuery) => { } } -const ProjectLink = ({ - project, - setOpen, -}: { - project: ProjectInfo - organization?: Organization - setOpen: (value: boolean) => void -}) => { - const router = useRouter() - const { ref } = useParams() - const sanitizedRoute = sanitizeRoute(router.route, router.query) - - // [Joshen] Temp while we're interim between v1 and v2 billing - let href = sanitizedRoute?.replace('[ref]', project.ref) ?? `/project/${project.ref}` - - return ( - { - router.push(href) - setOpen(false) - }} - onClick={() => setOpen(false)} - > - - {project.name} - {project.ref === ref && } - - - ) -} - export const ProjectDropdown = () => { const router = useRouter() const { ref } = useParams() @@ -122,57 +67,62 @@ export const ProjectDropdown = () => { {selectedProject?.name} - - + + { + router.push(`/project/${project.ref}`) + }} + renderTrigger={() => ( + )} + + + + setSearch('')} + /> + + + {isLoadingProjects ? ( + <> +
+ +
+
+ +
+ + ) : isErrorProjects ? ( +
+

Failed to retrieve projects

+ + + + + Error: {projectsError?.message} + +
+ ) : ( + <> + {search.length > 0 && projects.length === 0 && ( +

+ No projects found based on your search +

+ )} + 7 ? 'h-[210px]' : ''}> + {projects?.map((project) => ( + { + onSelect?.(project) + setOpen(false) + }} + onClick={() => setOpen(false)} + > + {!!renderRow ? ( + renderRow(project) + ) : ( +
+ {checkPosition === 'left' && project.ref === selectedRef && ( + + )} + {project.name} + {checkPosition === 'right' && project.ref === selectedRef && ( + + )} +
+ )} +
+ ))} +
+ {hasNextPage && ( +
+ +
+ )} + + + )} + + {!!renderActions && ( + <> + {/* [Joshen] Not using CommandSeparator to persist this while searching */} +
+ {renderActions(setOpen)} + + )} + + + + + ) +} From 08960d0f481e7ddd9f0a965a295e5133f0439c8c Mon Sep 17 00:00:00 2001 From: Jordi Enric <37541088+jordienr@users.noreply.github.com> Date: Wed, 17 Sep 2025 11:19:16 +0200 Subject: [PATCH 5/6] FE-1856: fix shared api report filters (#38775) * fix filters * handle numbers --- .../Reports/Reports.constants.test.ts | 105 ++++++++++++++++++ .../interfaces/Reports/Reports.constants.ts | 25 +++-- 2 files changed, 122 insertions(+), 8 deletions(-) create mode 100644 apps/studio/components/interfaces/Reports/Reports.constants.test.ts diff --git a/apps/studio/components/interfaces/Reports/Reports.constants.test.ts b/apps/studio/components/interfaces/Reports/Reports.constants.test.ts new file mode 100644 index 0000000000000..50d40d413fe41 --- /dev/null +++ b/apps/studio/components/interfaces/Reports/Reports.constants.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect } from 'vitest' +import { generateRegexpWhere } from './Reports.constants' +import type { ReportFilterItem } from './Reports.types' + +describe('generateRegexpWhere', () => { + it('should return empty string when no filters provided', () => { + const result = generateRegexpWhere([]) + expect(result).toBe('') + }) + + it('should generate WHERE clause for single filter', () => { + const filters: ReportFilterItem[] = [ + { + key: 'request.path', + value: '/api/users', + compare: 'is', + }, + ] + const result = generateRegexpWhere(filters, true) + expect(result).toBe("WHERE request.path = '/api/users'") + }) + + it('should generate AND clause for single filter with prepend=false', () => { + const filters: ReportFilterItem[] = [ + { + key: 'request.path', + value: '/api/users', + compare: 'is', + }, + ] + const result = generateRegexpWhere(filters, false) + expect(result).toBe("AND request.path = '/api/users'") + }) + + it('should handle different comparison operators', () => { + const filters: ReportFilterItem[] = [ + { + key: 'request.path', + value: '/api/*', + compare: 'matches', + }, + { + key: 'response.status_code', + value: 404, + compare: 'is', + }, + ] + const result = generateRegexpWhere(filters, true) + expect(result).toBe( + "WHERE REGEXP_CONTAINS(request.path, '/api/*') AND response.status_code = 404" + ) + }) + + it('should handle values with quotes', () => { + const filters: ReportFilterItem[] = [ + { + key: 'request.path', + value: '"/api/users"', + compare: 'is', + }, + ] + + const result = generateRegexpWhere(filters, true) + expect(result).toBe(`WHERE request.path = "/api/users"`) + }) + + it('should handle values without quotes', () => { + const filters: ReportFilterItem[] = [ + { + key: 'request.path', + value: '/api/users', + compare: 'is', + }, + ] + + const result = generateRegexpWhere(filters, true) + expect(result).toBe("WHERE request.path = '/api/users'") + }) + + it('should handle values with quotes and lowercase', () => { + const filters: ReportFilterItem[] = [ + { + key: 'request.path', + value: '"/Api/Users"', + compare: 'is', + }, + ] + + const result = generateRegexpWhere(filters, true) + expect(result).toBe(`WHERE request.path = "/api/users"`) + }) + + it('should handle numbers', () => { + const filters: ReportFilterItem[] = [ + { + key: 'request.status_code', + value: 200, + compare: 'is', + }, + ] + + const result = generateRegexpWhere(filters, true) + expect(result).toBe(`WHERE request.status_code = 200`) + }) +}) diff --git a/apps/studio/components/interfaces/Reports/Reports.constants.ts b/apps/studio/components/interfaces/Reports/Reports.constants.ts index 010d309650868..b80f349bb6b97 100644 --- a/apps/studio/components/interfaces/Reports/Reports.constants.ts +++ b/apps/studio/components/interfaces/Reports/Reports.constants.ts @@ -86,25 +86,34 @@ export const generateRegexpWhere = (filters: ReportFilterItem[], prepend = true) const normalizedKey = [splitKey[splitKey.length - 2], splitKey[splitKey.length - 1]].join('.') const filterKey = filter.key.includes('.') ? normalizedKey : filter.key + const hasQuotes = + filter.value.toString().includes('"') || filter.value.toString().includes("'") + + const valueIsNumber = !isNaN(Number(filter.value)) + const valueWithQuotes = !valueIsNumber && hasQuotes ? filter.value : `'${filter.value}'` + const lowercaseValue = !valueIsNumber && String(valueWithQuotes).toLowerCase() + + const finalValue = valueIsNumber ? filter.value : lowercaseValue + // Handle different comparison operators switch (filter.compare) { case 'matches': - return `REGEXP_CONTAINS(${filterKey}, '${filter.value}')` + return `REGEXP_CONTAINS(${filterKey}, ${finalValue})` case 'is': - return `${filterKey} = ${filter.value}` + return `${filterKey} = ${finalValue}` case '!=': - return `${filterKey} != ${filter.value}` + return `${filterKey} != ${finalValue}` case '>=': - return `${filterKey} >= ${filter.value}` + return `${filterKey} >= ${finalValue}` case '<=': - return `${filterKey} <= ${filter.value}` + return `${filterKey} <= ${finalValue}` case '>': - return `${filterKey} > ${filter.value}` + return `${filterKey} > ${finalValue}` case '<': - return `${filterKey} < ${filter.value}` + return `${filterKey} < ${finalValue}` default: // Fallback to exact match for unknown operators - return `${filterKey} = ${filter.value}` + return `${filterKey} = ${finalValue}` } }) .filter(Boolean) // Remove any null/undefined conditions From 4236f8781e4491dc40ac182bfa65383b36c3e532 Mon Sep 17 00:00:00 2001 From: Jordi Enric <37541088+jordienr@users.noreply.github.com> Date: Wed, 17 Sep 2025 11:30:57 +0200 Subject: [PATCH 6/6] add status code to tags instead of context (#38706) * add attachment with error for better filtering * fix type error * move status code to tag * skip 4xxs in sentry * Update apps/studio/data/permissions/permissions-query.ts Co-authored-by: Charis <26616127+charislam@users.noreply.github.com> * Update apps/studio/data/permissions/permissions-query.ts Co-authored-by: Charis <26616127+charislam@users.noreply.github.com> --------- Co-authored-by: Charis <26616127+charislam@users.noreply.github.com> --- apps/studio/data/permissions/permissions-query.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/studio/data/permissions/permissions-query.ts b/apps/studio/data/permissions/permissions-query.ts index 0a12374f13481..783ed966bd37d 100644 --- a/apps/studio/data/permissions/permissions-query.ts +++ b/apps/studio/data/permissions/permissions-query.ts @@ -11,11 +11,18 @@ export type PermissionsResponse = Permission[] export async function getPermissions(signal?: AbortSignal) { const { data, error } = await get('/platform/profile/permissions', { signal }) if (error) { + const statusCode = (!!error && typeof error === 'object' && (error as any).code) || 'unknown' + + // This is to avoid sending 4XX errors + // But we still want to capture errors without a status code or 5XXs + // since those may require investigation if they spike + const sendError = statusCode >= 500 || statusCode === 'unknown' handleError(error, { - alwaysCapture: true, + alwaysCapture: sendError, sentryContext: { tags: { permissionsQuery: true, + statusCode, }, contexts: { rawError: error,