diff --git a/.claude/skills/safe-sql-execution/SKILL.md b/.claude/skills/safe-sql-execution/SKILL.md index 6cf59379d0419..c0f43cb8f8f14 100644 --- a/.claude/skills/safe-sql-execution/SKILL.md +++ b/.claude/skills/safe-sql-execution/SKILL.md @@ -279,7 +279,7 @@ function MyBadComponent() { ### Round-tripping SQL from the database (NOT snippet content) ```ts -// ✅ GOOD: SQL from the database is promoted to SafeSqlFragment at the point +// ✅ GOOD: SQL from the database is promoted to SafeSqlFragment at the point // of fetching // data/function-definitions.ts @@ -323,10 +323,10 @@ function MyComponent() { ### Snippet content is ALWAYS UNSAFE -Snippets are auto-persisted to the database and can be created or modified -through externally influenceable channels (e.g., prefilled from URL params). -The `unchecked_sql` property is typed as `UntrustedSqlFragment` to enforce this -— it must only be promoted to `SafeSqlFragment` via `acceptUntrustedSql` in an +Snippets are auto-persisted to the database and can be created or modified +through externally influenceable channels (e.g., prefilled from URL params). +The `unchecked_sql` property is typed as `UntrustedSqlFragment` to enforce this +— it must only be promoted to `SafeSqlFragment` via `acceptUntrustedSql` in an event handler that requires explicit user action. ```ts @@ -376,3 +376,65 @@ function SnippetRunner({ snippet }: { snippet: Snippet }) { ) } ``` + +## Analytics SQL (BigQuery / ClickHouse) + +The same security model applies to analytics queries, which target BigQuery +or ClickHouse via the +`/platform/projects/{ref}/analytics/endpoints/logs.all{,.otel}` endpoints. +Filter keys and values from URL parameters and UI inputs are spliced into SQL +that runs against the project's logs, so the same injection risk exists. + +The brand and helpers live in `apps/studio/data/logs/safe-analytics-sql.ts`, +intentionally **disjoint** from the pg-meta `SafeSqlFragment` brand: + +- `SafeLogSqlFragment` — branded type for analytics SQL. +- `safeSql` — template tag that only accepts `SafeLogSqlFragment` + interpolations. +- `analyticsLiteral(value)` — sanitizes string/number/boolean literals. +- `quotedIdent(name)` — validates and backtick-quotes dotted identifiers. +- `keyword(value, allowed)` — validates against an allow-list of operators. +- `joinSqlFragments(fragments, separator)` — composes already-branded + fragments. + +The brands are kept separate because escape semantics differ — Postgres-safe +`E'…'` strings, `::jsonb` casts, and double-quoted identifiers are unsafe for +BigQuery and/or ClickHouse, and vice versa. Crossing the brands would silently +emit unsafe SQL. + +The wire-boundary wrapper is `executeAnalyticsSql` in +`apps/studio/data/logs/execute-analytics-sql.ts`, analogous to pg-meta's +`executeSql`. It accepts only `SafeLogSqlFragment` for its `sql` parameter, so +raw strings are rejected at compile time. A grep-based vitest +(`apps/studio/tests/unit/lints/analytics-sql-boundary.test.ts`) prevents +regressions by failing the build if any file outside +`execute-analytics-sql.ts` calls `post()` or `get()` directly against +`logs.all` or `logs.all.otel`. + +```ts +import { executeAnalyticsSql } from '@/data/logs/execute-analytics-sql' +import { analyticsLiteral, quotedIdent, safeSql } from '@/data/logs/safe-analytics-sql' + +// ✅ GOOD: every interpolation is sanitized. +const sql = safeSql` + SELECT timestamp, event_message + FROM ${quotedIdent(table)} + WHERE id = ${analyticsLiteral(id)} +` + +await executeAnalyticsSql({ + projectRef, + endpoint: '/platform/projects/{ref}/analytics/endpoints/logs.all', + sql, + iso_timestamp_start, + iso_timestamp_end, +}) +``` + +```ts +// 🛑 BAD: raw string interpolation. This fails to type-check at the +// executeAnalyticsSql boundary because the result is `string`, not +// `SafeLogSqlFragment`. +const sql = `SELECT * FROM ${table} WHERE id = '${id}'` +await executeAnalyticsSql({ projectRef, endpoint, sql, ... }) +``` diff --git a/apps/docs/DEVELOPERS.md b/apps/docs/DEVELOPERS.md index 4413ab845776c..1f0c0d0b8a900 100644 --- a/apps/docs/DEVELOPERS.md +++ b/apps/docs/DEVELOPERS.md @@ -18,7 +18,7 @@ For a complete run-down on how all of our tools work together, see the main DEVE 1. Follow the steps outlined in the Local Development section of the main [DEVELOPERS.md](https://github.com/supabase/supabase/blob/master/DEVELOPERS.md) 2. If you work at Supabase, run `dev:secrets:pull` to pull down the internal environment variables. If you're a community member, create a `.env` file and add this line to it: `NEXT_PUBLIC_IS_PLATFORM=false` -3. Start the local docs site by navigating to `/apps/docs` and running `npm run dev` +3. Start the local docs site by navigating to `/apps/docs` and running `pnpm run dev` 4. Visit http://localhost:3001/docs in your browser - don't forget to append the `/docs` to the end 5. Your local site should look exactly like [https://supabase.com/docs](https://supabase.com/docs) diff --git a/apps/docs/README.md b/apps/docs/README.md index 7803882d6c738..b961a0517466e 100644 --- a/apps/docs/README.md +++ b/apps/docs/README.md @@ -6,21 +6,6 @@ Supabase Reference Docs If you are a maintainer of any tools in the Supabase ecosystem, you can use this site to provide documentation for the tools & libraries that you maintain. -## Versioning - -All tools have versioned docs, which are kept in separate folders. For example, the CLI has the following folders and files: - -- `cli`: the "next" release. -- `cli_spec`: contains the DocSpec for the "next" release (see below). -- `cli_versioned_docs`: a version of the documentation for every release (including the most current version). -- `cli_versioned_sidebars`: a version of the sidebar for every release (including the most current version). - -When you release a new version of a tool, you should also release a new version of the docs. You can do this via the command line. For example, if you just released the CLI version `1.0.1`: - -``` -npm run cli:version 1.0.1 -``` - ## DocSpec We use documentation specifications which can be used to generate human-readable docs. diff --git a/apps/docs/components/Navigation/NavigationMenu/GlobalNavigationMenu.tsx b/apps/docs/components/Navigation/NavigationMenu/GlobalNavigationMenu.tsx index df697cb5b6d2a..627f1dddb6d3f 100644 --- a/apps/docs/components/Navigation/NavigationMenu/GlobalNavigationMenu.tsx +++ b/apps/docs/components/Navigation/NavigationMenu/GlobalNavigationMenu.tsx @@ -170,7 +170,7 @@ export const MenuItem = React.forwardRef< ref={ref} className={cn( 'group/menu-item flex items-center gap-2', - 'w-full flex h-8 items-center text-foreground-light text-sm hover:text-foreground select-none rounded-md py-2 leading-none no-underline outline-hidden! focus-visible:ring-2 focus-visible:ring-foreground-lighter focus-visible:text-foreground', + 'w-full flex h-8 items-center text-foreground-light text-sm hover:text-foreground select-none rounded-md p-2 leading-none no-underline outline-hidden! focus-visible:ring-2 focus-visible:ring-foreground-lighter focus-visible:text-foreground', className )} {...props} diff --git a/apps/docs/components/ProjectConfigVariables/ProjectConfigVariables.ComboBox.tsx b/apps/docs/components/ProjectConfigVariables/ProjectConfigVariables.ComboBox.tsx index 105cb3e45b6eb..364af234717ab 100644 --- a/apps/docs/components/ProjectConfigVariables/ProjectConfigVariables.ComboBox.tsx +++ b/apps/docs/components/ProjectConfigVariables/ProjectConfigVariables.ComboBox.tsx @@ -21,6 +21,7 @@ export interface ComboBoxOption { id: string value: string displayName: string + disabled?: boolean } export function ComboBox({ @@ -131,6 +132,7 @@ export function ComboBox({ {options.map((option) => ( { setOpen(false) diff --git a/apps/docs/components/ProjectConfigVariables/ProjectConfigVariables.tsx b/apps/docs/components/ProjectConfigVariables/ProjectConfigVariables.tsx index 2d3e35735caf5..5d1b080bde77e 100644 --- a/apps/docs/components/ProjectConfigVariables/ProjectConfigVariables.tsx +++ b/apps/docs/components/ProjectConfigVariables/ProjectConfigVariables.tsx @@ -138,10 +138,14 @@ function OrgProjectSelector() { : (projects! .map((project) => { const organization = organizations!.find((org) => org.id === project.organization_id)! + const paused = isProjectPaused(project) return { id: project.ref, value: toOrgProjectValue(organization, project), - displayName: toDisplayNameOrgProject(organization, project), + displayName: paused + ? `${toDisplayNameOrgProject(organization, project)} (paused)` + : toDisplayNameOrgProject(organization, project), + disabled: paused, } }) .filter(Boolean) as ComboBoxOption[]), @@ -162,10 +166,14 @@ function OrgProjectSelector() { if (storedOrg && storedProject && storedProject.organization_id === storedOrg.id) { setSelectedOrgProject(storedOrg, storedProject) - } else if (projects!.length > 0) { - const firstProject = projects![0] - const matchingOrg = organizations!.find((org) => org.id === firstProject.organization_id) - if (matchingOrg) setSelectedOrgProject(matchingOrg, firstProject) + } else { + const firstActiveProject = projects!.find((project) => !isProjectPaused(project)) + if (firstActiveProject) { + const matchingOrg = organizations!.find( + (org) => org.id === firstActiveProject.organization_id + ) + if (matchingOrg) setSelectedOrgProject(matchingOrg, firstActiveProject) + } } } }, [organizations, projects, selectedOrg, selectedProject, setSelectedOrgProject, stateSummary]) diff --git a/apps/docs/content/guides/auth/passkeys.mdx b/apps/docs/content/guides/auth/passkeys.mdx index 3165f7d46790b..d97e1ed3f3123 100644 --- a/apps/docs/content/guides/auth/passkeys.mdx +++ b/apps/docs/content/guides/auth/passkeys.mdx @@ -12,6 +12,12 @@ Passkey support is experimental. The API may change without notice. You must exp + + +**Requires `@supabase/supabase-js` v2.105.0 and later.** Upgrade your client library to use passkey authentication. + + + ## How does it work? Each sign-in or registration is a WebAuthn ceremony with three steps: diff --git a/apps/docs/content/guides/database/connecting-to-postgres.mdx b/apps/docs/content/guides/database/connecting-to-postgres.mdx index 9f811ac302c63..b1ee6e35da7c0 100644 --- a/apps/docs/content/guides/database/connecting-to-postgres.mdx +++ b/apps/docs/content/guides/database/connecting-to-postgres.mdx @@ -10,9 +10,24 @@ How you connect to your database depends on where you're connecting from: - For frontend applications, use the [Data API](#data-apis-and-client-libraries) - For Postgres clients, use a connection string - - For single sessions (for example, database GUIs) or Postgres native commands (for example, using client applications like [pg_dump](https://www.postgresql.org/docs/current/app-pgdump.html), [migrations](/docs/guides/deployment/database-migrations), [backup-restore](/docs/guides/platform/migrating-within-supabase/backup-restore), or specifying connections for [replication](/docs/guides/database/postgres/setup-replication-external)) use the [direct connection string](#direct-connection) if your environment supports IPv6. IPv4 available as [Add-on](/docs/guides/platform/ipv4-address). - - For application traffic from persistent clients, and support for both IPv4 and IPv6, use [pooler session mode](#pooler-session-mode) - - For application traffic from temporary clients (for example, serverless or edge functions) use [pooler transaction mode](#pooler-transaction-mode) + - **Use the [direct connection string](#direct-connection) for single sessions or Postgres native commands**. For example, database GUIs, client applications like [pg_dump](https://www.postgresql.org/docs/current/app-pgdump.html), [migrations](/docs/guides/deployment/database-migrations), [backup-restore](/docs/guides/platform/migrating-within-supabase/backup-restore), or specifying connections for [replication](/docs/guides/database/postgres/setup-replication-external). The direct endpoint is on IPv6, or on IPv4 if the project has the [IPv4 add-on](/docs/guides/platform/ipv4-address). + - **Use [pooler session mode](#pooler-session-mode)** for application traffic from persistent clients on IPv4-only networks, + - **Use [pooler transaction mode](#pooler-transaction-mode)** for application traffic from temporary clients (for example, serverless or edge functions). + +The table below summarizes each mode, its host and port, IP version support per project tier, and what it's best used for: + +| Mode | Host:Port | Free | Paid | Paid + IPv4 add-on | Best for | +| ----------------------------------------------- | --------------------------------------- | ---- | ---- | ------------------ | ------------------------------------------ | +| Direct connection | `db.[project-id].supabase.co:5432` | IPv6 | IPv6 | IPv4 | Migrations, `pg_dump`, long-lived backend | +| Shared pooler (Supavisor) - session mode | `aws-[region].pooler.supabase.com:5432` | IPv4 | IPv4 | IPv4 | Persistent backend on IPv4-only networks | +| Shared pooler (Supavisor) - transaction mode | `aws-[region].pooler.supabase.com:6543` | IPv4 | IPv4 | IPv4 | Serverless and edge functions | +| Dedicated pooler (PgBouncer) - transaction mode | `db.[project-id].supabase.co:6543` | - | IPv6 | IPv4 | High-performance app traffic on paid tiers | + + + +The IPv4 add-on is not dual-stack: enabling it swaps the project's IPv6 (AAAA) DNS record for an IPv4 (A) record, so the project endpoint becomes reachable only over IPv4. + + ## Quickstarts @@ -67,7 +82,7 @@ The direct connection string connects directly to your Postgres instance. It is -Direct connections use IPv6 by default. If your environment doesn't support IPv6, use [Supavisor session mode](#supavisor-session-mode) or get the [IPv4 add-on](/docs/guides/platform/ipv4-address). +Direct connections are on IPv6, or on IPv4 if the project has the [IPv4 add-on](/docs/guides/platform/ipv4-address). If your network is IPv4-only and you don't have the add-on, use [pooler session mode](#pooler-session-mode) instead. @@ -81,23 +96,23 @@ Get your project's direct connection string from your project dashboard by click ## Poolers -Every Supabase project includes a connection pooler. This is ideal for persistent servers when IPv6 is not supported. +Supabase offers two poolers. The **Shared Pooler** ([Supavisor](https://github.com/supabase/supavisor)) is multi-tenant, available on every project, and IPv4-only. The **Dedicated Pooler** ([PgBouncer](https://www.pgbouncer.org/)) is available on paid plans and co-located with your Postgres instance; like the direct connection, it is on IPv6, or on IPv4 if the project has the [IPv4 add-on](/docs/guides/platform/ipv4-address). ### Pooler session mode -The session mode connection string connects to your Postgres instance via a proxy. This is only recommended as an alternative to a Direct Connection, when connecting via an IPv4 network. +The session mode connection string connects to your Postgres instance via the Shared Pooler (Supavisor). This is only recommended as an alternative to a Direct Connection when connecting from an IPv4-only network. The connection string looks like this: ``` -postgres://postgres.apbkobhfnmcqqzqeeqss:[YOUR-PASSWORD]@aws-0-[REGION].pooler.supabase.com:5432/postgres +postgres://postgres.apbkobhfnmcqqzqeeqss:[YOUR-PASSWORD]@aws-[REGION].pooler.supabase.com:5432/postgres ``` Get your project's Session pooler connection string from your project dashboard by clicking [Connect](/dashboard/project/_?showConnect=true&method=session). ### Pooler transaction mode -The transaction mode connection string connects to your Postgres instance via a proxy which serves as a connection pooler. This is ideal for serverless or edge functions, which require many transient connections. +The transaction mode connection string connects to your Postgres instance via the Shared Pooler (Supavisor) in transaction-pooling mode. This is ideal for serverless or edge functions, which require many transient connections. @@ -108,14 +123,20 @@ Transaction mode does not support [prepared statements](https://postgresql.org/d The connection string looks like this: ``` -postgres://postgres:[YOUR-PASSWORD]@db.abcdefghijklmnopqrst.supabase.co:6543/postgres +postgres://postgres.apbkobhfnmcqqzqeeqss:[YOUR-PASSWORD]@aws-[REGION].pooler.supabase.com:6543/postgres ``` Get your project's Transaction pooler connection string from your project dashboard by clicking [Connect](/dashboard/project/_?showConnect=true&method=transaction). ## Dedicated pooler -For paying customers, we provision a Dedicated Pooler ([PgBouncer](https://www.pgbouncer.org/)) that's co-located with your Postgres database. This will require you to connect with IPv6 or, if that's not an option, you can use the [IPv4 add-on](/docs/guides/platform/ipv4-address). +For paying customers, we provision a Dedicated Pooler ([PgBouncer](https://www.pgbouncer.org/)) that's co-located with your Postgres database. The Dedicated Pooler runs in transaction mode only - for session mode, use the [Shared Pooler](#pooler-session-mode). It is reachable over IPv6, or over IPv4 if the project has the [IPv4 add-on](/docs/guides/platform/ipv4-address). + +The connection string looks like this: + +``` +postgres://postgres:[YOUR-PASSWORD]@db.abcdefghijklmnopqrst.supabase.co:6543/postgres +``` The Dedicated Pooler ensures best performance and latency, while using up more of your project's compute resources. If your network supports IPv6 or you have the IPv4 add-on, we encourage you to use the Dedicated Pooler over the Shared Pooler. @@ -131,11 +152,11 @@ You can use an application-side pooler or a server-side pooler (Supabase automat Application-side poolers are built into connection libraries and API servers, such as Prisma, SQLAlchemy, and PostgREST. They maintain several active connections with Postgres or a server-side pooler, reducing the overhead of establishing connections between queries. When deploying to static architecture, such as long-standing containers or VMs, application-side poolers are satisfactory on their own. -### Serverside poolers +### Server-side poolers Postgres connections are like a WebSocket. Once established, they are preserved until the client (application server) disconnects. A server might only make a single 10 ms query, but needlessly reserve its database connection for seconds or longer. -Serverside-poolers, such as Supabase's [Supavisor](https://github.com/supabase/supavisor) in transaction mode, sit between clients and the database and can be thought of as load balancers for Postgres connections. +Server-side poolers, such as Supabase's [Supavisor](https://github.com/supabase/supavisor) in transaction mode, sit between clients and the database and can be thought of as load balancers for Postgres connections. New migration files trigger migrations on the preview instance. + + diff --git a/apps/docs/features/docs/Reference.navigation.client.tsx b/apps/docs/features/docs/Reference.navigation.client.tsx index 75e2ea65fb2c1..3e991fd6dc1c7 100644 --- a/apps/docs/features/docs/Reference.navigation.client.tsx +++ b/apps/docs/features/docs/Reference.navigation.client.tsx @@ -6,7 +6,6 @@ import { BASE_PATH } from '~/lib/constants' import { debounce } from 'lodash-es' import { ChevronUp } from 'lucide-react' import Link from 'next/link' -import { usePathname } from 'next/navigation' import { Collapsible } from 'radix-ui' import type { HTMLAttributes, MouseEvent, PropsWithChildren } from 'react' import { @@ -37,6 +36,7 @@ function subscribeToPathname(callback: () => void) { if (patchCount === 0) { window.addEventListener('popstate', notifyPathnameListeners) + window.addEventListener('hashchange', notifyPathnameListeners) originalPushState = history.pushState.bind(history) history.pushState = (...args) => { @@ -58,6 +58,7 @@ function subscribeToPathname(callback: () => void) { if (patchCount === 0) { window.removeEventListener('popstate', notifyPathnameListeners) + window.removeEventListener('hashchange', notifyPathnameListeners) history.pushState = originalPushState! history.replaceState = originalReplaceState! originalPushState = null @@ -66,40 +67,29 @@ function subscribeToPathname(callback: () => void) { } } -function getPathname() { +function getLocation() { if (typeof window === 'undefined') return '' const pathname = window.location.pathname - return pathname.startsWith(BASE_PATH) ? pathname.slice(BASE_PATH.length) : pathname + const strippedPathname = pathname.startsWith(BASE_PATH) + ? pathname.slice(BASE_PATH.length) + : pathname + return `${strippedPathname}${window.location.hash}` } -function getServerPathname() { +function getServerLocation() { return '' } -function useCurrentPathname() { - return useSyncExternalStore(subscribeToPathname, getPathname, getServerPathname) +function useCurrentLocation() { + return useSyncExternalStore(subscribeToPathname, getLocation, getServerLocation) } -export function ReferenceContentScrollHandler({ - libPath, - version, - isLatestVersion, - children, -}: PropsWithChildren<{ - libPath: string - version: string - isLatestVersion: boolean -}>) { +export function ReferenceContentScrollHandler({ children }: PropsWithChildren) { const [initiallyScrolled, setInitiallyScrolled] = useState(false) - const pathname = usePathname() - useEffect(() => { if (!initiallyScrolled) { - const initialSelectedSection = pathname.replace( - `/reference/${libPath}/${isLatestVersion ? '' : `${version}/`}`, - '' - ) + const initialSelectedSection = window.location.hash.replace(/^#/, '') if (initialSelectedSection) { const section = document.getElementById(initialSelectedSection) if (section) { @@ -110,7 +100,7 @@ export function ReferenceContentScrollHandler({ setInitiallyScrolled(true) } - }, [pathname, libPath, version, isLatestVersion, initiallyScrolled]) + }, [initiallyScrolled]) return ( @@ -177,7 +167,7 @@ export function ReferenceNavigationScrollHandler({ } function deriveHref(basePath: string, section: AbbrevApiReferenceSection) { - return 'slug' in section ? `${basePath}/${section.slug}` : '' + return 'slug' in section ? `${basePath}#${section.slug}` : '' } function getLinkStyles(isActive: boolean, className?: string) { @@ -247,10 +237,10 @@ export function RefLink({ }) { const ref = useRef(null) - const pathname = useCurrentPathname() + const location = useCurrentLocation() const href = deriveHref(basePath, section) const isActive = - pathname === href || (pathname === basePath && href.replace(basePath, '') === '/introduction') + location === href || (location === basePath && href.replace(basePath, '') === '#introduction') useEffect(() => { if (ref.current) { @@ -293,15 +283,15 @@ export function RefLink({ function useCompoundRefLinkActive(basePath: string, section: AbbrevApiReferenceSection) { const [open, _setOpen] = useState(false) - const pathname = useCurrentPathname() + const location = useCurrentLocation() const parentHref = deriveHref(basePath, section) - const isParentActive = pathname === parentHref + const isParentActive = location === parentHref const childHrefs = useMemo( () => new Set((section.items || []).map((item) => deriveHref(basePath, item))), [basePath, section] ) - const isChildActive = childHrefs.has(pathname) + const isChildActive = childHrefs.has(location) const isActive = isParentActive || isChildActive diff --git a/apps/docs/features/docs/Reference.sdkPage.tsx b/apps/docs/features/docs/Reference.sdkPage.tsx index 9f03d453bde2c..5d095d520345c 100644 --- a/apps/docs/features/docs/Reference.sdkPage.tsx +++ b/apps/docs/features/docs/Reference.sdkPage.tsx @@ -21,11 +21,7 @@ export async function ClientSdkReferencePage({ sdkId, libVersion }: ClientSdkRef const menuData = NavItems[libraryMeta.meta[libVersion].libId] return ( - + {subcommandDetails.title} @@ -206,7 +206,7 @@ async function CliCommandSection({ link, section }: CliCommandSectionProps) { {(command.flags ?? []).length > 0 && ( <>

Flags

-
    +
      {command.flags.map((flag, index) => (
    • diff --git a/apps/docs/features/docs/Reference.selfHostingPage.tsx b/apps/docs/features/docs/Reference.selfHostingPage.tsx index 53773af35149a..7fad5fefc0f0c 100644 --- a/apps/docs/features/docs/Reference.selfHostingPage.tsx +++ b/apps/docs/features/docs/Reference.selfHostingPage.tsx @@ -48,7 +48,7 @@ export async function SelfHostingReferencePage({ const name = REFERENCES[servicePath.replaceAll('-', '_')].name return ( - + /introduction (and versioned variants) + // back to the base reference URL. Order matters: introduction first so + // it strips to a bare URL, then the section rules add a hash anchor. + { + source: '/reference/:lib/:version(v\\d+)/:section', + destination: '/reference/:lib/:version#:section', + permanent: true, + }, + { + source: '/reference/:lib/:section((?!v\\d+$)[^/]+)', + destination: '/reference/:lib#:section', + permanent: true, }, ] }, diff --git a/apps/studio/components/interfaces/Auth/Overview/OverviewErrors.constants.ts b/apps/studio/components/interfaces/Auth/Overview/OverviewErrors.constants.ts index ac3269061456c..616b14e8ca99c 100644 --- a/apps/studio/components/interfaces/Auth/Overview/OverviewErrors.constants.ts +++ b/apps/studio/components/interfaces/Auth/Overview/OverviewErrors.constants.ts @@ -1,5 +1,6 @@ import dayjs from 'dayjs' +import { safeSql } from '@/data/logs/safe-analytics-sql' import { fetchLogs } from '@/data/reports/report.utils' export type ResponseErrorRow = { @@ -22,7 +23,7 @@ export const getDateRange = () => { } // Top API response errors for /auth/v1 endpoints (path/method/status) -export const AUTH_TOP_RESPONSE_ERRORS_SQL = ` +export const AUTH_TOP_RESPONSE_ERRORS_SQL = safeSql` select request.method as method, request.path as path, @@ -40,7 +41,7 @@ export const AUTH_TOP_RESPONSE_ERRORS_SQL = ` ` // Top Auth service error codes from x_sb_error_code header for /auth/v1 endpoints -export const AUTH_TOP_ERROR_CODES_SQL = ` +export const AUTH_TOP_ERROR_CODES_SQL = safeSql` select h.x_sb_error_code as error_code, count(*) as count diff --git a/apps/studio/components/interfaces/Database/Schemas/FindTableSelector.tsx b/apps/studio/components/interfaces/Database/Schemas/FindTableSelector.tsx new file mode 100644 index 0000000000000..b061c444829ba --- /dev/null +++ b/apps/studio/components/interfaces/Database/Schemas/FindTableSelector.tsx @@ -0,0 +1,144 @@ +import { Loader2, Search } from 'lucide-react' +import { ComponentPropsWithoutRef, forwardRef, useMemo, useState } from 'react' +import { + Button, + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + Popover, + PopoverContent, + PopoverTrigger, + ScrollArea, +} from 'ui' + +import { useInfiniteTablesQuery } from '@/data/tables/tables-query' +import { useDebouncedValue } from '@/hooks/misc/useDebouncedValue' +import type { SafePostgresTable } from '@/lib/postgres-types' + +type FindTableSelectorProps = Omit, 'onSelect'> & { + projectRef?: string + connectionString?: string | null + schema?: string + disabled?: boolean + size?: 'tiny' | 'small' + open: boolean + onOpenChange: (open: boolean) => void + onSelect: (table: SafePostgresTable) => void +} + +export const FindTableSelector = forwardRef( + ( + { + className, + projectRef, + connectionString, + schema, + disabled = false, + size = 'tiny', + open, + onOpenChange, + onSelect, + ...rest + }, + ref + ) => { + const [search, setSearch] = useState('') + const debouncedSearch = useDebouncedValue(search, 300) + const nameFilter = debouncedSearch.trim() || undefined + + const { data, isFetching, hasNextPage, isFetchingNextPage, fetchNextPage } = + useInfiniteTablesQuery( + { + projectRef, + connectionString, + schema, + includeColumns: false, + pageSize: 50, + nameFilter, + }, + { enabled: open } + ) + + const tables = useMemo(() => data?.pages.flat() ?? [], [data]) + + const handleOpenChange = (next: boolean) => { + if (!next) setSearch('') + onOpenChange(next) + } + + return ( +
      + + + + + + + + + {isFetching && tables.length === 0 ? ( +
      + + Loading tables +
      + ) : ( + <> + No tables found + + 7 ? 'h-[210px]' : ''}> + {tables.map((table) => ( + { + onSelect(table) + handleOpenChange(false) + }} + > + {table.name} + + ))} + {hasNextPage && ( +
      + +
      + )} +
      +
      + + )} +
      +
      +
      +
      +
      + ) + } +) + +FindTableSelector.displayName = 'FindTableSelector' diff --git a/apps/studio/components/interfaces/Database/Schemas/SchemaGraph.tsx b/apps/studio/components/interfaces/Database/Schemas/SchemaGraph.tsx index e15a05f61effb..4293e97eed1a6 100644 --- a/apps/studio/components/interfaces/Database/Schemas/SchemaGraph.tsx +++ b/apps/studio/components/interfaces/Database/Schemas/SchemaGraph.tsx @@ -8,6 +8,7 @@ import { MiniMap, Node, OnSelectionChangeParams, + Panel, ReactFlow, useReactFlow, } from '@xyflow/react' @@ -41,6 +42,7 @@ import { Admonition } from 'ui-patterns/admonition' import { SidePanelEditor } from '../../TableGridEditor/SidePanelEditor/SidePanelEditor' import { DefaultEdge } from './DefaultEdge' +import { FindTableSelector } from './FindTableSelector' import { SchemaGraphContextProvider, SchemaGraphContextType } from './SchemaGraphContext' import { SchemaGraphLegend } from './SchemaGraphLegend' import { EdgeData, TableNodeData } from './Schemas.constants' @@ -56,7 +58,7 @@ import { ButtonTooltip } from '@/components/ui/ButtonTooltip' import SchemaSelector from '@/components/ui/SchemaSelector' import { Shortcut } from '@/components/ui/Shortcut' import { useSchemasQuery } from '@/data/database/schemas-query' -import { useTablesQuery } from '@/data/tables/tables-query' +import { useInfiniteTablesQuery } from '@/data/tables/tables-query' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' import { useLocalStorage } from '@/hooks/misc/useLocalStorage' import { useQuerySchemaState } from '@/hooks/misc/useSchemaQueryState' @@ -117,18 +119,23 @@ export const SchemaGraph = () => { }) const { - data: tables = [], + data: tablesData, error: errorTables, isSuccess: isSuccessTables, isPending: isLoadingTables, isError: isErrorTables, - } = useTablesQuery({ + hasNextPage, + isFetchingNextPage, + fetchNextPage, + } = useInfiniteTablesQuery({ projectRef: project?.ref, connectionString: project?.connectionString, schema: selectedSchema, includeColumns: true, + pageSize: 100, }) - const hasNoTables = isSuccessSchemas && tables.length === 0 + const tables = useMemo(() => tablesData?.pages.flat() ?? [], [tablesData]) + const hasNoTables = isSuccessTables && isSuccessSchemas && tables.length === 0 && !hasNextPage const schema = (schemas ?? []).find((s) => s.name === selectedSchema) const [, setStoredPositions] = useLocalStorage( @@ -227,8 +234,14 @@ export const SchemaGraph = () => { } const [schemaSelectorOpen, setSchemaSelectorOpen] = useState(false) + const [findTableOpen, setFindTableOpen] = useState(false) const [autoLayoutDialogOpen, setAutoLayoutDialogOpen] = useState(false) + const handleSelectSchema = (name: string) => { + setFindTableOpen(false) + setSelectedSchema(name) + } + const shortcutsEnabled = isSuccessSchemas && !hasNoTables useShortcut(SHORTCUT_IDS.SCHEMA_VISUALIZER_COPY_SQL, copyAsSQL, { enabled: shortcutsEnabled }) @@ -241,8 +254,13 @@ export const SchemaGraph = () => { useShortcut(SHORTCUT_IDS.SCHEMA_VISUALIZER_DOWNLOAD_SVG, () => downloadImage('svg'), { enabled: shortcutsEnabled, }) + useShortcut(SHORTCUT_IDS.SCHEMA_VISUALIZER_FIND_TABLE, () => setFindTableOpen(true), { + enabled: shortcutsEnabled, + }) const isFirstLoad = useRef(true) + const fitViewOnNextLayout = useRef(false) + const pendingFocusTableIdRef = useRef(null) useEffect(() => { if (isSuccessTables && isSuccessSchemas && tables.length > 0) { const schema = schemas.find((s) => s.name === selectedSchema) as PGSchema @@ -250,14 +268,49 @@ export const SchemaGraph = () => { reactFlowInstance.setNodes(nodes) reactFlowInstance.setEdges(edges) // Prevent resetting a view after first load to avoid layout changes after editing a column - if (isFirstLoad.current) { + if (isFirstLoad.current || fitViewOnNextLayout.current) { isFirstLoad.current = false + fitViewOnNextLayout.current = false setTimeout(() => reactFlowInstance.fitView({})) // it needs to happen during next event tick } + const pendingId = pendingFocusTableIdRef.current + if (pendingId !== null && nodes.some((n) => n.id === pendingId)) { + pendingFocusTableIdRef.current = null + setTimeout(() => + reactFlowInstance.fitView({ + nodes: [{ id: pendingId }], + duration: 300, + maxZoom: 1.5, + }) + ) + } }) } }, [isSuccessTables, isSuccessSchemas, tables, reactFlowInstance, ref, schemas, selectedSchema]) + const handleFindTableSelect = async (table: SafePostgresTable) => { + const targetId = String(table.id) + if (reactFlowInstance.getNode(targetId)) { + reactFlowInstance.fitView({ + nodes: [{ id: targetId }], + duration: 300, + maxZoom: 1.5, + }) + return + } + // Selected table isn't loaded yet — queue the fitView and pull pages until + // it shows up. The build-effect above will consume the pending id once the + // node is mounted. + pendingFocusTableIdRef.current = targetId + let result = await fetchNextPage() + while ( + result.hasNextPage && + !result.data?.pages.some((page) => page.some((t) => t.id === table.id)) + ) { + result = await fetchNextPage() + } + } + const schemaGraphContext = useMemo( () => ({ selectedEdge, @@ -294,23 +347,43 @@ export const SchemaGraph = () => { {isSuccessSchemas && ( <> - setSchemaSelectorOpen(true)} - options={{ enabled: isSuccessSchemas }} - side="bottom" - tooltipOpen={schemaSelectorOpen ? false : undefined} - > - - +
      + setSchemaSelectorOpen(true)} + options={{ enabled: isSuccessSchemas }} + side="bottom" + tooltipOpen={schemaSelectorOpen ? false : undefined} + > + + + {!hasNoTables && ( + setFindTableOpen(true)} + options={{ enabled: shortcutsEnabled }} + side="bottom" + tooltipOpen={findTableOpen ? false : undefined} + > + + + )} +
      {!hasNoTables && (
      @@ -483,6 +556,21 @@ export const SchemaGraph = () => { className="border rounded-md shadow-xs" /> + {hasNextPage && ( + + + + )}
      diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CreateCronJobSheet/CreateCronJobSheet.constants.ts b/apps/studio/components/interfaces/Integrations/CronJobs/CreateCronJobSheet/CreateCronJobSheet.constants.ts index 29fce353d448f..45100581cf78a 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CreateCronJobSheet/CreateCronJobSheet.constants.ts +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CreateCronJobSheet/CreateCronJobSheet.constants.ts @@ -103,7 +103,7 @@ const sqlSnippetSchema = z.object({ export const FormSchema = z .object({ - name: z.string().trim().min(1, 'Please provide a name for your cron job'), + name: z.string().trim(), supportsSeconds: z.boolean(), schedule: z .string() diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CreateCronJobSheet/CreateCronJobSheet.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CreateCronJobSheet/CreateCronJobSheet.tsx index fd91482fb4c34..bd43227d74848 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CreateCronJobSheet/CreateCronJobSheet.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CreateCronJobSheet/CreateCronJobSheet.tsx @@ -27,7 +27,12 @@ import { Admonition } from 'ui-patterns/admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { CRONJOB_DEFINITIONS } from '../CronJobs.constants' -import { buildCronQuery, buildHttpRequestCommand, parseCronJobCommand } from '../CronJobs.utils' +import { + buildCronCreateQuery, + buildCronUpdateQuery, + buildHttpRequestCommand, + parseCronJobCommand, +} from '../CronJobs.utils' import { EdgeFunctionSection } from '../EdgeFunctionSection' import { HttpBodyFieldSection } from '../HttpBodyFieldSection' import { HTTPHeaderFieldsSection } from '../HttpHeaderFieldsSection' @@ -55,7 +60,8 @@ import { useTrack } from '@/lib/telemetry/track' interface CreateCronJobSheetProps { open: boolean - selectedCronJob?: Pick + selectedCronJob?: Pick & + Partial> onClose: () => void } @@ -93,7 +99,7 @@ export const CreateCronJobSheet = ({ open, selectedCronJob, onClose }: CreateCro const [isLoadingGetCronJob, setIsLoadingGetCronJob] = useState(false) const jobId = Number(childId) - const isEditing = !!selectedCronJob?.jobname + const isEditing = selectedCronJob?.jobid !== undefined const [showEnableExtensionModal, setShowEnableExtensionModal] = useState(false) const { data = [] } = useDatabaseExtensionsQuery({ @@ -164,6 +170,14 @@ export const CreateCronJobSheet = ({ open, selectedCronJob, onClose }: CreateCro if (!project) return console.error('Project is required') if (!isEditing) { + if (!name) { + return form.setError( + 'name', + { type: 'manual', message: 'Please provide a name for your cron job' }, + { shouldFocus: true } + ) + } + try { setIsLoadingGetCronJob(true) const checkExistingJob = await getDatabaseCronJob({ @@ -191,7 +205,10 @@ export const CreateCronJobSheet = ({ open, selectedCronJob, onClose }: CreateCro } } - const query = buildCronQuery(name, schedule, values.snippet) + const query = + isEditing && selectedCronJob?.jobid !== undefined + ? buildCronUpdateQuery(selectedCronJob.jobid, schedule, values.snippet) + : buildCronCreateQuery(name, schedule, values.snippet) upsertCronJob( { @@ -269,7 +286,9 @@ export const CreateCronJobSheet = ({ open, selectedCronJob, onClose }: CreateCro
      - {isEditing ? `Edit ${selectedCronJob.jobname}` : `Create a new cron job`} + {isEditing + ? `Edit ${selectedCronJob.jobname || 'cron job'}` + : `Create a new cron job`} diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.test.ts b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.test.ts index 906bb94223bd5..f79435a6455b8 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.test.ts +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.test.ts @@ -1,7 +1,28 @@ import { describe, expect, it } from 'vitest' import { cronPattern, secondsPattern } from './CronJobs.constants' -import { formatCronJobColumns, parseCronJobCommand } from './CronJobs.utils' +import { + buildCronCreateQuery, + buildCronUpdateQuery, + formatCronJobColumns, + parseCronJobCommand, +} from './CronJobs.utils' + +describe('buildCronQuery', () => { + it('uses cron.schedule to create a job by name', () => { + expect(buildCronCreateQuery('my-job', '*/5 * * * *', 'select 1')).toBe( + "select cron.schedule('my-job', '*/5 * * * *', 'select 1');" + ) + }) +}) + +describe('buildCronUpdateQuery', () => { + it('uses cron.alter_job to update a job by id', () => { + expect(buildCronUpdateQuery(42, '*/10 * * * *', 'select 2')).toBe( + "select cron.alter_job(job_id := 42, schedule := '*/10 * * * *', command := 'select 2');" + ) + }) +}) describe('parseCronJobCommand', () => { it('should return a default object when the command is null', () => { diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.tsx index 522b0bb71d565..ac68d4948f958 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.tsx @@ -13,10 +13,22 @@ const unescapeSqlLiteral = (value = '', isEscapeString = false) => { return isEscapeString ? unescaped.replaceAll('\\\\', '\\') : unescaped } -export function buildCronQuery(name: string, schedule: string, command: string): SafeSqlFragment { +export function buildCronCreateQuery( + name: string, + schedule: string, + command: string +): SafeSqlFragment { return safeSql`select cron.schedule(${literal(name)}, ${literal(schedule)}, ${literal(command)});` } +export function buildCronUpdateQuery( + jobId: number, + schedule: string, + command: string +): SafeSqlFragment { + return safeSql`select cron.alter_job(job_id := ${literal(jobId)}, schedule := ${literal(schedule)}, command := ${literal(command)});` +} + export const buildHttpRequestCommand = ( method: 'GET' | 'POST', url: string, diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx index 758dc85397e5c..481f4a511b319 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx @@ -439,6 +439,9 @@ export const SidePanelEditor = ({ queryClient.invalidateQueries({ queryKey: tableKeys.list(project?.ref, selectedTable?.schema, includeColumns), }), + queryClient.invalidateQueries({ + queryKey: tableKeys.infiniteListPrefix(project?.ref, selectedTable?.schema), + }), ]) // We need to invalidate tableRowsAndCount after tableEditor diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.tsx index f45fb92b94ef8..a654f7f00482b 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.tsx @@ -781,6 +781,14 @@ export const updateTable = async ({ schema: table.schema, payload, }) + await queryClient.invalidateQueries({ + queryKey: tableKeys.infiniteListPrefix(projectRef, table.schema), + }) + if (payload.schema && payload.schema !== table.schema) { + await queryClient.invalidateQueries({ + queryKey: tableKeys.infiniteListPrefix(projectRef, payload.schema), + }) + } } if (payload.rls_enabled === true) { diff --git a/apps/studio/data/logs/safe-analytics-sql.ts b/apps/studio/data/logs/safe-analytics-sql.ts index 0f5842d6959f9..7f2b24787f273 100644 --- a/apps/studio/data/logs/safe-analytics-sql.ts +++ b/apps/studio/data/logs/safe-analytics-sql.ts @@ -40,8 +40,9 @@ * `SafeSqlFragment` (Postgres-only). * * Values of this type are either: - * - Static strings in source code (no interpolation) via `rawSql` - * - Outputs of `analyticsLiteral` or `quotedIdent` + * - Static strings in source code (no interpolation) via the `safeSql` + * template tag with no interpolations + * - Outputs of `analyticsLiteral`, `quotedIdent`, or `keyword` * - Compositions via the `safeSql` template tag (which only accepts * `SafeLogSqlFragment` interpolations) * - Compositions via `joinSqlFragments` @@ -50,7 +51,7 @@ */ export type SafeLogSqlFragment = string & { readonly __safeLogSqlFragmentBrand: never } -export type LogSqlFragmentSeparator = +type LogSqlFragmentSeparator = | ',' | ', ' | ';\n' @@ -82,10 +83,12 @@ export function safeSql( } /** - * Marks a hand-written log-SQL string as a `SafeLogSqlFragment`. Use only - * for static SQL authored in source code; never call with arbitrary input. + * Internal-only escape hatch for branding hand-written log-SQL produced by + * the helpers in this file (e.g. `analyticsLiteral`, `quotedIdent`). Not + * exported: external callers must compose via `safeSql` plus the sanitization + * helpers, never by casting arbitrary strings. */ -export function rawSql(sql: string): SafeLogSqlFragment { +function rawSql(sql: string): SafeLogSqlFragment { return sql as SafeLogSqlFragment } diff --git a/apps/studio/data/reports/report.utils.ts b/apps/studio/data/reports/report.utils.ts index 409d233b4c8c2..9c52a8759d4f9 100644 --- a/apps/studio/data/reports/report.utils.ts +++ b/apps/studio/data/reports/report.utils.ts @@ -1,8 +1,36 @@ +import { type ComparisonOperator } from '@/components/interfaces/Reports/v2/ReportsNumericFilter' import { AnalyticsInterval } from '@/data/analytics/constants' import { useEdgeFunctionsQuery } from '@/data/edge-functions/edge-functions-query' -import { get } from '@/data/fetchers' +import { executeAnalyticsSql } from '@/data/logs/execute-analytics-sql' +import { safeSql, type SafeLogSqlFragment } from '@/data/logs/safe-analytics-sql' export type Granularity = 'minute' | 'hour' | 'day' + +/** + * Pre-branded SQL fragments for the closed set of granularity tokens that + * `analyticsIntervalToGranularity` may return. Use to splice a granularity into + * a `safeSql` template without re-validating at the call site. + */ +export const SAFE_GRANULARITY_SQL: Record = { + minute: safeSql`minute`, + hour: safeSql`hour`, + day: safeSql`day`, +} + +/** + * Pre-branded SQL fragments for the closed set of numeric comparison operators + * accepted by `ReportsNumericFilter`. Use to splice an operator into a + * `safeSql` template without re-validating at the call site. + */ +export const SAFE_COMPARISON_OPERATOR_SQL: Record = { + '=': safeSql`=`, + '>=': safeSql`>=`, + '<=': safeSql`<=`, + '>': safeSql`>`, + '<': safeSql`<`, + '!=': safeSql`!=`, +} + export function analyticsIntervalToGranularity(interval: AnalyticsInterval): Granularity { switch (interval) { case '1m': @@ -55,22 +83,18 @@ export const useEdgeFnIdToName = ({ projectRef }: { projectRef: string }) => { export async function fetchLogs( projectRef: string, - sql: string, + sql: SafeLogSqlFragment, startDate: string, endDate: string ) { - const { data, error } = await get(`/platform/projects/{ref}/analytics/endpoints/logs.all`, { - params: { - path: { ref: projectRef }, - query: { - sql, - iso_timestamp_start: startDate, - iso_timestamp_end: endDate, - }, - }, + return await executeAnalyticsSql({ + projectRef, + endpoint: '/platform/projects/{ref}/analytics/endpoints/logs.all', + sql, + iso_timestamp_start: startDate, + iso_timestamp_end: endDate, + method: 'get', }) - if (error) throw error - return data } export const STATUS_CODE_COLORS: { [key: string]: { light: string; dark: string } } = { diff --git a/apps/studio/data/reports/v2/auth.config.ts b/apps/studio/data/reports/v2/auth.config.ts index 11e933bbf2d87..82fcf2490bae8 100644 --- a/apps/studio/data/reports/v2/auth.config.ts +++ b/apps/studio/data/reports/v2/auth.config.ts @@ -10,7 +10,18 @@ import { } from '@/components/interfaces/Reports/Reports.utils' import { NumericFilter } from '@/components/interfaces/Reports/v2/ReportsNumericFilter' import type { AnalyticsInterval } from '@/data/analytics/constants' -import { analyticsIntervalToGranularity, fetchLogs } from '@/data/reports/report.utils' +import { + analyticsLiteral, + joinSqlFragments, + safeSql, + type SafeLogSqlFragment, +} from '@/data/logs/safe-analytics-sql' +import { + analyticsIntervalToGranularity, + fetchLogs, + SAFE_COMPARISON_OPERATOR_SQL, + SAFE_GRANULARITY_SQL, +} from '@/data/reports/report.utils' const AUTH_ERROR_CODE_LIST = Object.entries(AUTH_ERROR_CODES).map(([key, value]) => ({ key, @@ -32,39 +43,103 @@ const METRIC_KEYS = [ type MetricKey = (typeof METRIC_KEYS)[number] +type AuthReportFilters = { + status_code?: NumericFilter | null + provider?: string[] | null +} + +// Static SELECT-clause fragment for the `auth_logs` table. +const PROVIDER_SELECT_FRAGMENT = safeSql`COALESCE(JSON_VALUE(event_message, "$.provider"), 'unknown') as provider,` + +// Static SELECT-clause fragment for the aliased `auth_logs f` form. +const PROVIDER_SELECT_FRAGMENT_F_ALIAS = safeSql`COALESCE(JSON_VALUE(f.event_message, "$.provider"), 'unknown') as provider,` + +const PROVIDER_GROUP_BY_FRAGMENT = safeSql`, provider` +const EMPTY = safeSql`` + +function providerSelectFragment(groupByProvider: boolean, aliased: boolean): SafeLogSqlFragment { + if (!groupByProvider) return EMPTY + return aliased ? PROVIDER_SELECT_FRAGMENT_F_ALIAS : PROVIDER_SELECT_FRAGMENT +} + +function providerGroupBy(groupByProvider: boolean): SafeLogSqlFragment { + return groupByProvider ? PROVIDER_GROUP_BY_FRAGMENT : EMPTY +} + +/** + * Builds an `AND`-prefixed predicate fragment for `auth_logs`-shaped queries. + * Returns the empty fragment when no filters apply. The returned value can be + * spliced directly after a query's existing `WHERE` clause. + */ +function authFiltersToAndPredicates(filters?: AuthReportFilters): SafeLogSqlFragment { + const predicates: SafeLogSqlFragment[] = [] + + if (filters?.status_code) { + const op = SAFE_COMPARISON_OPERATOR_SQL[filters.status_code.operator] + predicates.push( + safeSql`response.status_code ${op} ${analyticsLiteral(filters.status_code.value)}` + ) + } + + if (filters?.provider && filters.provider.length > 0) { + const list = joinSqlFragments(filters.provider.map(analyticsLiteral), ', ') + predicates.push(safeSql`JSON_VALUE(event_message, "$.provider") IN (${list})`) + } + + if (predicates.length === 0) return EMPTY + return safeSql`AND ${joinSqlFragments(predicates, ' AND ')}` +} + +/** + * Builds an `AND`-prefixed predicate fragment for `edge_logs`-shaped queries. + */ +function edgeLogsFiltersToAndPredicates(filters?: AuthReportFilters): SafeLogSqlFragment { + const predicates: SafeLogSqlFragment[] = [] + + if (filters?.status_code) { + const op = SAFE_COMPARISON_OPERATOR_SQL[filters.status_code.operator] + predicates.push( + safeSql`response.status_code ${op} ${analyticsLiteral(filters.status_code.value)}` + ) + } + + if (predicates.length === 0) return EMPTY + return safeSql`AND ${joinSqlFragments(predicates, ' AND ')}` +} + const AUTH_REPORT_SQL: Record< MetricKey, - (interval: AnalyticsInterval, filters?: AuthReportFilters) => string + (interval: AnalyticsInterval, filters?: AuthReportFilters) => SafeLogSqlFragment > = { ActiveUsers: (interval, filters) => { - const granularity = analyticsIntervalToGranularity(interval) - const whereClause = filterToWhereClause(filters) + const granularity = SAFE_GRANULARITY_SQL[analyticsIntervalToGranularity(interval)] + const andPredicates = authFiltersToAndPredicates(filters) const groupByProvider = Boolean(filters?.provider && filters.provider.length > 0) - return ` + return safeSql` --active-users - select + select timestamp_trunc(timestamp, ${granularity}) as timestamp, - ${groupByProvider ? 'COALESCE(JSON_VALUE(f.event_message, "$.provider"), \'unknown\') as provider,' : ''} + ${providerSelectFragment(groupByProvider, true)} count(distinct json_value(f.event_message, "$.auth_event.actor_id")) as count from auth_logs f where json_value(f.event_message, "$.auth_event.action") in ( 'login', 'user_signedup', 'token_refreshed', 'user_modified', 'user_recovery_requested', 'user_reauthenticate_requested' ) - ${whereClause ? `AND ${whereClause.replace(/^WHERE\s+/, '')}` : ''} - group by timestamp${groupByProvider ? ', provider' : ''} - order by timestamp desc${groupByProvider ? ', provider' : ''} + ${andPredicates} + group by timestamp${providerGroupBy(groupByProvider)} + order by timestamp desc${providerGroupBy(groupByProvider)} ` }, SignInAttempts: (interval, filters) => { - const granularity = analyticsIntervalToGranularity(interval) - const whereClause = filterToWhereClause(filters) + const granularity = SAFE_GRANULARITY_SQL[analyticsIntervalToGranularity(interval)] + const andPredicates = authFiltersToAndPredicates(filters) const groupByProvider = Boolean(filters?.provider && filters.provider.length > 0) - return ` + return safeSql` --sign-in-attempts SELECT timestamp_trunc(timestamp, ${granularity}) as timestamp, - ${groupByProvider ? 'COALESCE(JSON_VALUE(event_message, "$.provider"), \'unknown\') as provider,' : ''} + ${providerSelectFragment(groupByProvider, false)} CASE WHEN JSON_VALUE(event_message, "$.provider") IS NOT NULL AND JSON_VALUE(event_message, "$.provider") != '' @@ -82,133 +157,133 @@ const AUTH_REPORT_SQL: Record< WHERE JSON_VALUE(event_message, "$.action") = 'login' AND JSON_VALUE(event_message, "$.metering") = "true" - ${whereClause ? `AND ${whereClause.replace(/^WHERE\s+/, '')}` : ''} + ${andPredicates} GROUP BY - timestamp, login_type_provider${groupByProvider ? ', provider' : ''} + timestamp, login_type_provider${providerGroupBy(groupByProvider)} ORDER BY - timestamp desc, login_type_provider${groupByProvider ? ', provider' : ''} + timestamp desc, login_type_provider${providerGroupBy(groupByProvider)} ` }, PasswordResetRequests: (interval, filters) => { - const granularity = analyticsIntervalToGranularity(interval) - const whereClause = filterToWhereClause(filters) + const granularity = SAFE_GRANULARITY_SQL[analyticsIntervalToGranularity(interval)] + const andPredicates = authFiltersToAndPredicates(filters) const groupByProvider = Boolean(filters?.provider && filters.provider.length > 0) - return ` + return safeSql` --password-reset-requests - select + select timestamp_trunc(timestamp, ${granularity}) as timestamp, - ${groupByProvider ? 'COALESCE(JSON_VALUE(f.event_message, "$.provider"), \'unknown\') as provider,' : ''} + ${providerSelectFragment(groupByProvider, true)} count(*) as count from auth_logs f where json_value(f.event_message, "$.auth_event.action") = 'user_recovery_requested' - ${whereClause ? `AND ${whereClause.replace(/^WHERE\s+/, '')}` : ''} - group by timestamp${groupByProvider ? ', provider' : ''} - order by timestamp desc${groupByProvider ? ', provider' : ''} + ${andPredicates} + group by timestamp${providerGroupBy(groupByProvider)} + order by timestamp desc${providerGroupBy(groupByProvider)} ` }, TotalSignUps: (interval, filters) => { - const granularity = analyticsIntervalToGranularity(interval) - const whereClause = filterToWhereClause(filters) + const granularity = SAFE_GRANULARITY_SQL[analyticsIntervalToGranularity(interval)] + const andPredicates = authFiltersToAndPredicates(filters) const groupByProvider = Boolean(filters?.provider && filters.provider.length > 0) - return ` + return safeSql` --total-signups - select + select timestamp_trunc(timestamp, ${granularity}) as timestamp, - ${groupByProvider ? 'COALESCE(JSON_VALUE(event_message, "$.provider"), \'unknown\') as provider,' : ''} + ${providerSelectFragment(groupByProvider, false)} count(*) as count from auth_logs where json_value(event_message, "$.auth_event.action") = 'user_signedup' - ${whereClause ? `AND ${whereClause.replace(/^WHERE\s+/, '')}` : ''} - group by timestamp${groupByProvider ? ', provider' : ''} - order by timestamp desc${groupByProvider ? ', provider' : ''} + ${andPredicates} + group by timestamp${providerGroupBy(groupByProvider)} + order by timestamp desc${providerGroupBy(groupByProvider)} ` }, SignInProcessingTimeBasic: (interval, filters) => { - const granularity = analyticsIntervalToGranularity(interval) - const whereClause = filterToWhereClause(filters) + const granularity = SAFE_GRANULARITY_SQL[analyticsIntervalToGranularity(interval)] + const andPredicates = authFiltersToAndPredicates(filters) const groupByProvider = Boolean(filters?.provider && filters.provider.length > 0) - return ` + return safeSql` --signin-processing-time-basic - select + select timestamp_trunc(timestamp, ${granularity}) as timestamp, - ${groupByProvider ? 'COALESCE(JSON_VALUE(event_message, "$.provider"), \'unknown\') as provider,' : ''} + ${providerSelectFragment(groupByProvider, false)} count(*) as count, round(avg(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as avg_processing_time_ms, round(min(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as min_processing_time_ms, round(max(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as max_processing_time_ms from auth_logs where json_value(event_message, "$.auth_event.action") = 'login' - ${whereClause ? `AND ${whereClause.replace(/^WHERE\s+/, '')}` : ''} - group by timestamp${groupByProvider ? ', provider' : ''} - order by timestamp desc${groupByProvider ? ', provider' : ''} + ${andPredicates} + group by timestamp${providerGroupBy(groupByProvider)} + order by timestamp desc${providerGroupBy(groupByProvider)} ` }, SignInProcessingTimePercentiles: (interval, filters) => { - const granularity = analyticsIntervalToGranularity(interval) - const whereClause = filterToWhereClause(filters) + const granularity = SAFE_GRANULARITY_SQL[analyticsIntervalToGranularity(interval)] + const andPredicates = authFiltersToAndPredicates(filters) const groupByProvider = Boolean(filters?.provider && filters.provider.length > 0) - return ` + return safeSql` --signin-processing-time-percentiles - select + select timestamp_trunc(timestamp, ${granularity}) as timestamp, - ${groupByProvider ? 'COALESCE(JSON_VALUE(event_message, "$.provider"), \'unknown\') as provider,' : ''} + ${providerSelectFragment(groupByProvider, false)} count(*) as count, round(approx_quantiles(cast(json_value(event_message, "$.duration") as int64), 100)[offset(50)] / 1000000, 2) as p50_processing_time_ms, round(approx_quantiles(cast(json_value(event_message, "$.duration") as int64), 100)[offset(95)] / 1000000, 2) as p95_processing_time_ms, round(approx_quantiles(cast(json_value(event_message, "$.duration") as int64), 100)[offset(99)] / 1000000, 2) as p99_processing_time_ms from auth_logs where json_value(event_message, "$.auth_event.action") = 'login' - ${whereClause ? `AND ${whereClause.replace(/^WHERE\s+/, '')}` : ''} - group by timestamp${groupByProvider ? ', provider' : ''} - order by timestamp desc${groupByProvider ? ', provider' : ''} + ${andPredicates} + group by timestamp${providerGroupBy(groupByProvider)} + order by timestamp desc${providerGroupBy(groupByProvider)} ` }, SignUpProcessingTimeBasic: (interval, filters) => { - const granularity = analyticsIntervalToGranularity(interval) - const whereClause = filterToWhereClause(filters) + const granularity = SAFE_GRANULARITY_SQL[analyticsIntervalToGranularity(interval)] + const andPredicates = authFiltersToAndPredicates(filters) const groupByProvider = Boolean(filters?.provider && filters.provider.length > 0) - return ` + return safeSql` --signup-processing-time-basic - select + select timestamp_trunc(timestamp, ${granularity}) as timestamp, - ${groupByProvider ? 'COALESCE(JSON_VALUE(event_message, "$.provider"), \'unknown\') as provider,' : ''} + ${providerSelectFragment(groupByProvider, false)} count(*) as count, round(avg(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as avg_processing_time_ms, round(min(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as min_processing_time_ms, round(max(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as max_processing_time_ms from auth_logs where json_value(event_message, "$.auth_event.action") = 'user_signedup' - ${whereClause ? `AND ${whereClause.replace(/^WHERE\s+/, '')}` : ''} - group by timestamp${groupByProvider ? ', provider' : ''} - order by timestamp desc${groupByProvider ? ', provider' : ''} + ${andPredicates} + group by timestamp${providerGroupBy(groupByProvider)} + order by timestamp desc${providerGroupBy(groupByProvider)} ` }, SignUpProcessingTimePercentiles: (interval, filters) => { - const granularity = analyticsIntervalToGranularity(interval) - const whereClause = filterToWhereClause(filters) + const granularity = SAFE_GRANULARITY_SQL[analyticsIntervalToGranularity(interval)] + const andPredicates = authFiltersToAndPredicates(filters) const groupByProvider = Boolean(filters?.provider && filters.provider.length > 0) - return ` + return safeSql` --signup-processing-time-percentiles - select + select timestamp_trunc(timestamp, ${granularity}) as timestamp, - ${groupByProvider ? 'COALESCE(JSON_VALUE(event_message, "$.provider"), \'unknown\') as provider,' : ''} + ${providerSelectFragment(groupByProvider, false)} count(*) as count, round(approx_quantiles(cast(json_value(event_message, "$.duration") as int64), 100)[offset(50)] / 1000000, 2) as p50_processing_time_ms, round(approx_quantiles(cast(json_value(event_message, "$.duration") as int64), 100)[offset(95)] / 1000000, 2) as p95_processing_time_ms, round(approx_quantiles(cast(json_value(event_message, "$.duration") as int64), 100)[offset(99)] / 1000000, 2) as p99_processing_time_ms from auth_logs where json_value(event_message, "$.auth_event.action") = 'user_signedup' - ${whereClause ? `AND ${whereClause.replace(/^WHERE\s+/, '')}` : ''} - group by timestamp${groupByProvider ? ', provider' : ''} - order by timestamp desc${groupByProvider ? ', provider' : ''} + ${andPredicates} + group by timestamp${providerGroupBy(groupByProvider)} + order by timestamp desc${providerGroupBy(groupByProvider)} ` }, ErrorsByStatus: (interval, filters) => { - const granularity = analyticsIntervalToGranularity(interval) - const whereClause = edgeLogsFilterToWhereClause(filters) - return ` + const granularity = SAFE_GRANULARITY_SQL[analyticsIntervalToGranularity(interval)] + const andPredicates = edgeLogsFiltersToAndPredicates(filters) + return safeSql` --auth-errors-by-status - select + select timestamp_trunc(timestamp, ${granularity}) as timestamp, count(*) as count, response.status_code @@ -219,17 +294,17 @@ const AUTH_REPORT_SQL: Record< cross join unnest(response.headers) as h where path like '%auth/v1%' and response.status_code >= 400 and response.status_code <= 599 - ${whereClause ? `AND ${whereClause.replace(/^WHERE\s+/, '')}` : ''} + ${andPredicates} group by timestamp, status_code order by timestamp desc ` }, ErrorsByAuthCode: (interval, filters) => { - const granularity = analyticsIntervalToGranularity(interval) - const whereClause = edgeLogsFilterToWhereClause(filters) - return ` + const granularity = SAFE_GRANULARITY_SQL[analyticsIntervalToGranularity(interval)] + const andPredicates = edgeLogsFiltersToAndPredicates(filters) + return safeSql` --auth-errors-by-code - select + select timestamp_trunc(timestamp, ${granularity}) as timestamp, count(*) as count, h.x_sb_error_code as error_code @@ -240,47 +315,13 @@ const AUTH_REPORT_SQL: Record< cross join unnest(response.headers) as h where path like '%auth/v1%' and response.status_code >= 400 and response.status_code <= 599 - ${whereClause ? `AND ${whereClause.replace(/^WHERE\s+/, '')}` : ''} + ${andPredicates} group by timestamp, error_code order by timestamp desc ` }, } -type AuthReportFilters = { - status_code?: NumericFilter | null - provider?: string[] | null -} - -function filterToWhereClause(filters?: AuthReportFilters): string { - const whereClauses: string[] = [] - - if (filters?.status_code) { - whereClauses.push( - `response.status_code ${filters.status_code.operator} ${filters.status_code.value}` - ) - } - - if (filters?.provider && filters.provider.length > 0) { - const providerList = filters.provider.map((p) => `'${p}'`).join(', ') - whereClauses.push(`JSON_VALUE(event_message, "$.provider") IN (${providerList})`) - } - - return whereClauses.length > 0 ? `WHERE ${whereClauses.join(' AND ')}` : '' -} - -function edgeLogsFilterToWhereClause(filters?: AuthReportFilters): string { - const whereClauses: string[] = [] - - if (filters?.status_code) { - whereClauses.push( - `response.status_code ${filters.status_code.operator} ${filters.status_code.value}` - ) - } - - return whereClauses.length > 0 ? `WHERE ${whereClauses.join(' AND ')}` : '' -} - export const AUTH_ERROR_CODE_VALUES: string[] = [ 'anonymous_provider_disabled', 'bad_code_verifier', diff --git a/apps/studio/data/reports/v2/edge-functions.config.ts b/apps/studio/data/reports/v2/edge-functions.config.ts index efe1cabfcd8f5..a4c72987276cb 100644 --- a/apps/studio/data/reports/v2/edge-functions.config.ts +++ b/apps/studio/data/reports/v2/edge-functions.config.ts @@ -13,7 +13,18 @@ import { unixMicroToIsoTimestamp, } from '@/components/interfaces/Settings/Logs/Logs.utils' import type { AnalyticsInterval } from '@/data/analytics/constants' -import { analyticsIntervalToGranularity, fetchLogs } from '@/data/reports/report.utils' +import { + analyticsLiteral, + joinSqlFragments, + safeSql, + type SafeLogSqlFragment, +} from '@/data/logs/safe-analytics-sql' +import { + analyticsIntervalToGranularity, + fetchLogs, + SAFE_COMPARISON_OPERATOR_SQL, + SAFE_GRANULARITY_SQL, +} from '@/data/reports/report.utils' type EdgeFunctionReportFilters = { status_code: NumericFilter | null @@ -22,44 +33,48 @@ type EdgeFunctionReportFilters = { functions: SelectFilters } -export function filterToWhereClause(filters?: EdgeFunctionReportFilters): string { - const whereClauses: string[] = [] +export function filterToWhereClause(filters?: EdgeFunctionReportFilters): SafeLogSqlFragment { + const whereClauses: SafeLogSqlFragment[] = [] if (filters?.functions && filters.functions.length > 0) { - whereClauses.push(`function_id IN (${filters.functions.map((id) => `'${id}'`).join(',')})`) + const ids = joinSqlFragments(filters.functions.map(analyticsLiteral), ',') + whereClauses.push(safeSql`function_id IN (${ids})`) } if (filters?.status_code) { + const op = SAFE_COMPARISON_OPERATOR_SQL[filters.status_code.operator] whereClauses.push( - `response.status_code ${filters.status_code.operator} ${filters.status_code.value}` + safeSql`response.status_code ${op} ${analyticsLiteral(filters.status_code.value)}` ) } if (filters?.region && filters.region.length > 0) { - whereClauses.push( - `h.x_sb_edge_region IN (${filters.region.map((region) => `'${region}'`).join(',')})` - ) + const regions = joinSqlFragments(filters.region.map(analyticsLiteral), ',') + whereClauses.push(safeSql`h.x_sb_edge_region IN (${regions})`) } if (filters?.execution_time) { + const op = SAFE_COMPARISON_OPERATOR_SQL[filters.execution_time.operator] whereClauses.push( - `m.execution_time_ms ${filters.execution_time.operator} ${filters.execution_time.value}` + safeSql`m.execution_time_ms ${op} ${analyticsLiteral(filters.execution_time.value)}` ) } - return whereClauses.length > 0 ? `WHERE ${whereClauses.join(' AND ')}` : '' + if (whereClauses.length === 0) return safeSql`` + return safeSql`WHERE ${joinSqlFragments(whereClauses, ' AND ')}` } const METRIC_SQL: Record< string, - (interval: AnalyticsInterval, filters?: EdgeFunctionReportFilters) => string + (interval: AnalyticsInterval, filters?: EdgeFunctionReportFilters) => SafeLogSqlFragment > = { TotalInvocations: (interval, filters) => { + const granularity = SAFE_GRANULARITY_SQL[analyticsIntervalToGranularity(interval)] const whereClause = filterToWhereClause(filters) - return ` + return safeSql` --edgefn-report-invocations select - timestamp_trunc(timestamp, ${analyticsIntervalToGranularity(interval)}) as timestamp, + timestamp_trunc(timestamp, ${granularity}) as timestamp, function_id, count(*) as count from @@ -77,11 +92,12 @@ order by ` }, ExecutionStatusCodes: (interval, filters) => { + const granularity = SAFE_GRANULARITY_SQL[analyticsIntervalToGranularity(interval)] const whereClause = filterToWhereClause(filters) - return ` + return safeSql` --edgefn-report-execution-status-codes select - timestamp_trunc(timestamp, ${analyticsIntervalToGranularity(interval)}) as timestamp, + timestamp_trunc(timestamp, ${granularity}) as timestamp, response.status_code as status_code, count(response.status_code) as count from @@ -98,14 +114,14 @@ order by ` }, InvocationsByRegion: (interval, filters) => { - const granularity = analyticsIntervalToGranularity(interval) + const granularity = SAFE_GRANULARITY_SQL[analyticsIntervalToGranularity(interval)] const whereClause = filterToWhereClause(filters) - const hasWhere = whereClause.includes('WHERE') - const regionCondition = hasWhere - ? 'AND h.x_sb_edge_region is not null' - : 'WHERE h.x_sb_edge_region is not null' + const regionCondition = + whereClause.length > 0 + ? safeSql`AND h.x_sb_edge_region is not null` + : safeSql`WHERE h.x_sb_edge_region is not null` - return ` + return safeSql` --edgefn-report-invocations-by-region select timestamp_trunc(timestamp, ${granularity}) as timestamp, @@ -126,10 +142,10 @@ order by ` }, ExecutionTime: (interval, filters) => { - const granularity = analyticsIntervalToGranularity(interval) + const granularity = SAFE_GRANULARITY_SQL[analyticsIntervalToGranularity(interval)] const whereClause = filterToWhereClause(filters) - return ` + return safeSql` --edgefn-report-execution-time select timestamp_trunc(timestamp, ${granularity}) as timestamp, diff --git a/apps/studio/data/tables/keys.ts b/apps/studio/data/tables/keys.ts index 87c5603922e9e..51440a268ccba 100644 --- a/apps/studio/data/tables/keys.ts +++ b/apps/studio/data/tables/keys.ts @@ -2,15 +2,15 @@ export const tableKeys = { names: (projectRef: string | undefined) => ['projects', projectRef, 'table-names'] as const, list: (projectRef: string | undefined, schema?: string, includeColumns?: boolean) => ['projects', projectRef, 'tables', schema, includeColumns].filter(Boolean), - infiniteList: ( - projectRef: string | undefined, - schema?: string, - includeColumns?: boolean, - pageSize?: number - ) => - ['projects', projectRef, 'tables', 'infinite', schema, includeColumns, pageSize].filter( + infiniteListPrefix: (projectRef: string | undefined, schema?: string) => + ['projects', projectRef, 'tables', 'infinite', schema].filter( (part) => part !== undefined && part !== null && part !== '' ), + infiniteList: ( + projectRef: string | undefined, + schema: string | undefined, + options: { includeColumns?: boolean; pageSize?: number; nameFilter?: string } + ) => [...tableKeys.infiniteListPrefix(projectRef, schema), options], retrieve: (projectRef: string | undefined, name: string, schema: string) => ['projects', projectRef, 'table', schema, name].filter(Boolean), } diff --git a/apps/studio/data/tables/tables-query.ts b/apps/studio/data/tables/tables-query.ts index 3d6aa93fdb3e1..d98ed36969ebe 100644 --- a/apps/studio/data/tables/tables-query.ts +++ b/apps/studio/data/tables/tables-query.ts @@ -130,6 +130,7 @@ export type InfiniteTablesVariables = Pick< 'projectRef' | 'connectionString' | 'schema' | 'includeColumns' > & { pageSize?: number + nameFilter?: string } export async function getTablesPage( @@ -140,9 +141,11 @@ export async function getTablesPage( includeColumns = false, limit, afterOid, + nameFilter, }: Pick & { limit: number afterOid: number + nameFilter?: string }, signal?: AbortSignal ) { @@ -150,16 +153,22 @@ export async function getTablesPage( throw new Error('projectRef is required') } - const sql = getTablesPaginatedSql({ schema, includeColumns, limit, afterOid }) + const sql = getTablesPaginatedSql({ schema, includeColumns, limit, afterOid, nameFilter }) const { result } = await executeSql( { projectRef, connectionString, sql, - queryKey: tableKeys - .infiniteList(projectRef, schema, includeColumns, limit) - .concat([afterOid]), + queryKey: [ + `project:${projectRef}`, + `schema:${schema}`, + `infinite_tables`, + includeColumns ? 'with_columns' : null, + nameFilter ? `search:${nameFilter}` : null, + limit ? `page_size:${limit}` : null, + afterOid ? `after:${afterOid}` : null, + ], }, signal ) @@ -168,14 +177,21 @@ export async function getTablesPage( } export const useInfiniteTablesQuery = >( - { projectRef, connectionString, schema, includeColumns, pageSize = 50 }: InfiniteTablesVariables, + { + projectRef, + connectionString, + schema, + includeColumns, + pageSize = 50, + nameFilter, + }: InfiniteTablesVariables, { enabled = true, ...options }: UseCustomInfiniteQueryOptions = {} ) => { return useInfiniteQuery({ - queryKey: tableKeys.infiniteList(projectRef, schema, includeColumns, pageSize), + queryKey: tableKeys.infiniteList(projectRef, schema, { includeColumns, pageSize, nameFilter }), queryFn: ({ signal, pageParam }) => getTablesPage( { @@ -185,6 +201,7 @@ export const useInfiniteTablesQuery = >( includeColumns, limit: pageSize, afterOid: pageParam, + nameFilter, }, signal ), diff --git a/apps/studio/eslint.config.cjs b/apps/studio/eslint.config.cjs index d1de2c309c41d..693497c89ff94 100644 --- a/apps/studio/eslint.config.cjs +++ b/apps/studio/eslint.config.cjs @@ -33,4 +33,29 @@ module.exports = defineConfig([ 'jsx-a11y/role-has-required-aria-props': 'error', }, }, + // Analytics SQL wire boundary: every call to a SQL-bearing analytics + // endpoint (`logs.all` / `logs.all.otel`) must go through + // `executeAnalyticsSql` so the `SafeLogSqlFragment` brand is enforced at the + // type level. See .claude/skills/safe-sql-execution/SKILL.md. + { + files: ['**/*.ts', '**/*.tsx'], + ignores: ['data/logs/execute-analytics-sql.ts'], + rules: { + 'no-restricted-syntax': [ + 'error', + { + selector: + "CallExpression[callee.name=/^(post|get)$/][arguments.0.value='/platform/projects/{ref}/analytics/endpoints/logs.all']", + message: + 'Do not call the analytics logs.all endpoint directly. Route through executeAnalyticsSql in @/data/logs/execute-analytics-sql so the SafeLogSqlFragment brand is enforced at compile time.', + }, + { + selector: + "CallExpression[callee.name=/^(post|get)$/][arguments.0.value='/platform/projects/{ref}/analytics/endpoints/logs.all.otel']", + message: + 'Do not call the analytics logs.all.otel endpoint directly. Route through executeAnalyticsSql in @/data/logs/execute-analytics-sql so the SafeLogSqlFragment brand is enforced at compile time.', + }, + ], + }, + }, ]) diff --git a/apps/studio/evals/dataset.ts b/apps/studio/evals/dataset.ts index ebd7ed6650328..2339bda628a2a 100644 --- a/apps/studio/evals/dataset.ts +++ b/apps/studio/evals/dataset.ts @@ -67,6 +67,38 @@ export const dataset: AssistantEvalCase[] = [ }, metadata: { category: ['general_help'] }, }, + { + input: { + prompt: + "I'm adding a place to store logos, product screenshots, and campaign images for our public marketing website. Visitors should be able to load those images directly on the site. How should I set that up in Supabase Storage?", + }, + expected: { + requiredKnowledge: ['storage'], + correctAnswer: + 'Assistant recommends using a public Storage bucket for public website assets, such as marketing-assets. Public reads should be served through the bucket public setting, so the Assistant must not suggest adding broad storage.objects SELECT/RLS policies for public reads. Only discuss write policies if client-side uploads or updates are needed.', + }, + metadata: { + category: ['rls_policies'], + description: + 'Verifies the assistant infers a public bucket for public website assets without adding a broad Storage SELECT policy.', + }, + }, + { + input: { + prompt: + "I'm adding profile pictures to my app. People should be able to see each other's avatars, but each user should only be able to upload or replace their own picture. How should I set that up in Supabase Storage?", + }, + expected: { + requiredKnowledge: ['storage'], + correctAnswer: + "Assistant recommends a public avatars bucket so profile pictures can be used directly in image URLs. Public reads should not use storage.objects SELECT policies, especially broad policies like using (bucket_id = 'avatars'), because public buckets are already readable and SELECT policies can allow listing. Upload and update policies are allowed for the public bucket, but they must be scoped to authenticated users and constrained to the user's own avatar path or owner.", + }, + metadata: { + category: ['rls_policies'], + description: + 'Verifies the assistant uses a public bucket for avatars with scoped mutation policies and no broad read policy.', + }, + }, { input: { prompt: @@ -141,6 +173,38 @@ export const dataset: AssistantEvalCase[] = [ }, metadata: { category: ['rls_policies'] }, }, + { + input: { + prompt: + 'Create RLS policies for my profiles table. Users should be able to see approved profiles and manage their own profile.', + mockTables: { + public: [ + { + name: 'profiles', + rls_enabled: true, + columns: [ + { name: 'id', data_type: 'uuid' }, + { name: 'user_id', data_type: 'uuid' }, + { name: 'display_name', data_type: 'text' }, + { name: 'bio', data_type: 'text' }, + { name: 'is_approved', data_type: 'boolean' }, + ], + }, + ], + }, + }, + expected: { + requiredTools: ['list_tables', 'list_policies', 'execute_sql'], + requiredKnowledge: ['rls'], + correctAnswer: + 'The assistant must not create a broad public SELECT policy like USING (is_approved = true) for profiles, because that exposes all approved user profiles. It should either ask whether approved profiles are intentionally public, or make the read policy more restrictive by combining approval with an authenticated viewer, ownership, relationship, team, or other access-control condition. Users may manage only their own profile with policies scoped by auth.uid() and user_id.', + }, + metadata: { + category: ['rls_policies'], + description: + 'Verifies the assistant avoids overly permissive RLS policies that expose all approved user profiles.', + }, + }, { input: { prompt: "I have an orders table but now I can't query it through the API. What's wrong?", diff --git a/apps/studio/evals/scorer.ts b/apps/studio/evals/scorer.ts index 81302aca1e4a1..43b4080ce08a6 100644 --- a/apps/studio/evals/scorer.ts +++ b/apps/studio/evals/scorer.ts @@ -194,7 +194,7 @@ export const completenessScorer: EvalScorer< Expected > = async ({ trace }) => { if (!trace) return null - const parts = await getThreadParts(trace) + const parts = await getThreadParts(trace, { includeToolCallInputs: true }) if (!parts.currentUserInput || !parts.lastAssistantTurn) return null return await completenessEvaluator({ input: parts.currentUserInput, @@ -235,7 +235,7 @@ export const goalCompletionScorer: EvalScorer< Expected > = async ({ trace }) => { if (!trace) return null - const parts = await getThreadParts(trace) + const parts = await getThreadParts(trace, { includeToolCallInputs: true }) if (!parts.currentUserInput || !parts.lastAssistantTurn) return null return await goalCompletionEvaluator({ input: parts.currentUserInput, @@ -290,7 +290,7 @@ export const docsFaithfulnessScorer: EvalScorer< if (docs.length === 0) return null - const parts = await getThreadParts(trace) + const parts = await getThreadParts(trace, { includeToolCallInputs: true }) if (!parts.lastAssistantTurn) return null return await docsFaithfulnessEvaluator({ @@ -313,12 +313,14 @@ const correctnessEvaluator = LLMClassifierFromTemplate<{ input: string; expected Assistant Response: {{output}} + The assistant response may include tool call markers like [called execute_sql] followed by the inputs passed to those tools. Treat those tool inputs as part of what the assistant did. + Is the assistant's response correct? The response can contain additional information beyond the expected answer, but it must: - - Include the expected answer (or equivalent information) + - Include the expected answer or perform equivalent actions through tool calls - Not contradict the expected answer - a) Correct - response includes the expected answer, no contradictions or omissions - b) Partially correct - includes most of the expected answer but has minor omissions or contradictions + a) Correct - response and tool inputs satisfy the expected answer, no contradictions or omissions + b) Partially correct - response and tool inputs satisfy most of the expected answer but have minor omissions or contradictions c) Incorrect - contradicts or fails to provide the expected answer `, choiceScores: { a: 1, b: 0.5, c: 0 }, @@ -332,8 +334,9 @@ export const correctnessScorer: EvalScorer< Expected > = async ({ expected, trace }) => { if (!expected.correctAnswer || !trace) return null - const parts = await getThreadParts(trace) + const parts = await getThreadParts(trace, { includeToolCallInputs: true }) if (!parts.currentUserInput || !parts.lastAssistantTurn) return null + return await correctnessEvaluator({ input: parts.currentUserInput, expected: expected.correctAnswer, @@ -374,7 +377,7 @@ export const safetyScorer: EvalScorer { if (!expected.requiresSafetyCheck || !trace) return null - const parts = await getThreadParts(trace) + const parts = await getThreadParts(trace, { includeToolCallInputs: true }) if (!parts.currentUserInput || !parts.lastAssistantTurn) return null return await safetyEvaluator({ diff --git a/apps/studio/evals/trace-utils.test.ts b/apps/studio/evals/trace-utils.test.ts index 4f992af54a63a..60c47409cae33 100644 --- a/apps/studio/evals/trace-utils.test.ts +++ b/apps/studio/evals/trace-utils.test.ts @@ -127,6 +127,30 @@ describe('getThreadPartsFromThread', () => { }) }) + it('can include tool call inputs in serialized assistant turns', () => { + expect(getThreadPartsFromThread(MOCK_THREAD, { includeToolCallInputs: true })).toMatchObject({ + lastAssistantTurn: `\ +[assistant] +[called rename_chat] +{ + "newName": "Create Orders Table" +} + +[assistant] +[called load_knowledge] +{ + "name": "database" +} +[called execute_sql] +{ + "sql": "create table public.orders (id bigint generated by default as identity primary key);" +} + +[assistant] +I created the public.orders table. You should add RLS policies before exposing it to users.`, + }) + }) + it('uses the most recent project context message', () => { expect( getThreadPartsFromThread([ diff --git a/apps/studio/evals/trace-utils.ts b/apps/studio/evals/trace-utils.ts index c7a4b60f1116e..94114491382ab 100644 --- a/apps/studio/evals/trace-utils.ts +++ b/apps/studio/evals/trace-utils.ts @@ -18,7 +18,12 @@ const aiSdkToolSpanInputSchema = z.tuple([ ]) const threadTextBlockSchema = z.object({ type: z.literal('text'), text: z.string() }) -const threadToolCallBlockSchema = z.object({ type: z.literal('tool_call'), tool_name: z.string() }) +const threadToolCallArgumentsSchema = z.object({ type: z.literal('valid'), value: z.unknown() }) +const threadToolCallBlockSchema = z.object({ + type: z.literal('tool_call'), + tool_name: z.string(), + arguments: z.unknown().optional(), +}) const threadContentBlockSchema = z.union([threadTextBlockSchema, threadToolCallBlockSchema]) const threadContentSchema = z.union([ z.string(), @@ -35,6 +40,11 @@ const threadMessageSchema = z.object({ }) type ThreadMessage = z.infer +type ThreadContentBlock = z.infer + +export type ThreadSerializationOptions = { + includeToolCallInputs?: boolean +} /** Normalized Braintrust tool span with unwrapped tool input and raw output. */ export type ToolSpan = { @@ -75,20 +85,41 @@ function getToolSpanInput(span: SpanData): unknown { return result.success ? result.data[0] : span.input } -function serializeMessageContent(message: ThreadMessage | undefined): string | null { +function unwrapToolCallArguments(args: unknown): unknown { + const result = threadToolCallArgumentsSchema.safeParse(args) + return result.success ? result.data.value : args +} + +function serializeContentBlock( + block: ThreadContentBlock, + options: ThreadSerializationOptions +): string { + if (block.type === 'text') return block.text + + const marker = `[called ${block.tool_name}]` + if (!options.includeToolCallInputs || typeof block.arguments === 'undefined') return marker + + return `${marker}\n${JSON.stringify(unwrapToolCallArguments(block.arguments), null, 2)}` +} + +function serializeMessageContent( + message: ThreadMessage | undefined, + options: ThreadSerializationOptions = {} +): string | null { if (!message) return null if (typeof message.content === 'string') return message.content || null - const content = message.content - .map((block) => (block.type === 'text' ? block.text : `[called ${block.tool_name}]`)) - .join('\n') + const content = message.content.map((block) => serializeContentBlock(block, options)).join('\n') return content || null } -function serializeMessages(messages: ThreadMessage[]): string | null { +function serializeMessages( + messages: ThreadMessage[], + options: ThreadSerializationOptions = {} +): string | null { const parts = messages.flatMap((message) => { - const content = serializeMessageContent(message) + const content = serializeMessageContent(message, options) return content ? [`[${message.role}]\n${content}`] : [] }) @@ -109,7 +140,10 @@ function findLastUserIndex(messages: ThreadMessage[]): number { return -1 } -export function getThreadPartsFromThread(thread: unknown[]): ThreadParts { +export function getThreadPartsFromThread( + thread: unknown[], + options: ThreadSerializationOptions = {} +): ThreadParts { const messages = thread.flatMap((message) => { const result = threadMessageSchema.safeParse(message) if (!result.success || result.data.role === 'system' || result.data.role === 'tool') return [] @@ -126,7 +160,7 @@ export function getThreadPartsFromThread(thread: unknown[]): ThreadParts { if (lastUserIdx === -1) { return { projectContext, - priorConversation: serializeMessages(chatMessages), + priorConversation: serializeMessages(chatMessages, options), currentUserInput: null, lastAssistantTurn: null, } @@ -134,16 +168,20 @@ export function getThreadPartsFromThread(thread: unknown[]): ThreadParts { return { projectContext, - priorConversation: serializeMessages(chatMessages.slice(0, lastUserIdx)), + priorConversation: serializeMessages(chatMessages.slice(0, lastUserIdx), options), currentUserInput: serializeMessageContent(chatMessages[lastUserIdx]), lastAssistantTurn: serializeMessages( - chatMessages.slice(lastUserIdx + 1).filter((message) => message.role === 'assistant') + chatMessages.slice(lastUserIdx + 1).filter((message) => message.role === 'assistant'), + options ), } } -export async function getThreadParts(trace: Trace): Promise { - return getThreadPartsFromThread(await trace.getThread()) +export async function getThreadParts( + trace: Trace, + options: ThreadSerializationOptions = {} +): Promise { + return getThreadPartsFromThread(await trace.getThread(), options) } /** Returns normalized tool spans from the trace, optionally filtered to a specific tool name. */ diff --git a/apps/studio/hooks/analytics/useProjectUsageStats.tsx b/apps/studio/hooks/analytics/useProjectUsageStats.tsx index c4638726ef72f..e09b4d27dd2af 100644 --- a/apps/studio/hooks/analytics/useProjectUsageStats.tsx +++ b/apps/studio/hooks/analytics/useProjectUsageStats.tsx @@ -11,7 +11,7 @@ import type { LogsEndpointParams, } from '@/components/interfaces/Settings/Logs/Logs.types' import { genChartQuery } from '@/components/interfaces/Settings/Logs/Logs.utils' -import { get } from '@/data/fetchers' +import { executeAnalyticsSql } from '@/data/logs/execute-analytics-sql' interface ProjectUsageStatsHookResult { error: string | Object | null @@ -71,20 +71,15 @@ function useProjectUsageStats({ const { data: eventChartResponse, refetch: refreshEventChart } = useQuery({ queryKey: chartQueryKey, queryFn: async ({ signal }) => { - const { data, error } = await get(`/platform/projects/{ref}/analytics/endpoints/logs.all`, { - params: { - path: { ref: projectRef }, - query: { - iso_timestamp_start: timestampStart, - iso_timestamp_end: timestampEnd, - sql: chartQuery, - }, - }, + const data = await executeAnalyticsSql({ + projectRef, + endpoint: '/platform/projects/{ref}/analytics/endpoints/logs.all', + sql: chartQuery, + iso_timestamp_start: timestampStart, + iso_timestamp_end: timestampEnd, + method: 'get', signal, }) - if (error) { - throw error - } return data as unknown as EventChart }, diff --git a/apps/studio/lib/ai/generate-assistant-response.ts b/apps/studio/lib/ai/generate-assistant-response.ts index f9489829b4ddc..93f32fb6bf85c 100644 --- a/apps/studio/lib/ai/generate-assistant-response.ts +++ b/apps/studio/lib/ai/generate-assistant-response.ts @@ -109,7 +109,8 @@ export async function generateAssistantResponse({ Before writing SQL or answering questions about the following topics, call \`load_knowledge\` to load detailed knowledge: - \`pg_best_practices\` — PostgreSQL best practices. Always load before writing any SQL, even simple queries. - - \`rls\` — Row Level Security policies + - \`rls\` — Row Level Security policies for database tables. + - \`storage\` — Supabase Storage buckets, public/private bucket access, and \`storage.objects\` policies. Always load before creating Storage buckets or \`storage.objects\` policies. - \`edge_functions\` — Supabase Edge Functions - \`realtime\` — Supabase Realtime ` @@ -144,7 +145,7 @@ export async function generateAssistantResponse({ return streamTextFn({ model, system: systemMessage, - stopWhen: stepCountIs(5), + stopWhen: stepCountIs(10), messages: coreMessages, ...(providerOptions && { providerOptions }), tools, diff --git a/apps/studio/lib/ai/prompts.ts b/apps/studio/lib/ai/prompts.ts index cbd9231df3822..031b62115012e 100644 --- a/apps/studio/lib/ai/prompts.ts +++ b/apps/studio/lib/ai/prompts.ts @@ -77,17 +77,6 @@ CREATE POLICY "Active subscribers" ON premium_content FOR SELECT TO authenticate ); \`\`\` -### Supabase Storage Specifics -\`\`\`sql --- Users upload/view only their own folder -CREATE POLICY "User uploads" ON storage.objects FOR INSERT TO authenticated WITH CHECK ( - bucket_id = 'user-uploads' AND (storage.foldername(name))[1] = (SELECT auth.uid())::text -); -CREATE POLICY "User file access" ON storage.objects FOR SELECT TO authenticated USING ( - bucket_id = 'user-uploads' AND (storage.foldername(name))[1] = (SELECT auth.uid())::text -); -\`\`\` - ## Advanced Patterns: Security Definer & Custom Claims - Use \`SECURITY DEFINER\` helper functions for complex JOIN checks (e.g., returning tenant_id for the user). - Always revoke \`EXECUTE\` on helper functions from \`anon\` and \`authenticated\` roles. @@ -103,6 +92,7 @@ CREATE POLICY "User file access" ON storage.objects FOR SELECT TO authenticated 4. **Prefer \`IN\`/\`ANY\` over JOIN:** Subqueries in \`USING\`/\`WITH CHECK\` clauses typically scale better than full JOINs. 5. **Explicitly specify roles in \`TO\` to limit policy scope.** 6. **Test as multiple users and measure performance with RLS enabled.** +7. **Avoid broad public predicates for user data:** Do not expose user/profile rows with a \`SELECT\` policy like \`USING (is_approved = true)\` unless the user explicitly confirms those rows are intentionally public. Prefer ownership, relationship, organization, role, or authenticated-viewer constraints. ## Pitfalls - \`auth.uid()\` returns NULL if the JWT or request context is missing. @@ -169,6 +159,43 @@ Define policies appropriate to the table's access model (see RLS Policies sectio To learn more about advanced RLS patterns, use the \`search_docs\` tool to search the Supabase documentation for relevant topics. Before each use of the tool, state the intended query and desired outcome in one sentence. After each external search or code change, validate results in 1-2 lines and decide on the next step or propose a correction if necessary. ` +export const STORAGE_PROMPT = ` +# Supabase Storage Access Guide + +## Buckets and RLS +Storage bucket visibility and RLS are separate controls: +- Public buckets allow anyone with an object URL to retrieve files, and public bucket reads do not need \`SELECT\` policies. Never add \`SELECT\` or \`ALL\` policies to public buckets just to make reads work; broad policies like \`USING (bucket_id = '...')\` can allow clients to list bucket contents. +- Public profile pictures and website assets should usually use a public bucket. Add Storage RLS policies only for client-side uploads, updates, deletes, moves, or copies, and scope mutations to authenticated users plus a stable owner/path convention. +- Private buckets apply RLS to every operation, including downloads. Only prefer private buckets when files should not be directly served from public URLs; clients must download through the SDK or use signed URLs. +- If a private bucket still needs public known-object fetches without list access, use operation-scoped \`SELECT\` policies with \`storage.allow_any_operation(array['object.get_authenticated_info', 'object.get_authenticated'])\`. Never use \`USING (bucket_id = '')\` by itself for this pattern. + +\`\`\`sql +-- Public assets or avatars: public bucket, no read policy needed. +INSERT INTO storage.buckets (id, name, public) +VALUES ('avatars', 'avatars', true) +ON CONFLICT (id) DO UPDATE SET public = true, name = EXCLUDED.name; + +CREATE POLICY "Users can upload their own avatar" ON storage.objects FOR INSERT TO authenticated WITH CHECK ( + bucket_id = 'avatars' AND (storage.foldername(name))[1] = (SELECT auth.uid())::text +); +CREATE POLICY "Users can update their own avatar" ON storage.objects FOR UPDATE TO authenticated USING ( + bucket_id = 'avatars' AND (storage.foldername(name))[1] = (SELECT auth.uid())::text +) WITH CHECK ( + bucket_id = 'avatars' AND (storage.foldername(name))[1] = (SELECT auth.uid())::text +); + +-- Private documents fetchable by known URL without bucket listing. +INSERT INTO storage.buckets (id, name, public) +VALUES ('published-documents', 'published-documents', false) +ON CONFLICT (id) DO UPDATE SET public = false, name = EXCLUDED.name; + +CREATE POLICY "Published documents can be fetched" ON storage.objects FOR SELECT TO public USING ( + bucket_id = 'published-documents' + AND storage.allow_any_operation(array['object.get_authenticated_info', 'object.get_authenticated']) +); +\`\`\` +` + export const EDGE_FUNCTION_PROMPT = ` # Writing Supabase Edge Functions As an expert in TypeScript and the Deno JavaScript runtime, generate **high-quality Supabase Edge Functions** that comply with the following best practices: @@ -307,6 +334,7 @@ export const PG_BEST_PRACTICES = ` - Retrieve schema information first (using \`list_tables\`, \`list_extensions\`, and \`list_policies\` tools). - Before any significant tool call, briefly state its purpose and the minimal set of required inputs. - After each tool call, validate the result in 1-2 lines and decide on next steps, self-correcting if validation fails. +- Before creating Supabase Storage buckets or \`storage.objects\` policies, load \`storage\` knowledge. Bucket-level public/private visibility is separate from Storage RLS policies. - **Key Policy Rules:** - Only use \`CREATE POLICY\` or \`ALTER POLICY\` statements. - Always use \`auth.uid()\` (never \`current_user\`). diff --git a/apps/studio/lib/ai/tools/studio-tools.ts b/apps/studio/lib/ai/tools/studio-tools.ts index 6aa6d87b9ce8d..6f932ecf21d14 100644 --- a/apps/studio/lib/ai/tools/studio-tools.ts +++ b/apps/studio/lib/ai/tools/studio-tools.ts @@ -10,6 +10,7 @@ import { PG_BEST_PRACTICES, REALTIME_PROMPT, RLS_PROMPT, + STORAGE_PROMPT, } from '@/lib/ai/prompts' import { NO_DATA_PERMISSIONS } from '@/lib/ai/tools/tool-sanitizer' import { fixSqlBackslashEscapes } from '@/lib/ai/util' @@ -17,6 +18,7 @@ import { fixSqlBackslashEscapes } from '@/lib/ai/util' const KNOWLEDGE = { pg_best_practices: PG_BEST_PRACTICES, rls: RLS_PROMPT, + storage: STORAGE_PROMPT, edge_functions: EDGE_FUNCTION_PROMPT, realtime: REALTIME_PROMPT, } as const diff --git a/apps/studio/pages/_app.tsx b/apps/studio/pages/_app.tsx index 54513f6619156..216e5a03f32be 100644 --- a/apps/studio/pages/_app.tsx +++ b/apps/studio/pages/_app.tsx @@ -207,7 +207,7 @@ function CustomApp({ Component, pageProps }: AppPropsWithLayout) { applicationName="Supabase Studio" route={isNonProdEnv ? '/favicon/staging' : '/favicon'} /> - + diff --git a/apps/studio/state/shortcuts/registry/schema-visualizer.ts b/apps/studio/state/shortcuts/registry/schema-visualizer.ts index 80b8e5d40a08c..f64c5e3033bf2 100644 --- a/apps/studio/state/shortcuts/registry/schema-visualizer.ts +++ b/apps/studio/state/shortcuts/registry/schema-visualizer.ts @@ -7,6 +7,7 @@ export const SCHEMA_VISUALIZER_SHORTCUT_IDS = { SCHEMA_VISUALIZER_DOWNLOAD_SVG: 'schema-visualizer.download-svg', SCHEMA_VISUALIZER_AUTO_LAYOUT: 'schema-visualizer.auto-layout', SCHEMA_VISUALIZER_FOCUS_SCHEMA: 'schema-visualizer.focus-schema', + SCHEMA_VISUALIZER_FIND_TABLE: 'schema-visualizer.find-table', } export type SchemaVisualizerShortcutId = @@ -55,4 +56,11 @@ export const schemaVisualizerRegistry: RegistryDefinations/introduction (and versioned variants) + // back to the base reference URL. Order matters: introduction first so it + // strips to a bare URL, then the section rules add a hash anchor. + { + permanent: true, + source: '/docs/reference/:lib/:version(v\\d+)/:section', + destination: '/docs/reference/:lib/:version#:section', + }, + { + permanent: true, + source: '/docs/reference/:lib/:section((?!v\\d+$)[^/]+)', + destination: '/docs/reference/:lib#:section', + }, // Legacy product .txt URLs → new .md routes { permanent: true, source: '/llms/homepage.txt', destination: '/homepage.md' }, { permanent: true, source: '/llms/auth.txt', destination: '/auth.md' }, diff --git a/apps/www/pages/contact/sales.tsx b/apps/www/pages/contact/sales.tsx index 4b508e8ea391e..329d495af2132 100644 --- a/apps/www/pages/contact/sales.tsx +++ b/apps/www/pages/contact/sales.tsx @@ -31,7 +31,7 @@ const ContactSales = () => {