diff --git a/apps/docs/.gitignore b/apps/docs/.gitignore index 5dbb96adaccc7..ca8ec6b88b967 100644 --- a/apps/docs/.gitignore +++ b/apps/docs/.gitignore @@ -40,6 +40,11 @@ public/docs/ # Generated reference content (built by scripts/build-reference-content.ts) /content/reference/ +# Federated guide content, fetched from an external repo by +# scripts/federated-content/fetch-federated-content.ts +/content/guides/graphql/ +/content/guides/graphql.mdx + # Downloaded TypeDoc dumps under spec/reference///. Regenerated by # `cd apps/docs/spec && make download.tsdoc.v2`. Hand-authored files in the # same folders (config.json, partials/) stay tracked. diff --git a/apps/docs/app/guides/graphql/[[...slug]]/page.tsx b/apps/docs/app/guides/graphql/[[...slug]]/page.tsx index eb0c01b48f5f1..66ac2c7fd6b26 100644 --- a/apps/docs/app/guides/graphql/[[...slug]]/page.tsx +++ b/apps/docs/app/guides/graphql/[[...slug]]/page.tsx @@ -1,198 +1,26 @@ -import { isAbsolute, relative } from 'path' -import { GuideTemplate, newEditLink } from '~/features/docs/GuidesMdx.template' -import { genGuideMeta } from '~/features/docs/GuidesMdx.utils' +import { GuideTemplate } from '~/features/docs/GuidesMdx.template' +import { + genGuideMeta, + genGuidesStaticParams, + getGuidesMarkdown, +} from '~/features/docs/GuidesMdx.utils' import { getEmptyArray } from '~/features/helpers.fn' import { IS_DEV } from '~/lib/constants' -import { linkTransform, UrlTransformFunction } from '~/lib/mdx/plugins/rehypeLinkTransform' -import remarkMkDocsAdmonition from '~/lib/mdx/plugins/remarkAdmonition' -import { removeTitle } from '~/lib/mdx/plugins/remarkRemoveTitle' -import remarkPyMdownTabs from '~/lib/mdx/plugins/remarkTabs' -import { getGitHubFileContents } from '~/lib/octokit' -import { SerializeOptions } from '~/types/next-mdx-remote-serialize' -import { notFound } from 'next/navigation' -import rehypeSlug from 'rehype-slug' -// We fetch these docs at build time from an external repo -const org = 'supabase' -const repo = 'pg_graphql' -const branch = 'master' -const docsDir = 'docs' -const externalSite = 'https://supabase.github.io/pg_graphql' - -// Each external docs page is mapped to a local page -const pageMap = [ - { - meta: { - id: 'graphql-overview', - title: 'GraphQL', - subtitle: 'Autogenerated GraphQL APIs with Postgres.', - }, - remoteFile: 'supabase.md', - }, - { - slug: 'api', - meta: { - id: 'graphql-api', - title: 'GraphQL API', - subtitle: 'Understanding the core concepts of the GraphQL API.', - }, - remoteFile: 'api.md', - }, - { - slug: 'views', - meta: { - id: 'graphql-views', - title: 'Views', - subtitle: 'Using Postgres Views with GraphQL.', - }, - remoteFile: 'views.md', - }, - { - slug: 'functions', - meta: { - id: 'graphql-functions', - title: 'Functions', - subtitle: 'Using Postgres Functions with GraphQL.', - }, - remoteFile: 'functions.md', - }, - { - slug: 'computed-fields', - meta: { - id: 'graphql-computed-fields', - title: 'Computed Fields', - subtitle: 'Using Postgres Computed Fields with GraphQL.', - }, - remoteFile: 'computed_fields.md', - }, - { - slug: 'configuration', - meta: { - id: 'graphql-configuration', - title: 'Configuration & Customization', - subtitle: 'Extra configuration options can be set on SQL entities using comment directives.', - }, - remoteFile: 'configuration.md', - }, - { - slug: 'security', - meta: { - id: 'graphql-security', - title: 'Security', - subtitle: 'Securing your GraphQL API.', - }, - remoteFile: 'security.md', - }, - { - slug: 'with-apollo', - meta: { - id: 'graphql-with-apollo', - title: 'With Apollo', - subtitle: 'Using pg_grapqhl with Apollo.', - }, - remoteFile: 'usage_with_apollo.md', - }, - { - slug: 'with-relay', - meta: { - id: 'graphql-with-relay', - title: 'With Relay', - subtitle: 'Using pg_grapqhl with Relay.', - }, - remoteFile: 'usage_with_relay.md', - }, -] - -interface Params { - slug?: string[] -} +type Params = { slug?: string[] } const PGGraphQLDocs = async (props: { params: Promise }) => { const params = await props.params - const { meta, ...data } = await getContent(params) - - const options = { - mdxOptions: { - remarkPlugins: [remarkMkDocsAdmonition, remarkPyMdownTabs, [removeTitle, meta.title]], - rehypePlugins: [[linkTransform, urlTransform], rehypeSlug], - }, - } as SerializeOptions - - return -} - -/** - * Fetch markdown from external repo and transform links - */ -const getContent = async ({ slug }: Params) => { - const page = pageMap.find((page) => page.slug === slug?.at(0)) - - if (!page) { - notFound() - } - - const { remoteFile, meta } = page - - const editLink = newEditLink(`${org}/${repo}/blob/${branch}/${docsDir}/${remoteFile}`) - - const content = await getGitHubFileContents({ - org, - repo, - path: `${docsDir}/${remoteFile}`, - branch, - }) - - return { - pathname: `/guides/graphql${slug?.length ? `/${slug.join('/')}` : ''}` satisfies `/${string}`, - meta, - content, - editLink, - } -} - -const urlTransform: UrlTransformFunction = (url) => { - try { - const externalSiteUrl = new URL(externalSite) - - const placeholderHostname = 'placeholder' - const { hostname, pathname, hash } = new URL(url, `http://${placeholderHostname}`) - - // Don't modify a url with a FQDN or a url that's only a hash - if (hostname !== placeholderHostname || pathname === '/') { - return url - } - - const getRelativePath = () => { - if (pathname.endsWith('.md')) { - return pathname.replace(/\.md$/, '') - } - if (isAbsolute(url)) { - return relative(externalSiteUrl.pathname, pathname) - } - return pathname - } - - const relativePath = getRelativePath().replace(/^\//, '') - - const page = pageMap.find(({ remoteFile }) => `${relativePath}.md` === remoteFile) - - // If we have a mapping for this page, use the mapped path - if (page) { - return '/docs/guides/graphql/' + page.slug + hash - } + const slug = ['graphql', ...(params.slug ?? [])] + const data = await getGuidesMarkdown(slug) - // If we don't have this page in our docs, link to original docs - return `${externalSite}/${relativePath}${hash}` - } catch (err) { - console.error('Error transforming markdown URL', err) - return url - } + return } -const generateStaticParams = !IS_DEV - ? async () => pageMap.map(({ slug }) => ({ slug: slug ? [slug] : [] })) - : getEmptyArray -const generateMetadata = genGuideMeta(getContent) +const generateStaticParams = !IS_DEV ? genGuidesStaticParams('graphql') : getEmptyArray +const generateMetadata = genGuideMeta((params: { slug?: string[] }) => + getGuidesMarkdown(['graphql', ...(params.slug ?? [])]) +) export default PGGraphQLDocs -export { generateMetadata, generateStaticParams } +export { generateStaticParams, generateMetadata } diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts index dbcfb17e11240..ccbb06a95ddce 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts @@ -740,6 +740,10 @@ export const auth: NavMenuConstant = { name: 'Flows (How-tos)', enabled: authFlowsEnabled, items: [ + { + name: 'Which package to use', + url: '/guides/auth/choosing-a-server-package', + }, { name: 'Server-Side Rendering', url: '/guides/auth/server-side', diff --git a/apps/docs/content/guides/auth/choosing-a-server-package.mdx b/apps/docs/content/guides/auth/choosing-a-server-package.mdx new file mode 100644 index 0000000000000..199d6357eb2aa --- /dev/null +++ b/apps/docs/content/guides/auth/choosing-a-server-package.mdx @@ -0,0 +1,95 @@ +--- +title: 'Which package to use' +subtitle: 'When to use supabase-js, @supabase/ssr, or @supabase/server on the server.' +--- + +When you use Supabase from JavaScript on the server, there are three packages to choose from. They are not alternatives to each other — `@supabase/ssr` and `@supabase/server` both build on top of `supabase-js` and solve different problems. This guide helps you pick the right one. + + + +These are JavaScript packages, not separate language SDKs. If you're looking for the client library for another language (Python, Swift, Kotlin, etc.), see the [client library references](/docs/reference). + + + +## Which package to use + +The quickest way to decide is by **how the user's identity reaches your code**: + +- The session lives in **cookies** (an SSR framework like Next.js or SvelteKit) → use **`@supabase/ssr`**. +- Auth arrives **per request in headers** (`Authorization: Bearer `) → use **`@supabase/server`**. +- You want the base client, or you're handling auth yourself → use **`@supabase/supabase-js`** directly. + +| Package | Use it when | Runs in | Auth model | +| ----------------------- | ----------------------------------------------------- | ----------------------------------------------------------------------------------- | -------------------------------------------------- | +| `@supabase/supabase-js` | You want the base client, or you manage auth yourself | Browser and server | You wire up auth | +| `@supabase/ssr` | User sessions are stored in **cookies** | SSR frameworks (Next.js, SvelteKit, TanStack Start) | Cookie-based sessions, with refresh-token rotation | +| `@supabase/server` | Auth arrives **per request in headers** | Edge Functions, Workers, Vercel, Bun, and framework APIs (Hono, H3, Elysia, NestJS) | Stateless Bearer JWT + `apikey` | + +## `@supabase/supabase-js` + +The isomorphic base client. `@supabase/ssr` and `@supabase/server` both wrap it — reach for `supabase-js` directly when you don't need either wrapper's auth handling. + +```ts +import { createClient } from '@supabase/supabase-js' + +const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_PUBLISHABLE_KEY!) +``` + +## `@supabase/ssr` + +For SSR frameworks that store the user's session in cookies. It reads and writes session cookies and handles refresh-token rotation, so the same user and session are available on both the client and the server. + +```ts +import { createServerClient } from '@supabase/ssr' + +const supabase = createServerClient( + process.env.SUPABASE_URL!, + process.env.SUPABASE_PUBLISHABLE_KEY!, + { + cookies: { + getAll() { + // return the request's cookies + }, + setAll(cookiesToSet) { + // write cookies back on the response + }, + }, + } +) +``` + +See the [server-side rendering guide](/guides/auth/server-side) for framework-specific setup. + +## `@supabase/server` + +For stateless, header-based auth in backend runtimes — Edge Functions, Workers, and framework APIs. You declare who may call an endpoint and receive a ready-to-use context (a caller-scoped client that respects RLS, plus an admin client). It verifies JWTs and resolves the new API keys (`SUPABASE_PUBLISHABLE_KEYS` / `SUPABASE_SECRET_KEYS`) for you. + +```ts +import { withSupabase } from '@supabase/server' + +export default { + fetch: withSupabase({ auth: 'user' }, async (req, ctx) => { + // ctx.supabase is scoped to the caller and respects RLS + const { data } = await ctx.supabase.from('todos').select() + return Response.json(data) + }), +} +``` + +See the [`@supabase/server` reference](/docs/reference/server) for the full API. + + + +`@supabase/ssr` and `@supabase/server` coexist and are not replacements for each other, and `@supabase/ssr` is not deprecated. Pick based on where your code runs and how auth reaches it, using the table above. + + + +## Advanced: Combining `@supabase/server` and `@supabase/ssr` + +In a cookie-based framework you can compose the two — let `@supabase/ssr` own the cookie session lifecycle and hand the resolved token to `@supabase/server`'s primitives. This requires more setup, and deeper first-party integration is on the roadmap. See the [`@supabase/server` SSR frameworks guide](https://github.com/supabase/server/blob/main/docs/ssr-frameworks.md). + +## Next steps + +- [Server-side rendering](/guides/auth/server-side) — set up `@supabase/ssr` for your framework. +- [`@supabase/server` reference](/docs/reference/server) — API for header-based server auth. +- [`supabase-js` reference](/docs/reference/javascript) — the base JavaScript client. diff --git a/apps/docs/content/guides/auth/social-login/auth-apple.mdx b/apps/docs/content/guides/auth/social-login/auth-apple.mdx index a419aa82a8c92..8bc81e9429c4a 100644 --- a/apps/docs/content/guides/auth/social-login/auth-apple.mdx +++ b/apps/docs/content/guides/auth/social-login/auth-apple.mdx @@ -120,7 +120,7 @@ The platform-specific examples below demonstrate how to implement this pattern f 4. A **Services ID** which uniquely identifies the web services provided by the app you registered in the previous step. You can create a new Services ID from the [Identifiers](https://developer.apple.com/account/resources/identifiers/list/serviceId) section in the Apple Developer Console (use the filter menu in the upper right side to see all Services IDs). These usually are a reverse domain name string, for example `com.example.app.web`. 5. Configure Website URLs for the newly created **Services ID**. The web domain you should use is the domain your Supabase project is hosted on. This is usually `.supabase.co` while the redirect URL is `https://.supabase.co/auth/v1/callback`. 6. Create a signing **Key** in the [Keys](https://developer.apple.com/account/resources/authkeys/list) section of the Apple Developer Console. You can use this key to generate a secret key using the tool below, which is added to your Supabase project's Auth configuration. Make sure you safely store the `AuthKey_XXXXXXXXXX.p8` file. If you ever lose access to it, or make it public accidentally, revoke it from the Apple Developer Console and create a new one immediately. - 7. Finally, add the information you configured above to the [Apple provider configuration in the Supabase dashboard](/dashboard/project/_/auth/providers). + 7. Finally, add the information you configured above to the [Apple provider configuration in the Supabase dashboard](/dashboard/project/_/auth/providers). If your project also uses native Sign in with Apple (for example on iOS, Expo, or Flutter), list this Services ID as the **first** entry in the _Client IDs_ field. Supabase uses the first client ID in the list for the web `signInWithOAuth` flow, while the native `signInWithIdToken` flow accepts any client ID in the list as a valid token audience, regardless of order. If a native App ID comes before the Services ID, native sign-in keeps working but web sign-in is rejected by Apple. You can also configure the Apple auth provider using the Management API: @@ -508,7 +508,7 @@ curl -X PATCH "https://api.supabase.com/v1/projects/$PROJECT_REF/config/auth" \ 4. A **Services ID** which uniquely identifies the web services provided by the app you registered in the previous step. You can create a new Services ID from the [Identifiers](https://developer.apple.com/account/resources/identifiers/list/serviceId) section in the Apple Developer Console (use the filter menu in the upper right side to see all Services IDs). These usually are a reverse domain name string, for example `com.example.app.web`. 5. Configure Website URLs for the newly created **Services ID**. The web domain you should use is the domain your Supabase project is hosted on. This is usually `.supabase.co` while the redirect URL is `https://.supabase.co/auth/v1/callback`. 6. Create a signing **Key** in the [Keys](https://developer.apple.com/account/resources/authkeys/list) section of the Apple Developer Console. You can use this key to generate a secret key using the tool below, which is added to your Supabase project's Auth configuration. Make sure you safely store the `AuthKey_XXXXXXXXXX.p8` file. If you ever lose access to it, or make it public accidentally, revoke it from the Apple Developer Console and create a new one immediately. You will have to generate a new secret key using this file every 6 months, so make sure you schedule a recurring reminder in your calendar! - 7. Finally, add the information you configured above to the [Apple provider configuration in the Supabase dashboard](/dashboard/project/_/auth/providers). + 7. Finally, add the information you configured above to the [Apple provider configuration in the Supabase dashboard](/dashboard/project/_/auth/providers). If your app also uses native Sign in with Apple (on iOS or macOS), list this Services ID as the **first** entry in the _Client IDs_ field. Supabase uses the first client ID in the list for the web `signInWithOAuth` flow, while the native `signInWithIdToken` flow accepts any client ID in the list as a valid token audience, regardless of order. If a native App ID comes before the Services ID, native sign-in keeps working but web sign-in is rejected by Apple. diff --git a/apps/docs/data/content-listings/auth.data.ts b/apps/docs/data/content-listings/auth.data.ts index e70d1cd58367c..1374ac25b32c9 100644 --- a/apps/docs/data/content-listings/auth.data.ts +++ b/apps/docs/data/content-listings/auth.data.ts @@ -16,6 +16,11 @@ export const authGetStarted: ContentListingGroup = { href: '/guides/auth/server-side', description: 'Create a Supabase client for SSR frameworks like Next.js and SvelteKit.', }, + { + title: 'Which package to use', + href: '/guides/auth/choosing-a-server-package', + description: 'supabase-js vs @supabase/ssr vs @supabase/server — which to use on the server.', + }, { title: 'Row Level Security', href: '/guides/database/postgres/row-level-security', diff --git a/apps/docs/docs/ref/javascript/introduction.mdx b/apps/docs/docs/ref/javascript/introduction.mdx index 38419887755c4..b0ed783aa6a0d 100644 --- a/apps/docs/docs/ref/javascript/introduction.mdx +++ b/apps/docs/docs/ref/javascript/introduction.mdx @@ -7,3 +7,5 @@ hideTitle: true This reference documents every object and method available in Supabase's isomorphic JavaScript library, `supabase-js`. You can use `supabase-js` to interact with your Postgres database, listen to database changes, invoke Deno Edge Functions, build login and user management functionality, and manage large files. To convert SQL queries to `supabase-js` calls, use the [SQL to REST API translator](/docs/guides/api/sql-to-rest). + +Using `supabase-js` on the server? See [which package to use](/docs/guides/auth/choosing-a-server-package) to decide between `supabase-js`, `@supabase/ssr`, and `@supabase/server`. diff --git a/apps/docs/docs/ref/server/introduction.mdx b/apps/docs/docs/ref/server/introduction.mdx index eaacb0c51dd50..5c9e884b186ee 100644 --- a/apps/docs/docs/ref/server/introduction.mdx +++ b/apps/docs/docs/ref/server/introduction.mdx @@ -6,3 +6,5 @@ title: Introduction `@supabase/server` is a framework-agnostic library for authenticating requests in server-side JavaScript environments. It verifies JWTs, resolves Supabase API keys, and creates pre-configured Supabase clients — exposing everything through a single `SupabaseContext` that is identical regardless of which adapter or primitive produced it. Adapters for Hono, H3, Elysia, and NestJS are included. You can also compose the lower-level primitives directly for custom frameworks or edge runtimes. + +Not sure this is the right package? See [which package to use](/docs/guides/auth/choosing-a-server-package) — for cookie-based sessions in SSR frameworks, use [`@supabase/ssr`](/docs/guides/auth/server-side) instead. diff --git a/apps/docs/features/docs/GuidesMdx.utils.tsx b/apps/docs/features/docs/GuidesMdx.utils.tsx index 1cd8e04a9eb8c..fc47f2664aeef 100644 --- a/apps/docs/features/docs/GuidesMdx.utils.tsx +++ b/apps/docs/features/docs/GuidesMdx.utils.tsx @@ -30,7 +30,7 @@ const PUBLISHED_SECTIONS = [ 'deployment', 'functions', 'getting-started', - // 'graphql', -- technically published, but completely federated + 'graphql', 'integrations', 'local-development', 'platform', @@ -77,13 +77,14 @@ const getGuidesMarkdownInternal = async (slug: string[]) => { throw Error(`Type of frontmatter is not valid for path: ${fullPath}`) } + const { editLink: editLinkOverride, ...restMeta } = meta const editLink = newEditLink( - `supabase/supabase/blob/master/apps/docs/content/guides/${relPath}.mdx` + editLinkOverride ?? `supabase/supabase/blob/master/apps/docs/content/guides/${relPath}.mdx` ) return { pathname: `/guides/${slug.join('/')}` satisfies `/${string}`, - meta, + meta: restMeta, content, editLink, } diff --git a/apps/docs/lib/docs.ts b/apps/docs/lib/docs.ts index bfd0b1e426cba..d070cc84c47ab 100644 --- a/apps/docs/lib/docs.ts +++ b/apps/docs/lib/docs.ts @@ -28,6 +28,12 @@ export type GuideFrontmatter = { /** @deprecated */ hide_table_of_contents?: boolean tocVideo?: string + /** + * Overrides the "Edit this page on GitHub" link. Used for federated + * content, whose source of truth lives in an external repo rather than + * this generated file. + */ + editLink?: string } /** @@ -64,6 +70,9 @@ export function isValidGuideFrontmatter(obj: object): obj is GuideFrontmatter { if ('tocVideo' in obj && typeof obj.tocVideo !== 'string') { throw Error(`Invalid guide frontmatter: tocVideo must be a string. Received ${obj.tocVideo}`) } + if ('editLink' in obj && typeof obj.editLink !== 'string') { + throw Error(`Invalid guide frontmatter: editLink must be a string. Received: ${obj.editLink}`) + } return true } diff --git a/apps/docs/package.json b/apps/docs/package.json index 8f2b95802220a..737d46427f83a 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -7,6 +7,7 @@ "build": "next build", "build:analyze": "ANALYZE=true next build", "build:guides-markdown": "tsx ./internals/generate-guides-markdown.ts", + "build:federated-content": "tsx --conditions=react-server ./scripts/federated-content/fetch-federated-content.ts", "prebuild:reference-markdown": "pnpm run codegen:references", "build:reference-markdown": "tsx ./internals/generate-reference-markdown.ts", "build:markdown": "pnpm build:guides-markdown && pnpm build:reference-markdown", @@ -35,8 +36,8 @@ "lint": "eslint .", "lint:mdx": "supa-mdx-lint content --config ../../supa-mdx-lint.config.toml", "postbuild": "pnpm run build:sitemap && ./../../scripts/upload-static-assets.sh", - "prebuild": "pnpm run codegen:graphql && pnpm run codegen:references && pnpm run codegen:examples && pnpm run build:markdown && pnpm run build:gz-archive", - "predev": "pnpm run codegen:graphql && pnpm run codegen:references && pnpm run codegen:examples && pnpm run build:markdown", + "prebuild": "pnpm run codegen:graphql && pnpm run codegen:references && pnpm run codegen:examples && pnpm build:federated-content && pnpm run build:markdown && pnpm run build:gz-archive", + "predev": "pnpm run codegen:graphql && pnpm run codegen:references && pnpm run codegen:examples && pnpm build:federated-content && pnpm run build:markdown", "preembeddings": "pnpm run codegen:references", "preinstall": "npx only-allow pnpm", "presync": "pnpm run codegen:graphql", diff --git a/apps/docs/scripts/federated-content/fetch-federated-content.ts b/apps/docs/scripts/federated-content/fetch-federated-content.ts new file mode 100644 index 0000000000000..91b1df9c4e4a7 --- /dev/null +++ b/apps/docs/scripts/federated-content/fetch-federated-content.ts @@ -0,0 +1,143 @@ +import '../utils/dotenv' + +import { mkdir, readdir, writeFile } from 'node:fs/promises' +import { dirname, isAbsolute, join, relative } from 'node:path' +import { fileURLToPath } from 'node:url' +import { BASE_PATH } from '~/lib/constants' +import { GUIDES_DIRECTORY } from '~/lib/docs' +import remarkMkDocsAdmonition from '~/lib/mdx/plugins/remarkAdmonition' +import { removeTitle } from '~/lib/mdx/plugins/remarkRemoveTitle' +import remarkPyMdownTabs from '~/lib/mdx/plugins/remarkTabs' +import { getGitHubFileContents } from '~/lib/octokit' +import matter from 'gray-matter' +import { fromMarkdown } from 'mdast-util-from-markdown' +import { gfmFromMarkdown, gfmToMarkdown } from 'mdast-util-gfm' +import { mdxFromMarkdown, mdxToMarkdown } from 'mdast-util-mdx' +import { toMarkdown } from 'mdast-util-to-markdown' +import { gfm } from 'micromark-extension-gfm' +import { mdxjs } from 'micromark-extension-mdxjs' +import { visit } from 'unist-util-visit' + +import type { FederatedContentSource, FederatedPage } from './types' + +const SOURCES_DIR = join(dirname(fileURLToPath(import.meta.url)), 'sources') + +const PARSE_OPTIONS = { + extensions: [mdxjs(), gfm()], + mdastExtensions: [mdxFromMarkdown(), gfmFromMarkdown()], +} +// `fences: true` keeps code blocks as ``` rather than indented, so they +// aren't misread differently (e.g. as MDX/JSX) than they were authored. +const STRINGIFY_OPTIONS = { + extensions: [mdxToMarkdown(), gfmToMarkdown()], + bullet: '-' as const, + listItemIndent: 'one' as const, + fences: true, +} + +/** + * Discovers every `FederatedContentSource` under `./sources`. + */ +async function loadSources(): Promise { + const files = (await readdir(SOURCES_DIR)).filter((file) => /\.tsx?$/.test(file)) + + return Promise.all( + files.map(async (file) => { + const mod = await import(join(SOURCES_DIR, file)) + return mod.default as FederatedContentSource + }) + ) +} + +/** + * Rewrites a link URL: pages mapped in `source.pageMap` go to their local + * `/guides/
` route, everything else falls back to `externalSite`. + */ +function transformUrl(source: FederatedContentSource, url: string): string { + try { + const placeholderHostname = 'placeholder' + const { hostname, pathname, hash } = new URL(url, `http://${placeholderHostname}`) + + // Don't modify a url with a FQDN or a url that's only a hash + if (hostname !== placeholderHostname || pathname === '/') { + return url + } + + const relativePath = ( + pathname.endsWith('.md') + ? pathname.replace(/\.md$/, '') + : isAbsolute(url) + ? relative(new URL(source.externalSite).pathname, pathname) + : pathname + ).replace(/^\//, '') + + const mapped = source.pageMap.find(({ remoteFile }) => `${relativePath}.md` === remoteFile) + + // If we have a mapping for this page, use the mapped path; otherwise + // link to the original docs + return mapped + ? `${BASE_PATH}/guides/${source.section}${mapped.slug ? `/${mapped.slug}` : ''}${hash}` + : `${source.externalSite}/${relativePath}${hash}` + } catch (err) { + throw Error('[DOCS] fetch-federated-content: Error transforming markdown URL', { cause: err }) + } +} + +async function fetchPage(source: FederatedContentSource, page: FederatedPage): Promise { + const raw = await getGitHubFileContents({ + org: source.org, + repo: source.repo, + path: `${source.docsDir}/${page.remoteFile}`, + branch: source.branch, + }) + + const tree = fromMarkdown(raw, PARSE_OPTIONS) + remarkMkDocsAdmonition()(tree) + remarkPyMdownTabs()(tree) + removeTitle(page.meta.title)(tree) + visit(tree, ['link', 'image', 'definition'], (node: any) => { + node.url = transformUrl(source, node.url) + }) + const content = toMarkdown(tree, STRINGIFY_OPTIONS).trim() + + const frontmatter: Record = { + title: page.meta.title, + // Points the "Edit this page on GitHub" link back at the source repo + // instead of this generated file. + editLink: `${source.org}/${source.repo}/blob/${source.branch}/${source.docsDir}/${page.remoteFile}`, + } + if (page.meta.subtitle) frontmatter.subtitle = page.meta.subtitle + + return matter.stringify(`${content}\n`, frontmatter) +} + +async function fetchSource(source: FederatedContentSource): Promise { + await mkdir(join(GUIDES_DIRECTORY, source.section), { recursive: true }) + + await Promise.all( + source.pageMap.map(async (page) => { + const output = await fetchPage(source, page) + + const outPath = page.slug + ? join(GUIDES_DIRECTORY, source.section, `${page.slug}.mdx`) + : join(GUIDES_DIRECTORY, `${source.section}.mdx`) + + await writeFile(outPath, output) + }) + ) +} + +async function fetchFederatedContent() { + const sources = await loadSources() + + await Promise.all(sources.map(fetchSource)) + + const pageCount = sources.reduce((sum, source) => sum + source.pageMap.length, 0) + console.log( + `Fetched ${pageCount} federated page(s) across ${sources.length} source(s) into content/guides/` + ) +} + +fetchFederatedContent().catch((error) => { + throw error +}) diff --git a/apps/docs/scripts/federated-content/sources/graphql.ts b/apps/docs/scripts/federated-content/sources/graphql.ts new file mode 100644 index 0000000000000..f3fdfc57f6a05 --- /dev/null +++ b/apps/docs/scripts/federated-content/sources/graphql.ts @@ -0,0 +1,87 @@ +import type { FederatedContentSource } from '../types' + +// We fetch these docs at build time from an external repo +const graphql: FederatedContentSource = { + section: 'graphql', + org: 'supabase', + repo: 'pg_graphql', + branch: 'master', + docsDir: 'docs', + externalSite: 'https://supabase.github.io/pg_graphql', + pageMap: [ + { + meta: { + title: 'GraphQL', + subtitle: 'Autogenerated GraphQL APIs with Postgres.', + }, + remoteFile: 'supabase.md', + }, + { + slug: 'api', + meta: { + title: 'GraphQL API', + subtitle: 'Understanding the core concepts of the GraphQL API.', + }, + remoteFile: 'api.md', + }, + { + slug: 'views', + meta: { + title: 'Views', + subtitle: 'Using Postgres Views with GraphQL.', + }, + remoteFile: 'views.md', + }, + { + slug: 'functions', + meta: { + title: 'Functions', + subtitle: 'Using Postgres Functions with GraphQL.', + }, + remoteFile: 'functions.md', + }, + { + slug: 'computed-fields', + meta: { + title: 'Computed Fields', + subtitle: 'Using Postgres Computed Fields with GraphQL.', + }, + remoteFile: 'computed_fields.md', + }, + { + slug: 'configuration', + meta: { + title: 'Configuration & Customization', + subtitle: + 'Extra configuration options can be set on SQL entities using comment directives.', + }, + remoteFile: 'configuration.md', + }, + { + slug: 'security', + meta: { + title: 'Security', + subtitle: 'Securing your GraphQL API.', + }, + remoteFile: 'security.md', + }, + { + slug: 'with-apollo', + meta: { + title: 'With Apollo', + subtitle: 'Using pg_grapqhl with Apollo.', + }, + remoteFile: 'usage_with_apollo.md', + }, + { + slug: 'with-relay', + meta: { + title: 'With Relay', + subtitle: 'Using pg_grapqhl with Relay.', + }, + remoteFile: 'usage_with_relay.md', + }, + ], +} + +export default graphql diff --git a/apps/docs/scripts/federated-content/types.ts b/apps/docs/scripts/federated-content/types.ts new file mode 100644 index 0000000000000..eddd7f9cb2697 --- /dev/null +++ b/apps/docs/scripts/federated-content/types.ts @@ -0,0 +1,33 @@ +/** + * A single external page that gets federated into a local guide. + */ +export interface FederatedPage { + /** Local slug, relative to `section`. Omit for the section's index page. */ + slug?: string + meta: { + title: string + subtitle?: string + } + /** Path of the file in the remote repo, relative to `docsDir`. */ + remoteFile: string +} + +/** + * Describes where to fetch a set of external docs pages from, and how they + * map onto local `/guides/
` routes. + * + * Add a new entry point by exporting a `FederatedContentSource` as the + * default export of a file under `./sources`. + */ +export interface FederatedContentSource { + /** Guide section these pages are mounted under, e.g. 'graphql' -> content/guides/graphql */ + section: string + org: string + repo: string + branch: string + /** Directory in the remote repo containing the docs, e.g. 'docs'. */ + docsDir: string + /** Public site the remote docs are also published to, used to resolve unmapped links. */ + externalSite: string + pageMap: FederatedPage[] +} diff --git a/apps/docs/spec/reference/javascript/v2/partials/introduction.mdx b/apps/docs/spec/reference/javascript/v2/partials/introduction.mdx index ff2524c82f9b2..2de91f081eba8 100644 --- a/apps/docs/spec/reference/javascript/v2/partials/introduction.mdx +++ b/apps/docs/spec/reference/javascript/v2/partials/introduction.mdx @@ -6,3 +6,5 @@ title: Introduction This reference documents every object and method available in Supabase's isomorphic JavaScript library, `supabase-js`. You can use `supabase-js` to interact with your Postgres database, listen to database changes, invoke Deno Edge Functions, build login and user management functionality, and manage large files. To convert SQL queries to `supabase-js` calls, use the [SQL to REST API translator](/docs/guides/api/sql-to-rest). + +Using `supabase-js` on the server? See [which package to use](/docs/guides/auth/choosing-a-server-package) to decide between `supabase-js`, `@supabase/ssr`, and `@supabase/server`. diff --git a/apps/docs/spec/reference/server/v1/partials/introduction.mdx b/apps/docs/spec/reference/server/v1/partials/introduction.mdx index eaacb0c51dd50..146ee752b2263 100644 --- a/apps/docs/spec/reference/server/v1/partials/introduction.mdx +++ b/apps/docs/spec/reference/server/v1/partials/introduction.mdx @@ -6,3 +6,9 @@ title: Introduction `@supabase/server` is a framework-agnostic library for authenticating requests in server-side JavaScript environments. It verifies JWTs, resolves Supabase API keys, and creates pre-configured Supabase clients — exposing everything through a single `SupabaseContext` that is identical regardless of which adapter or primitive produced it. Adapters for Hono, H3, Elysia, and NestJS are included. You can also compose the lower-level primitives directly for custom frameworks or edge runtimes. + + + +See [which package to use](/docs/guides/auth/choosing-a-server-package) — for cookie-based sessions in SSR frameworks, use [`@supabase/ssr`](/docs/guides/auth/server-side) instead. + + diff --git a/apps/studio/components/interfaces/Database/Hooks/EditHookPanel.tsx b/apps/studio/components/interfaces/Database/Hooks/EditHookPanel.tsx index 40db2124f0a17..aae54a2bf1557 100644 --- a/apps/studio/components/interfaces/Database/Hooks/EditHookPanel.tsx +++ b/apps/studio/components/interfaces/Database/Hooks/EditHookPanel.tsx @@ -21,22 +21,11 @@ import { } from '@/data/table-editor/table-editor-query' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { useConfirmOnClose } from '@/hooks/ui/useConfirmOnClose' +import { isEdgeFunctionUrl } from '@/lib/api/edgeFunctions' import { uuidv4 } from '@/lib/helpers' export type HTTPArgument = { id: string; name: string; value: string } -export const isEdgeFunction = ({ - ref, - restUrlTld, - url, -}: { - ref?: string - restUrlTld?: string - url: string -}) => - url.includes(`https://${ref}.functions.supabase.${restUrlTld}/`) || - url.includes(`https://${ref}.supabase.${restUrlTld}/functions/`) - const FORM_ID = 'edit-hook-panel-form' const parseHeaders = (selectedHook?: PGTrigger): HTTPArgument[] => { @@ -143,7 +132,6 @@ export const EditHookPanel = () => { const isSubmitting = isCreating || isUpdating || isLoadingTable const restUrl = project?.restUrl - const restUrlTld = restUrl ? new URL(restUrl).hostname.split('.').pop() : 'co' const form = useForm({ resolver: zodResolver(FormSchema), @@ -152,11 +140,7 @@ export const EditHookPanel = () => { table_id: selectedHook?.table_id?.toString() ?? '', http_url: selectedHook?.function_args?.[0] ?? '', http_method: (selectedHook?.function_args?.[1] as 'GET' | 'POST') ?? 'POST', - function_type: isEdgeFunction({ - ref, - restUrlTld, - url: selectedHook?.function_args?.[0] ?? '', - }) + function_type: isEdgeFunctionUrl(selectedHook?.function_args?.[0] ?? '', ref ?? '', restUrl) ? 'supabase_function' : 'http_request', timeout_ms: Number(selectedHook?.function_args?.[4] ?? 5000), @@ -188,11 +172,7 @@ export const EditHookPanel = () => { table_id: selectedHook?.table_id?.toString() ?? '', http_url: selectedHook?.function_args?.[0] ?? '', http_method: (selectedHook?.function_args?.[1] as 'GET' | 'POST') ?? 'POST', - function_type: isEdgeFunction({ - ref, - restUrlTld, - url: selectedHook?.function_args?.[0] ?? '', - }) + function_type: isEdgeFunctionUrl(selectedHook?.function_args?.[0] ?? '', ref ?? '', restUrl) ? 'supabase_function' : 'http_request', timeout_ms: Number(selectedHook?.function_args?.[4] ?? 5000), @@ -201,7 +181,7 @@ export const EditHookPanel = () => { httpParameters: parseParameters(selectedHook), }) } - }, [visible, selectedHook, ref, restUrlTld, form]) + }, [visible, selectedHook, ref, restUrl, form]) const queryClient = useQueryClient() const onSubmit: SubmitHandler = async (values) => { diff --git a/apps/studio/components/interfaces/Database/Hooks/FormContents.tsx b/apps/studio/components/interfaces/Database/Hooks/FormContents.tsx index 509482267baec..84fdc1d909efb 100644 --- a/apps/studio/components/interfaces/Database/Hooks/FormContents.tsx +++ b/apps/studio/components/interfaces/Database/Hooks/FormContents.tsx @@ -22,7 +22,6 @@ import { } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' -import { isEdgeFunction } from './EditHookPanel' import { WebhookFormValues } from './EditHookPanel.constants' import { AVAILABLE_WEBHOOK_TYPES, HOOK_EVENTS } from './Hooks.constants' import { HTTPHeaders } from './HTTPHeaders' @@ -38,6 +37,7 @@ import { useEdgeFunctionsQuery } from '@/data/edge-functions/edge-functions-quer import { useTableNamesQuery } from '@/data/tables/table-names-query' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' +import { buildDatabaseEdgeFunctionUrl, isEdgeFunctionUrl } from '@/lib/api/edgeFunctions' import { uuidv4 } from '@/lib/helpers' export interface FormContentsProps { @@ -50,7 +50,6 @@ export const FormContents = ({ form, selectedHook }: FormContentsProps) => { const { data: project } = useSelectedProjectQuery() const restUrl = project?.restUrl - const restUrlTld = restUrl ? new URL(restUrl).hostname.split('.').pop() : 'co' const { can: canReadAPIKeys } = useAsyncCheckPermissions(PermissionAction.SECRETS_READ, '*') const { data: keys = [] } = useAPIKeysQuery( @@ -75,7 +74,7 @@ export const FormContents = ({ form, selectedHook }: FormContentsProps) => { useEffect(() => { if (!isSuccessEdgeFunctions) return - const isEdgeFunctionSelected = isEdgeFunction({ ref, restUrlTld, url: httpUrl }) + const isEdgeFunctionSelected = isEdgeFunctionUrl(httpUrl, ref ?? '', restUrl) if (httpUrl && isEdgeFunctionSelected) { const fnSlug = httpUrl.split('/').at(-1) @@ -98,8 +97,16 @@ export const FormContents = ({ form, selectedHook }: FormContentsProps) => { form.setValue('httpHeaders', updatedHttpHeaders) } } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [httpUrl, isSuccessEdgeFunctions]) + }, [ + form, + functions, + httpHeaders, + httpUrl, + isSuccessEdgeFunctions, + legacyServiceRole, + ref, + restUrl, + ]) return (
@@ -235,9 +242,13 @@ export const FormContents = ({ form, selectedHook }: FormContentsProps) => { } else if (functionType === 'supabase_function') { // Default to first edge function in the list const fnSlug = functions[0]?.slug - const defaultFunctionUrl = `https://${ref}.supabase.${restUrlTld}/functions/v1/${fnSlug}` + const defaultFunctionUrl = buildDatabaseEdgeFunctionUrl( + fnSlug ?? '', + ref ?? '', + restUrl + ) const currentUrl = form.getValues('http_url') - if (!isEdgeFunction({ ref, restUrlTld, url: currentUrl })) { + if (!isEdgeFunctionUrl(currentUrl, ref ?? '', restUrl)) { form.setValue('http_url', defaultFunctionUrl, { shouldDirty: false }) } } diff --git a/apps/studio/components/interfaces/Database/Hooks/HTTPRequestConfig.tsx b/apps/studio/components/interfaces/Database/Hooks/HTTPRequestConfig.tsx index a52184fc84f0d..915ec14606015 100644 --- a/apps/studio/components/interfaces/Database/Hooks/HTTPRequestConfig.tsx +++ b/apps/studio/components/interfaces/Database/Hooks/HTTPRequestConfig.tsx @@ -23,6 +23,7 @@ import { } from '@/components/ui/Forms/FormSection' import { useEdgeFunctionsQuery } from '@/data/edge-functions/edge-functions-query' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' +import { buildDatabaseEdgeFunctionUrl } from '@/lib/api/edgeFunctions' interface HTTPRequestConfigProps { form: UseFormReturn @@ -116,8 +117,7 @@ export const HTTPRequestConfig = ({ form }: HTTPRequestConfigProps) => { {edgeFunctions.map((fn) => { const restUrl = selectedProject?.restUrl - const restUrlTld = restUrl ? new URL(restUrl).hostname.split('.').pop() : 'co' - const functionUrl = `https://${ref}.supabase.${restUrlTld}/functions/v1/${fn.slug}` + const functionUrl = buildDatabaseEdgeFunctionUrl(fn.slug, ref ?? '', restUrl) return ( diff --git a/apps/studio/components/interfaces/Database/Hooks/HooksList/HookList.tsx b/apps/studio/components/interfaces/Database/Hooks/HooksList/HookList.tsx index 6b6791393d803..fe681e49113c3 100644 --- a/apps/studio/components/interfaces/Database/Hooks/HooksList/HookList.tsx +++ b/apps/studio/components/interfaces/Database/Hooks/HooksList/HookList.tsx @@ -19,6 +19,7 @@ import { ButtonTooltip } from '@/components/ui/ButtonTooltip' import { useDatabaseHooksQuery } from '@/data/database-triggers/database-triggers-query' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' +import { isEdgeFunctionUrl } from '@/lib/api/edgeFunctions' import { BASE_PATH } from '@/lib/constants' export interface HookListProps { @@ -38,7 +39,6 @@ export const HookList = ({ schema, filterString }: HookListProps) => { const [, setSelectedHookIdToDelete] = useQueryState('delete', parseAsString.withDefault('')) const restUrl = project?.restUrl - const restUrlTld = restUrl ? new URL(restUrl).hostname.split('.').pop() : 'co' const filteredHooks = (hooks ?? []).filter( (x) => @@ -54,10 +54,8 @@ export const HookList = ({ schema, filterString }: HookListProps) => { return ( <> {filteredHooks.map((x) => { - const isEdgeFunction = (url: string) => - url.includes(`https://${ref}.functions.supabase.${restUrlTld}/`) || - url.includes(`https://${ref}.supabase.${restUrlTld}/functions/`) const [url, method] = x.function_args + const isEdgeFunction = isEdgeFunctionUrl(url, ref ?? '', restUrl) return ( @@ -66,7 +64,7 @@ export const HookList = ({ schema, filterString }: HookListProps) => {
{ layout="fixed" width="20" height="20" - title={isEdgeFunction(url) ? 'Supabase Edge Function' : 'HTTP Request'} + title={isEdgeFunction ? 'Supabase Edge Function' : 'HTTP Request'} />

diff --git a/apps/studio/components/interfaces/Functions/TerminalInstructions.tsx b/apps/studio/components/interfaces/Functions/TerminalInstructions.tsx index 4a091db1df42b..5cd3bd4b9d63e 100644 --- a/apps/studio/components/interfaces/Functions/TerminalInstructions.tsx +++ b/apps/studio/components/interfaces/Functions/TerminalInstructions.tsx @@ -37,10 +37,6 @@ export const TerminalInstructions = forwardRef< const apiKey = publishableKey?.api_key ?? anonKey?.api_key ?? '[YOUR ANON KEY]' - // get the .co or .net TLD from the restUrl - const restUrl = `https://${endpoint}` - const restUrlTld = !!endpoint ? new URL(restUrl).hostname.split('.').pop() : 'co' - const commands: Commands[] = [ { command: 'supabase functions new hello-world', @@ -68,7 +64,7 @@ export const TerminalInstructions = forwardRef< comment: 'Deploy your function', }, { - command: `curl -L -X POST 'https://${projectRef}.supabase.${restUrlTld}/functions/v1/hello-world' -H 'Authorization: Bearer ${apiKey}'${anonKey?.type === 'publishable' ? ` -H 'apikey: ${apiKey}'` : ''} --data '{"name":"Functions"}'`, + command: `curl -L -X POST '${functionsEndpoint}/hello-world' -H 'Authorization: Bearer ${apiKey}'${anonKey?.type === 'publishable' ? ` -H 'apikey: ${apiKey}'` : ''} --data '{"name":"Functions"}'`, description: 'Invokes the hello-world function', jsx: () => { return ( diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CreateCronJobSheet/CreateCronJobSheet.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CreateCronJobSheet/CreateCronJobSheet.tsx index 31a2d3e0a917c..67335691638d2 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CreateCronJobSheet/CreateCronJobSheet.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CreateCronJobSheet/CreateCronJobSheet.tsx @@ -122,7 +122,11 @@ export const CreateCronJobSheet = ({ open, selectedCronJob, onClose }: CreateCro 'extensions' ) - const cronJobValues = parseCronJobCommand(selectedCronJob?.command || '', project?.ref!) + const cronJobValues = parseCronJobCommand( + selectedCronJob?.command || '', + project?.ref!, + project?.restUrl + ) const defaultValues = { name: selectedCronJob?.jobname || '', diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobPage.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobPage.tsx index a999b6fc37e6c..b2831ea564806 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobPage.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobPage.tsx @@ -54,7 +54,7 @@ export const CronJobPage = () => { const { data: edgeFunctions = [] } = useEdgeFunctionsQuery({ projectRef: project?.ref }) // Parse the cron job command to check if it's an edge function - const cronJobValues = parseCronJobCommand(job?.command || '', project?.ref!) + const cronJobValues = parseCronJobCommand(job?.command || '', project?.ref!, project?.restUrl) const edgeFunction = cronJobValues.type === 'edge_function' ? cronJobValues.edgeFunctionName : undefined const edgeFunctionSlug = edgeFunction?.split('/functions/v1/').pop() diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobTableCell.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobTableCell.tsx index 499697b15e5f3..7617544dfca00 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobTableCell.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobTableCell.tsx @@ -21,7 +21,6 @@ import { DialogSection, DialogSectionSeparator, DialogTitle, - DialogTrigger, DropdownMenu, DropdownMenuContent, DropdownMenuItem, @@ -147,16 +146,21 @@ export const CronJobTableCell = ({ return (

- - - - - - + <> +
e.stopPropagation()}> + { + e.stopPropagation() + setShowToggleModal(true) + }} + /> +
+ + e.stopPropagation()} + dialogOverlayProps={{ onClick: (e) => e.stopPropagation() }} + > + + {active ? 'Disable' : 'Enable'} cron job + + + +

+ Are you sure you want to {active ? 'disable' : 'enable'} the cron job "{jobname} + "?{' '} +

+
+ + + + +
+
+ ) } 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 93c3b1c73902c..478349b8fa115 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.test.ts +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.test.ts @@ -151,6 +151,24 @@ describe('parseCronJobCommand', () => { }) }) + it('should return an edge function config for a self-hosted function URL', () => { + const command = `select net.http_post( url:='http://kong:8000/functions/v1/_', headers:=jsonb_build_object('Authorization', 'Bearer something'), timeout_milliseconds:=5000 );` + expect(parseCronJobCommand(command, 'default', undefined, false)).toMatchObject({ + edgeFunctionName: 'http://kong:8000/functions/v1/_', + type: 'edge_function', + }) + }) + + it('should return an edge function config for a self-hosted public function URL', () => { + const command = `select net.http_post( url:='https://default.supabase.localhost/functions/v1/_', headers:=jsonb_build_object('Authorization', 'Bearer something'), timeout_milliseconds:=5000 );` + expect( + parseCronJobCommand(command, 'default', 'https://default.supabase.localhost/rest/v1/', false) + ).toMatchObject({ + edgeFunctionName: 'https://default.supabase.localhost/functions/v1/_', + type: 'edge_function', + }) + }) + it("should return an HTTP request config when there's a query parameter or hash in the URL (also handles edge function)", () => { const command = `select net.http_post( url:='https://random_project_ref.supabase.co/functions/v1/_?first=1#second=2', headers:=jsonb_build_object('Authorization', 'Bearer something'), timeout_milliseconds:=5000 )` expect(parseCronJobCommand(command, 'random_project_ref')).toStrictEqual({ diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.tsx index 22884d07b7635..cc28f6118a5c3 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.tsx @@ -7,6 +7,8 @@ import { CronJobType } from './CreateCronJobSheet/CreateCronJobSheet.constants' import { CRON_TABLE_COLUMNS, HTTPHeader, secondsPattern } from './CronJobs.constants' import { CronJobTableCell } from './CronJobTableCell' import { CronJob } from '@/data/database-cron-jobs/database-cron-jobs-infinite-query' +import { isEdgeFunctionUrl } from '@/lib/api/edgeFunctions' +import { IS_PLATFORM } from '@/lib/constants' const unescapeSqlLiteral = (value = '', isEscapeString = false) => { const unescaped = value.replaceAll("''", "'") @@ -129,7 +131,12 @@ const DEFAULT_CRONJOB_COMMAND = { httpBody: '', } as const -export const parseCronJobCommand = (originalCommand: string, projectRef: string): CronJobType => { +export const parseCronJobCommand = ( + originalCommand: string, + projectRef: string, + restUrl?: string, + isPlatform = IS_PLATFORM +): CronJobType => { const command = originalCommand.replaceAll('$$', ' ').replaceAll(/\n/g, ' ').trim() if (command.toLocaleLowerCase().match(/^select\s+net\./)) { @@ -181,8 +188,7 @@ export const parseCronJobCommand = (originalCommand: string, projectRef: string) } catch {} if ( - url.includes(`${projectRef}.supabase.`) && - url.includes('/functions/v1/') && + isEdgeFunctionUrl(url, projectRef, restUrl, isPlatform) && searchParams.length === 0 && urlHash.length === 0 ) { diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/DeleteCronJob.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/DeleteCronJob.tsx index ae9a700053f7f..b74e2f9898045 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/DeleteCronJob.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/DeleteCronJob.tsx @@ -32,7 +32,7 @@ export const DeleteCronJob = () => { } = useDatabaseCronJobDeleteMutation({ onSuccess: () => { if (cronJob && project) { - const { type } = parseCronJobCommand(cronJob.command, project.ref) + const { type } = parseCronJobCommand(cronJob.command, project.ref, project.restUrl) track('cron_job_removed', { type }) } toast.success(`Successfully removed cron job`) diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/EdgeFunctionSection.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/EdgeFunctionSection.tsx index 4806d8be0cd8d..5e22a953f7826 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/EdgeFunctionSection.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/EdgeFunctionSection.tsx @@ -37,17 +37,12 @@ import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { CreateCronJobForm } from './CreateCronJobSheet/CreateCronJobSheet.constants' import { useEdgeFunctionsQuery } from '@/data/edge-functions/edge-functions-query' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' +import { buildDatabaseEdgeFunctionUrl } from '@/lib/api/edgeFunctions' interface HTTPRequestFieldsProps { form: UseFormReturn } -const buildFunctionUrl = (slug: string, projectRef: string, restUrl?: string) => { - const restUrlTld = restUrl ? new URL(restUrl).hostname.split('.').pop() : 'co' - const functionUrl = `https://${projectRef}.supabase.${restUrlTld}/functions/v1/${slug}` - return functionUrl -} - export const EdgeFunctionSection = ({ form }: HTTPRequestFieldsProps) => { const { ref } = useParams() const { data: selectedProject } = useSelectedProjectQuery() @@ -62,7 +57,11 @@ export const EdgeFunctionSection = ({ form }: HTTPRequestFieldsProps) => { () => functions?.map((fn) => ({ ...fn, - url: buildFunctionUrl(fn.slug, selectedProject?.ref || '', selectedProject?.restUrl), + url: buildDatabaseEdgeFunctionUrl( + fn.slug, + selectedProject?.ref || '', + selectedProject?.restUrl + ), })) ?? [], [functions, selectedProject] ) diff --git a/apps/studio/components/interfaces/QueryPerformance/WithStatements/WithStatements.tsx b/apps/studio/components/interfaces/QueryPerformance/WithStatements/WithStatements.tsx index 7d683a2bc7b64..3c04b93c5536d 100644 --- a/apps/studio/components/interfaces/QueryPerformance/WithStatements/WithStatements.tsx +++ b/apps/studio/components/interfaces/QueryPerformance/WithStatements/WithStatements.tsx @@ -210,6 +210,7 @@ export const WithStatements = ({ side="top" >