diff --git a/apps/docs/app/guides/database/database-advisors/page.tsx b/apps/docs/app/guides/database/database-advisors/page.tsx index e35e9da410c..56d9c89876b 100644 --- a/apps/docs/app/guides/database/database-advisors/page.tsx +++ b/apps/docs/app/guides/database/database-advisors/page.tsx @@ -25,7 +25,7 @@ const meta = { } const generateMetadata = genGuideMeta(() => ({ - pathname: '/guides/database/database-linter', + pathname: '/guides/database/database-advisors', meta, })) diff --git a/apps/docs/app/guides/database/extensions/wrappers/[[...slug]]/page.tsx b/apps/docs/app/guides/database/extensions/wrappers/[[...slug]]/page.tsx index d7cb4b25678..ebafbd690b7 100644 --- a/apps/docs/app/guides/database/extensions/wrappers/[[...slug]]/page.tsx +++ b/apps/docs/app/guides/database/extensions/wrappers/[[...slug]]/page.tsx @@ -15,7 +15,7 @@ import { linkTransform, type UrlTransformFunction } from '~/lib/mdx/plugins/rehy 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 { getGitHubFileContents, octokit } from '~/lib/octokit' import type { SerializeOptions } from '~/types/next-mdx-remote-serialize' import { isFeatureEnabled } from 'common' import matter from 'gray-matter' @@ -29,10 +29,62 @@ import { Admonition } from 'ui-patterns' // We fetch these docs at build time from an external repo const org = 'supabase' const repo = 'wrappers' -const branch = 'main' const docsDir = 'docs/catalog' const externalSite = 'https://supabase.github.io/wrappers' +type DocsTagsQueryResponse = { + repository: { + refs: { + nodes: { name: string }[] | null + pageInfo: { hasNextPage: boolean; endCursor: string | null } + } + } +} + +const docsTagsQuery = ` + query DocsTagsQuery($owner: String!, $name: String!, $after: String) { + repository(owner: $owner, name: $name) { + refs( + refPrefix: "refs/tags/", + orderBy: { field: TAG_COMMIT_DATE, direction: DESC }, + first: 5, + after: $after + ) { + nodes { name } + pageInfo { hasNextPage endCursor } + } + } + } +` + +async function getLatestDocsTag(after: string | null = null): Promise { + try { + /** + * We use GraphQL as it's the only way to use `orderBy` on Github API. + */ + const { + repository: { + refs: { + nodes, + pageInfo: { hasNextPage, endCursor }, + }, + }, + } = await octokit().graphql(docsTagsQuery, { + owner: org, + name: repo, + after, + }) + + return ( + nodes?.find(({ name }) => /^docs_v\d+\.\d+\.\d+/.test(name))?.name ?? + (hasNextPage && endCursor ? await getLatestDocsTag(endCursor) : null) + ) + } catch (error) { + console.error(`Error fetching docs tags for wrappers federated pages: ${error}`) + return null + } +} + // Each external docs page is mapped to a local page const pageMap = [ { @@ -378,16 +430,22 @@ const getContent = async (params: Params) => { let remoteFile: string ;({ remoteFile, meta } = federatedPage) - editLink = `${org}/${repo}/blob/${branch}/${docsDir}/${remoteFile}` + const tag = await getLatestDocsTag() + + if (!tag) { + throw new Error('No latest docs tag found for federated wrappers pages') + } + + editLink = `${org}/${repo}/blob/${tag}/${docsDir}/${remoteFile}` let rawContent = await getGitHubFileContents({ org, repo, path: `${docsDir}/${remoteFile}`, - branch, + branch: tag, }) - assetsBaseUrl = `https://raw.githubusercontent.com/${org}/${repo}/${branch}/docs/assets/` + assetsBaseUrl = `https://raw.githubusercontent.com/${org}/${repo}/${tag}/docs/assets/` const { content: contentWithoutFrontmatter } = matter(rawContent) content = removeRedundantH1(contentWithoutFrontmatter) diff --git a/apps/docs/app/guides/deployment/ci/[slug]/page.tsx b/apps/docs/app/guides/deployment/ci/[slug]/page.tsx index 76152dc0aa1..4053787f65e 100644 --- a/apps/docs/app/guides/deployment/ci/[slug]/page.tsx +++ b/apps/docs/app/guides/deployment/ci/[slug]/page.tsx @@ -87,7 +87,7 @@ const getContent = async ({ slug }: Params) => { ) return { - pathname: `/guides/cli/github-action/${slug}` satisfies `/${string}`, + pathname: `/guides/deployment/ci/${slug}` satisfies `/${string}`, meta, content, editLink, diff --git a/apps/docs/app/guides/deployment/terraform/[[...slug]]/page.tsx b/apps/docs/app/guides/deployment/terraform/[[...slug]]/page.tsx index 07c76c8da40..153c814eff3 100644 --- a/apps/docs/app/guides/deployment/terraform/[[...slug]]/page.tsx +++ b/apps/docs/app/guides/deployment/terraform/[[...slug]]/page.tsx @@ -136,7 +136,7 @@ const getContent = async ({ slug }: Params) => { return { pathname: - `/guides/platform/terraform${slug?.length ? `/${slug.join('/')}` : ''}` satisfies `/${string}`, + `/guides/deployment/terraform${slug?.length ? `/${slug.join('/')}` : ''}` satisfies `/${string}`, meta, content, editLink, diff --git a/apps/docs/app/guides/deployment/terraform/reference/page.tsx b/apps/docs/app/guides/deployment/terraform/reference/page.tsx index d703ea17cb3..8b974373fe4 100644 --- a/apps/docs/app/guides/deployment/terraform/reference/page.tsx +++ b/apps/docs/app/guides/deployment/terraform/reference/page.tsx @@ -22,7 +22,7 @@ const meta = { } const generateMetadata = genGuideMeta(() => ({ - pathname: '/guides/platform/terraform/reference', + pathname: '/guides/deployment/terraform/reference', meta, })) diff --git a/apps/docs/app/guides/local-development/cli/config/page.tsx b/apps/docs/app/guides/local-development/cli/config/page.tsx index 124ade3a74c..8461b6baeb4 100644 --- a/apps/docs/app/guides/local-development/cli/config/page.tsx +++ b/apps/docs/app/guides/local-development/cli/config/page.tsx @@ -12,7 +12,7 @@ const meta = { } const generateMetadata = genGuideMeta(() => ({ - pathname: '/guides/cli/config', + pathname: '/guides/local-development/cli/config', meta, })) diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts index 86b77a82876..c77840d6ce9 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts @@ -340,6 +340,10 @@ export const gettingstarted: NavMenuConstant = { { name: 'API Keys', url: '/guides/getting-started/api-keys' }, { name: 'Local Development', url: '/guides/cli/getting-started' }, { name: 'Architecture', url: '/guides/getting-started/architecture' }, + { + name: 'Migrating to new API keys', + url: '/guides/getting-started/migrating-to-new-api-keys', + }, { name: 'Framework Quickstarts', enabled: frameworkQuickstartsEnabled, @@ -1571,6 +1575,10 @@ export const api: NavMenuConstant = { { name: 'Generating TypeScript Types', url: '/guides/api/rest/generating-types' }, { name: 'Generating Python Types', url: '/guides/api/rest/generating-python-types' }, { name: 'Error Codes', url: '/guides/api/rest/postgrest-error-codes' }, + { + name: 'Handling Errors in supabase-js', + url: '/guides/api/handling-errors-in-supabase-js', + }, ], }, { diff --git a/apps/docs/content/guides/ai/examples/huggingface-image-captioning.mdx b/apps/docs/content/guides/ai/examples/huggingface-image-captioning.mdx index b6b7b05ea47..a0f89695d59 100644 --- a/apps/docs/content/guides/ai/examples/huggingface-image-captioning.mdx +++ b/apps/docs/content/guides/ai/examples/huggingface-image-captioning.mdx @@ -65,7 +65,7 @@ Deno.serve(async (req) => { // Supabase API URL - env var exported by default when deployed. Deno.env.get('SUPABASE_URL') ?? '', // Supabase API SECRET KEY - env var exported by default when deployed. - Deno.env.get(SUPABASE_SECRET_KEYS['default']) ?? '' + SUPABASE_SECRET_KEYS['default'] ?? '' ) // Construct image url from storage diff --git a/apps/docs/content/guides/api/handling-errors-in-supabase-js.mdx b/apps/docs/content/guides/api/handling-errors-in-supabase-js.mdx new file mode 100644 index 00000000000..3e816f35b86 --- /dev/null +++ b/apps/docs/content/guides/api/handling-errors-in-supabase-js.mdx @@ -0,0 +1,145 @@ +--- +id: handling-errors-in-supabase-js +title: 'Handling errors in `supabase-js`' +subtitle: 'Read `error.hint` first — Postgres often tells you the exact fix. Log the full error so you actually see it.' +--- + +Every `supabase-js` call returns a `{ data, error }` pair instead of throwing. When something fails, the single most useful field on `error` is usually `hint` — Postgres returns the _fix_, not just a description of the problem. Logging only `error.message` hides it. + +## Usage of `message` and `hint` properties + +Consider a `42501` permission-denied error on a table where default `GRANT`s have been revoked from `anon`: + +``` +message: "permission denied for table users" +hint: "Grant the required privileges to the current role with: GRANT SELECT ON public.users TO anon;" +``` + +The `message` exposes the error reason, and `hint` gives you the literal SQL statement to run in the dashboard SQL editor to fix it. + +The same pattern shows up across many Postgres errors — missing column? `hint` suggests the column name you probably meant. Type mismatch? `hint` shows the expected type. Whenever Postgres knows the fix, it puts it in `hint`. + +Log the full `error` object, not just `error.message`. + +## The recommended pattern + +Read `{ data, error }` from the response, check `error`, log the whole object, and return early. + +```ts +const { data, error } = await supabase.from('users').select() +if (error) { + console.error(error) + return +} +``` + +In the case of a permission-denied error, the response body will look like this: + +```json +{ + "error": { + "code": "42501", + "message": "permission denied for table users", + "details": null, + "hint": "Grant the required privileges to the current role with: GRANT SELECT ON public.users TO anon;" + }, + "status": 401, + "statusText": "Unauthorized" +} +``` + +`postgrest-js` passes the body through verbatim, so `error.hint` is the exact string Postgres produced. Treat it as the answer the database is giving you, not as a suggestion to file away. + +## The `PostgrestError` fields, by usefulness + +Database calls (`select`, `insert`, `update`, `upsert`, `delete`, `rpc`) return a `PostgrestError` with four fields. Read them in roughly this order: + +| Field | Read it when | +| --------- | ------------------------------------------------------------------------------------------------------------------ | +| `hint` | Always check first. When Postgres includes one, it's the actionable fix (a `GRANT` to run, a column name, a type). | +| `code` | When branching in code. Codes are stable across versions; `message` text isn't. | +| `details` | When `hint` and `message` aren't enough. Often contains the offending value, key, or row. | +| `message` | As the human summary. Useful in UI strings, less useful for debugging. | + +A full list of PostgREST error codes is in the [Error Codes reference](/guides/api/rest/postgrest-error-codes). + +## Branch on `error.code`, not `error.message` + +`error.code` is more reliable than `error.message` for programmatic branching: messages change between Postgres and PostgREST versions, but codes are stable. + +```ts +const { data, error } = await supabase.from('users').select() +if (error) { + console.error(error) + if (error.code === '42501') { + // Permission denied. error.hint usually contains the GRANT to run. + } + return +} +``` + +## Errors from Auth, Storage, and Edge Functions + +The same rule applies across the SDK — log the whole error object — but the shape differs by client. + +### Auth + +`AuthError` exposes `error.code` (e.g. `'invalid_credentials'`, `'email_not_confirmed'`) and `error.status`. Branch on `code`; log the whole thing. + +```ts +const { data, error } = await supabase.auth.signInWithPassword({ + email: 'example@email.com', + password: 'example-password', +}) +if (error) { + console.error(error) + return +} +``` + +### Storage + +`StorageError` exposes `error.statusCode` (HTTP status as a string) and a structured `error` name (e.g. `'Duplicate'`, `'NotFound'`). + +```ts +const { data, error } = await supabase.storage + .from('avatars') + .upload('public/avatar1.png', avatarFile) +if (error) { + console.error(error) + return +} +``` + +### Edge Functions + +Functions errors arrive as one of three subclasses. Narrow with `instanceof`; for `FunctionsHttpError`, parse the body to get the function's own error payload. + +```ts +import { FunctionsFetchError, FunctionsHttpError, FunctionsRelayError } from '@supabase/supabase-js' + +const { data, error } = await supabase.functions.invoke('hello') +if (error instanceof FunctionsHttpError) { + console.error('Function error', await error.context.json()) +} else if (error) { + console.error(error) +} +``` + +### Realtime + +The `subscribe()` callback receives a `status` and, on failure, an `err` argument. Log the whole `err` — its `cause` often holds the underlying reason. + +```ts +supabase.channel('room1').subscribe((status, err) => { + if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT') { + console.error(status, err) + } +}) +``` + +## Related + +- [PostgREST Error Codes](/guides/api/rest/postgrest-error-codes) +- [Automatic retries with `supabase-js`](/guides/api/automatic-retries-in-supabase-js) +- [Securing your API](/guides/api/securing-your-api) diff --git a/apps/docs/content/guides/functions/examples/elevenlabs-transcribe-speech.mdx b/apps/docs/content/guides/functions/examples/elevenlabs-transcribe-speech.mdx index db8248876c3..6cdc51f58ca 100644 --- a/apps/docs/content/guides/functions/examples/elevenlabs-transcribe-speech.mdx +++ b/apps/docs/content/guides/functions/examples/elevenlabs-transcribe-speech.mdx @@ -120,7 +120,7 @@ const elevenLabsClient = new ElevenLabsClient({ const SUPABASE_SECRET_KEYS = JSON.parse(Deno.env.get('SUPABASE_SECRET_KEYS')!) const supabase = createClient( Deno.env.get('SUPABASE_URL') || '', - Deno.env.get(SUPABASE_SECRET_KEYS['default']) || '' + SUPABASE_SECRET_KEYS['default'] || '' ) async function scribe({ diff --git a/apps/docs/content/guides/getting-started/api-keys.mdx b/apps/docs/content/guides/getting-started/api-keys.mdx index 1d3ebea3115..44f97cb20a2 100644 --- a/apps/docs/content/guides/getting-started/api-keys.mdx +++ b/apps/docs/content/guides/getting-started/api-keys.mdx @@ -36,7 +36,7 @@ There are 4 types of API keys that you can use with Supabase: Supabase has changed the way keys work to improve project security and developer experience. You can read [the full announcement](https://github.com/orgs/supabase/discussions/29260). -`anon` and `service_role` keys are based on the project's JWT secret. They are generated when your project is created and you can only change them when you rotate the JWT secret. This can cause significant issues in production applications. **You should now use the `sb_publishable_xxx` and `sb_secret_xxx` keys instead**. +`anon` and `service_role` keys are based on the project's JWT secret. They are generated when your project is created and you can only change them when you rotate the JWT secret. This can cause significant issues in production applications. **You should now use the `sb_publishable_xxx` and `sb_secret_xxx` keys instead**. See [Migrate to publishable and secret API keys](/docs/guides/getting-started/migrating-to-new-api-keys) for a step-by-step guide. You can still find legacy keys in the **Legacy anon, service_role API keys** tab of the [**Settings > API Keys**](/dashboard/project/_/settings/api-keys/) section of the Dashboard: diff --git a/apps/docs/content/guides/getting-started/migrating-to-new-api-keys.mdx b/apps/docs/content/guides/getting-started/migrating-to-new-api-keys.mdx new file mode 100644 index 00000000000..0a7d3f29d46 --- /dev/null +++ b/apps/docs/content/guides/getting-started/migrating-to-new-api-keys.mdx @@ -0,0 +1,225 @@ +--- +id: 'migrating-to-new-api-keys' +title: 'Migrating to publishable and secret API keys' +description: 'Move from legacy JWT-based anon and service_role keys to publishable and secret keys.' +--- + +Supabase has changed the way API keys work. The legacy `anon` and `service_role` keys are based on your project's JWT secret, which makes them hard to rotate without downtime. The new publishable (`sb_publishable_...`) and secret (`sb_secret_...`) keys can be created, named, and revoked independently, so you can rotate a single key without touching the rest of your app. + +This guide covers migrating an **existing project.** Both key types work simultaneously, so you can swap clients one at a time and deactivate the legacy keys only after nothing depends on them. + + + +The legacy `anon` and `service_role` keys keep working until the end of 2026. You don't have to migrate today, but doing it early lets you rotate keys safely from now on. + + + +## Before you start + +The migration maps onto your existing keys: + +| Legacy key | Replace with | Used by | +| -------------- | --------------- | ------------------------------------------------------ | +| `anon` | Publishable key | Browsers, mobile and desktop apps, CLIs, public source | +| `service_role` | Secret key | Servers, Edge Functions, workers, other backend code | + +For a full explanation of each key type, read [the Understanding API keys guide](/docs/guides/getting-started/api-keys). + +## Step 1: Create the new API keys + +Open the [**Settings > API Keys**](/dashboard/project/_/settings/api-keys/) section of the Dashboard and select the **Publishable and secret API keys** tab. + +Older projects don't have these keys yet. If you see a **Create new API keys** button, your project is still on legacy keys only. Creating the new keys is safe. It adds a publishable key and a secret key alongside your existing `anon` and `service_role` keys. Your legacy keys keep working. + +The new keys are created under the name `default`. You can add more keys with different names later, for example, one secret key per backend component, so you can rotate them independently. For an initial migration, the `default` publishable and secret keys are all you need. + +## Step 2: Swap the publishable key in client code + +Anywhere you use the `anon` key in public code, switch to the publishable key. This includes web pages, mobile and desktop apps, and any CLI or script that ships to users. + +```ts +import { createClient } from '@supabase/supabase-js' + +const supabase = createClient( + 'https://your-project.supabase.co', + 'sb_publishable_...' // was the anon key +) +``` + +The publishable key carries the same low privileges as the `anon` key, so your [Row Level Security](/docs/guides/database/postgres/row-level-security) policies behave the same. User authentication through Supabase Auth is unchanged. The user still signs in and gets their own JWT. + +## Step 3: Swap the secret key in backend code + +Anywhere you use the `service_role` key on a server you control, switch to a secret key. + +```ts +import { createClient } from '@supabase/supabase-js' + +const supabaseAdmin = createClient( + 'https://your-project.supabase.co', + 'sb_secret_...' // was the service_role key +) +``` + +Secret keys add protections the `service_role` key doesn't have. They return HTTP 401 if used in a browser (matched on the `User-Agent` header), and you can run a separate key per service so a single leak only forces one rotation. + + + +Secret keys bypass Row Level Security and have full access to your data. Keep them on backends you control, out of source control, and out of client code. + + + +### Database Webhooks and `pg_net` + +Calls made from Postgres with `pg_net`, including Database Webhooks, usually send the `service_role` key on the `Authorization: Bearer` header. The new secret keys aren't JWTs, so they're rejected there. Send the secret key on the `apikey` header instead. + +```sql +-- before +select net.http_post( + url := 'https://your-project.supabase.co/functions/v1/your-function', + headers := jsonb_build_object( + 'Content-Type', 'application/json', + 'Authorization', 'Bearer ' + ), + body := jsonb_build_object('event', 'ping') +); + +-- after +select net.http_post( + url := 'https://your-project.supabase.co/functions/v1/your-function', + headers := jsonb_build_object( + 'Content-Type', 'application/json', + 'apikey', 'sb_secret_...' + ), + body := jsonb_build_object('event', 'ping') +); +``` + +For Database Webhooks created in the Dashboard, edit each webhook's HTTP headers: remove the `Authorization` header that holds the key and add an `apikey` header with a secret key instead. + + + +Don't hardcode a secret key in SQL or a webhook configuration, where it's stored in plain text. Store it in [Vault](/docs/guides/database/vault) and read it at call time: + +```sql +headers := jsonb_build_object( + 'Content-Type', 'application/json', + 'apikey', (select decrypted_secret from vault.decrypted_secrets where name = 'secret_key') +) +``` + + + +## Step 4: Update Edge Functions + +Edge Functions read their keys from environment variables. Supabase adds two new ones to your functions' environment, `SUPABASE_PUBLISHABLE_KEYS` and `SUPABASE_SECRET_KEYS`, alongside the legacy `SUPABASE_ANON_KEY` and `SUPABASE_SERVICE_ROLE_KEY`. Confirm they exist in the [**Edge Functions > Secrets**](/dashboard/project/_/functions/secrets) section of the Dashboard before you start. + +You have two options: a minimal change that swaps which variable you read, or a fuller upgrade to the [`@supabase/server`](https://github.com/supabase/server) SDK. + +### Option 1: Read the new keys from the environment + +For most functions, the only change is how you read the key. The legacy variables held a plain string. The new ones hold a JSON object keyed by name, so you parse them and read the key by name. The key you created in [step 1](#step-1-create-the-new-api-keys) is named `default`. + +```ts +// before +const secretKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')! + +// after +const secretKey = JSON.parse(Deno.env.get('SUPABASE_SECRET_KEYS')!)['default'] +``` + +The publishable keys work the same way through `SUPABASE_PUBLISHABLE_KEYS`. Read [the Managing Secrets guide](/docs/guides/functions/secrets) for more on environment variables in Edge Functions. + +If you created more than one secret key in [step 1](#step-1-create-the-new-api-keys), every key lives in the same `SUPABASE_SECRET_KEYS` object, each under its own name. Read a non-default key the same way: + +```ts +const secretKeys = JSON.parse(Deno.env.get('SUPABASE_SECRET_KEYS')!) + +const defaultKey = secretKeys['default'] +const billingKey = secretKeys['billing'] // the secret key you named "billing" +``` + +Send publishable and secret keys on the `apikey` header only. If you also pass the key on the `Authorization: Bearer` header, which many Supabase clients do by default, the platform tries to parse it as a JWT and rejects the request with `Invalid JWT`. The platform's built-in `verify_jwt` check only understands the legacy JWT-based keys, so set `verify_jwt = false` for these functions and authorize the request in your own code, or let the `@supabase/server` SDK do it for you ([Option 2](#option-2-adopt-the-supabaseserver-sdk)). + +```toml +[functions.my-function] +verify_jwt = false +``` + +### Option 2: Adopt the @supabase/server SDK + +To get the most out of the new key model, migrate to the [`@supabase/server`](https://github.com/supabase/server) SDK. It removes the client-setup boilerplate every function repeats: reading keys from the environment, parsing the `Authorization` header, and initializing a user-scoped client and a separate admin client. You declare who can call the function, and get both clients ready to use on `ctx` (`ctx.supabase` respects Row Level Security, `ctx.supabaseAdmin` uses the secret key). This is the recommended approach for new functions. + +Wrap your existing `Deno.serve` handler with `withSupabase` and declare an `auth` mode for who can call it. Keep `verify_jwt = false` so the SDK does the authorization. + +For a function your users call from the client, use `auth: 'user'`. The SDK validates the user's session JWT and gives you a client scoped to their Row Level Security policies. + +```ts +import { withSupabase } from 'npm:@supabase/server' + +Deno.serve( + withSupabase({ auth: 'user' }, async (_req, ctx) => { + // ctx.supabase is scoped to the authenticated user + return Response.json({ email: ctx.userClaims?.email }) + }) +) +``` + +For a function called by your own backend, a worker, or `pg_net`, use `auth: 'secret'`. The SDK validates the secret key and gives you a client that bypasses Row Level Security. + +```ts +import { withSupabase } from 'npm:@supabase/server' + +Deno.serve( + withSupabase({ auth: 'secret' }, async (_req, ctx) => { + // ctx.supabaseAdmin is authenticated with a valid secret key + return Response.json({ ok: true }) + }) +) +``` + +To accept a specific named key instead of `default`, add its name after the mode with a colon. For example, `auth: 'secret:billing'` validates the request against the secret key you named `billing`, and `auth: 'publishable:web'` against a publishable key named `web`. + +`withSupabase` returns a standard request handler, so you can also export it as a `fetch` handler instead of passing it to `Deno.serve`: + +```ts +import { withSupabase } from 'npm:@supabase/server' + +export default { + fetch: withSupabase({ auth: 'user' }, async (_req, ctx) => { + // ctx.supabase is scoped to the authenticated user + return Response.json({ email: ctx.userClaims?.email }) + }), +} +``` + +`export default { fetch }` is equivalent to `Deno.serve(...)`: both define a request handler. The `fetch` style is portable across Edge Functions, Cloudflare Workers, and Bun, so prefer it if you want the same function to run in more than one environment. `Deno.serve` keeps working on Edge Functions, so you can leave it in place during a migration and switch later. + +A good way to try this is to duplicate one of your functions and migrate the copy first. See [Securing Edge Functions](/docs/guides/functions/auth) for every auth mode and use case, and [Authorization headers](/docs/guides/functions/auth-headers) for how the headers work. + +## Step 5: Verify nothing uses the legacy keys + +Before turning the legacy keys off, confirm nothing still depends on them. There's no automatic usage indicator, so this is a manual check. Go through every place that holds a Supabase key and make sure it now uses a publishable or secret key. + +Don't forget callers that are easy to miss: + +- Mobile or desktop app versions already in users' hands. +- CI/CD pipelines and deployment scripts. +- Third-party integrations and webhooks. +- Cron jobs, workers, and `pg_net` calls or Database Webhooks (see [Database Webhooks and `pg_net`](#database-webhooks-and-pgnet)). + +## Step 6: Deactivate the legacy keys + +Once you've confirmed nothing uses the legacy keys, deactivate them in the [**Settings > API Keys**](/dashboard/project/_/settings/api-keys/) section of the Dashboard. You can re-activate them if you find a client you missed, so this step is reversible. + +## Known limitations + +A few behaviors differ from the legacy JWT-based keys. Plan for them during the migration: + +- You can't send a publishable or secret key in the `Authorization: Bearer ...` header. Send it on the `apikey` header instead. +- Edge Functions don't verify the `apikey` header for the new keys. Use `verify_jwt = false` and authorize in code, as shown in [Step 4](#step-4-update-edge-functions). +- Public Realtime connections are limited to 24 hours unless the connection is upgraded with user-level authentication through Supabase Auth or a supported third-party auth provider. + +## Next steps + +After migrating your keys, consider moving to the [JWT signing keys](/docs/guides/auth/signing-keys) system as well. This is a separate, independent migration. The new publishable and secret keys aren't JWTs, so they no longer touch your project's JWT secret. But the access tokens Supabase Auth issues to your users are still signed by that shared secret. Signing keys replace it with rotatable keys you can change without downtime. Together, the two migrations get your whole project off the shared JWT secret. diff --git a/apps/docs/content/guides/platform/access-control.mdx b/apps/docs/content/guides/platform/access-control.mdx index e3490f0d70c..73bea005159 100644 --- a/apps/docs/content/guides/platform/access-control.mdx +++ b/apps/docs/content/guides/platform/access-control.mdx @@ -264,7 +264,7 @@ The table below shows the actions each role can take on the resources belonging | Production Branch | Read | | | | | | | Write | | | | | | Development Branches | List | | | | | -| | Create | | | | | +| | Create[^8] | | | | | | | Update | | | | | | | Delete | | | | | @@ -281,3 +281,5 @@ The table below shows the actions each role can take on the resources belonging [^6]: Listed permissions are for the API and Dashboard. [^7]: Limited to executing SELECT queries. SQL Query Snippets run by the Read-Only role are run against the database using the **supabase_read_only_user**. This role has the [predefined Postgres role pg_read_all_data](https://www.postgresql.org/docs/current/predefined-roles.html). + +[^8]: When using dashboard branching without a GitHub integration, the first branch creation also registers the project's production branch — a one-time step that requires Owner or Administrator. See [Branching via the dashboard](/docs/guides/deployment/branching/dashboard) for details. Developers can create, update, and delete branches normally after that. diff --git a/apps/docs/features/directives/Partial.test.ts b/apps/docs/features/directives/Partial.test.ts index 5217554a15c..9e0124ddc6e 100644 --- a/apps/docs/features/directives/Partial.test.ts +++ b/apps/docs/features/directives/Partial.test.ts @@ -1,7 +1,6 @@ -import { describe, it, expect } from 'vitest' - import { mdxToMarkdown } from 'mdast-util-mdx' import { toMarkdown } from 'mdast-util-to-markdown' +import { describe, expect, it } from 'vitest' import { partialsRemark } from './Partial' import { fromDocsMarkdown } from './utils.server' @@ -116,7 +115,12 @@ Some more text. await expect(partialsRemark()(mdast)).rejects.toThrowError(/valid JSON/) }) - it('should error when required variable is missing', async () => { + it('should render an unprovided variable as an empty string', async () => { + // The variables.mdx fixture reads "Here is a partial that takes a {{ .var }}." + // When `var` is not provided, the `{{ .var }}` placeholder is replaced with + // an empty string rather than throwing. The trailing " ." in the expected + // output is the intended result: the placeholder is gone, leaving nothing + // between "a" and the period. const markdown = ` # Embed partial @@ -126,27 +130,48 @@ Some more text. `.trim() const mdast = fromDocsMarkdown(markdown) - await expect(partialsRemark()(mdast)).rejects.toThrowError( - /Missing required variable in \$Partial ".*variables\.mdx": "var"/ - ) + const transformed = await partialsRemark()(mdast) + const output = toMarkdown(transformed, { extensions: [mdxToMarkdown()] }) + + // Note the empty gap where `{{ .var }}` used to be — this is deliberate. + const expected = ` +# Embed partial + +Here is a partial that takes a . + +Some more text. +`.trimStart() + + expect(output).toEqual(expected) + // The placeholder must be fully removed, not left as literal `{{ .var }}`. + expect(output).not.toContain('{{') }) - it('should error when unexpected variable is provided', async () => { + it('should ignore a variable that is not referenced in the partial', async () => { + // The variables.mdx fixture only references `var`. Providing an additional + // `extra` variable that the partial never uses is silently ignored rather + // than throwing — `var` is substituted and `extra` leaves no trace. const markdown = ` # Embed partial -<$Partial path="/_fixtures/variables.mdx" variables={{ "var": "correct", "extra": "unexpected" }} /> +<$Partial path="/_fixtures/variables.mdx" variables={{ "var": "correct", "extra": "unused" }} /> Some more text. `.trim() const mdast = fromDocsMarkdown(markdown) - await expect(partialsRemark()(mdast)).rejects.toThrowError( - /Unexpected variable in \$Partial ".*variables\.mdx": "extra"/ - ) + const transformed = await partialsRemark()(mdast) + const output = toMarkdown(transformed, { extensions: [mdxToMarkdown()] }) + + expect(output).toContain('Here is a partial that takes a correct.') + expect(output).not.toContain('unused') }) - it('should error with detailed message for multiple missing variables', async () => { + it('should render only the unprovided variables as empty when some are provided', async () => { + // The multiple-variables.mdx fixture reads: + // "This partial has {{ .var1 }}, {{ .var2 }}, and {{ .var3 }}." + // Only `var1` is provided here, so `var2` and `var3` collapse to empty + // strings while `var1` is substituted normally. const markdown = ` # Embed partial @@ -156,24 +181,13 @@ Some more text. `.trim() const mdast = fromDocsMarkdown(markdown) - await expect(partialsRemark()(mdast)).rejects.toThrowError( - /Missing required variables.*"var2".*"var3".*Expected variables.*"var1".*"var2".*"var3".*Provided variable: "var1"/s - ) - }) + const transformed = await partialsRemark()(mdast) + const output = toMarkdown(transformed, { extensions: [mdxToMarkdown()] }) - it('should error with detailed message for multiple unexpected variables', async () => { - const markdown = ` -# Embed partial - -<$Partial path="/_fixtures/variables.mdx" variables={{ "var": "correct", "extra1": "wrong", "extra2": "also wrong" }} /> - -Some more text. -`.trim() - - const mdast = fromDocsMarkdown(markdown) - await expect(partialsRemark()(mdast)).rejects.toThrowError( - /Unexpected variables.*"extra1".*"extra2".*Expected variable: "var".*Provided variables.*"var".*"extra1".*"extra2"/s - ) + // Provided variable is substituted; the two unprovided ones leave empty gaps. + expect(output).toContain('This partial has value1, , and .') + // No placeholder text survives for the unprovided variables. + expect(output).not.toContain('{{') }) it('should succeed when all variables match exactly', async () => { @@ -212,7 +226,12 @@ Some more text. expect(output).toContain('alphanumeric value') }) - it('should error when hyphenated variable is missing', async () => { + it('should render unprovided hyphenated variables as empty', async () => { + // The hyphenated-variables.mdx fixture reads: + // "This partial has {{ .my-var }}, {{ .another_var }}, and {{ .myVar123 }}." + // Only `my-var` is provided, so the underscore and alphanumeric variables + // collapse to empty strings — confirming the empty-substitution behavior + // applies to all supported variable name styles. const markdown = ` # Embed partial @@ -222,8 +241,10 @@ Some more text. `.trim() const mdast = fromDocsMarkdown(markdown) - await expect(partialsRemark()(mdast)).rejects.toThrowError( - /Missing required variables.*"another_var".*"myVar123".*Expected variables.*"my-var".*"another_var".*"myVar123".*Provided variable: "my-var"/s - ) + const transformed = await partialsRemark()(mdast) + const output = toMarkdown(transformed, { extensions: [mdxToMarkdown()] }) + + expect(output).toContain('This partial has value, , and .') + expect(output).not.toContain('{{') }) }) diff --git a/apps/docs/features/directives/Partial.ts b/apps/docs/features/directives/Partial.ts index 15d40004f54..86001a7ade8 100644 --- a/apps/docs/features/directives/Partial.ts +++ b/apps/docs/features/directives/Partial.ts @@ -7,6 +7,10 @@ * Simple string replacement is supported. The replacement strings are * specified using the `variables` field. * + * Variable substitution is optional. Any variable referenced in the partial + * content but not provided is rendered as an empty string, and any variable + * provided but not referenced in the content is ignored. + * * ## Examples * * ### Simple partial @@ -33,14 +37,14 @@ * ``` */ -import { type Root } from 'mdast' -import type { MdxJsxFlowElement } from 'mdast-util-mdx-jsx' import { readFile } from 'node:fs/promises' import { join } from 'node:path' +import { PARTIALS_DIRECTORY } from '~/lib/docs' +import { type Root } from 'mdast' +import type { MdxJsxFlowElement } from 'mdast-util-mdx-jsx' import { type Parent } from 'unist' import { visitParents } from 'unist-util-visit-parents' -import { PARTIALS_DIRECTORY } from '~/lib/docs' import { fromDocsMarkdown, getAttributeValue, getAttributeValueExpression } from './utils.server' export function partialsRemark() { @@ -74,67 +78,19 @@ function toFilePath(node: MdxJsxFlowElement) { } /** - * Extracts all variable names expected in the partial content. - * Returns a Set of variable names found in {{ .variableName }} patterns. - * Variable names can contain alphanumeric characters, hyphens, and underscores. + * Substitutes provided variables into the partial content. Variable + * substitution is optional: any variable referenced in the content but not + * provided is replaced with an empty string, and any variable provided but not + * referenced is ignored. The leading `\` escape (`\{{ .var }}`) opts a + * placeholder out of substitution. */ -function extractExpectedVariables(content: string): Set { - const variablePattern = /(?() - let match - - while ((match = variablePattern.exec(content)) !== null) { - variables.add(match[1]) - } - - return variables -} - -/** - * Validates that all expected variables are provided and no unexpected variables are included. - * Throws descriptive errors if validation fails. - */ -function validateVariables( - content: string, - vars: Record | undefined, - partialPath: string -) { - const expectedVars = extractExpectedVariables(content) - const providedVars = vars ? new Set(Object.keys(vars)) : new Set() - - // Check for missing variables - const missingVars = [...expectedVars].filter((v) => !providedVars.has(v)) - if (missingVars.length > 0) { - const varList = missingVars.map((v) => `"${v}"`).join(', ') - const plural = missingVars.length > 1 - throw new Error( - `Missing required variable${plural ? 's' : ''} in $Partial "${partialPath}": ${varList}\n` + - `Expected variable${expectedVars.size > 1 ? 's' : ''}: ${[...expectedVars].map((v) => `"${v}"`).join(', ')}\n` + - `Provided variable${providedVars.size !== 1 ? 's' : ''}: ${providedVars.size > 0 ? [...providedVars].map((v) => `"${v}"`).join(', ') : 'none'}` - ) - } - - // Check for unexpected variables - const unexpectedVars = [...providedVars].filter((v) => !expectedVars.has(v)) - if (unexpectedVars.length > 0) { - const varList = unexpectedVars.map((v) => `"${v}"`).join(', ') - const plural = unexpectedVars.length > 1 - throw new Error( - `Unexpected variable${plural ? 's' : ''} in $Partial "${partialPath}": ${varList}\n` + - `Expected variable${expectedVars.size !== 1 ? 's' : ''}: ${expectedVars.size > 0 ? [...expectedVars].map((v) => `"${v}"`).join(', ') : 'none'}\n` + - `Provided variable${plural ? 's' : ''}: ${[...providedVars].map((v) => `"${v}"`).join(', ')}` - ) - } -} - function substituteVars(content: string, vars: Record | undefined) { - if (vars === undefined) { - return content - } - - for (const [key, value] of Object.entries(vars)) { + for (const [key, value] of Object.entries(vars ?? {})) { content = content.replace(new RegExp(`(?, - string, - ][] + const partialNodes = [] as [Parent, MdxJsxFlowElement, undefined | Record][] const pendingFetches = [] as Promise[] visitParents(tree, 'mdxJsxFlowElement', (node: MdxJsxFlowElement, ancestors) => { @@ -174,9 +125,8 @@ async function fetchPartialsContent(tree: Root) { const filePath = toFilePath(node) const variables = getVariables(node) const fetchTask = readFile(filePath, 'utf-8') - const partialPath = getAttributeValue(node, 'path') as string - partialNodes.push([parent, node, variables, partialPath]) + partialNodes.push([parent, node, variables]) pendingFetches.push(fetchTask) }) @@ -184,21 +134,19 @@ async function fetchPartialsContent(tree: Root) { const nodeContentMap = new Map< MdxJsxFlowElement, - [Parent, string, undefined | Record, string] + [Parent, string, undefined | Record] >() - partialNodes.forEach(([parent, node, variables, partialPath], index) => { - nodeContentMap.set(node, [parent, resolvedContent[index], variables, partialPath]) + partialNodes.forEach(([parent, node, variables], index) => { + nodeContentMap.set(node, [parent, resolvedContent[index], variables]) }) return nodeContentMap } function rewriteNodes( - contentMap: Map, string]> + contentMap: Map]> ) { - for (const [node, [parent, rawContent, vars, partialPath]] of contentMap) { - const trimmedContent = rawContent.trim() - validateVariables(trimmedContent, vars, partialPath) - let content = substituteVars(trimmedContent, vars) + for (const [node, [parent, rawContent, vars]] of contentMap) { + const content = substituteVars(rawContent.trim(), vars) const replacementContent = fromDocsMarkdown(content) parent.children.splice(parent.children.indexOf(node), 1, replacementContent) } diff --git a/apps/docs/features/docs/Reference.generated.script.ts b/apps/docs/features/docs/Reference.generated.script.ts index d8824206342..7a52c883ffb 100644 --- a/apps/docs/features/docs/Reference.generated.script.ts +++ b/apps/docs/features/docs/Reference.generated.script.ts @@ -17,7 +17,8 @@ import selfHostingRealtimeCommonSections from '~/spec/common-self-hosting-realti import selfHostingStorageCommonSections from '~/spec/common-self-hosting-storage-sections.json' with { type: 'json' } import storageSpec from '~/spec/storage_v0_openapi.json' with { type: 'json' } import analyticsSpec from '~/spec/transforms/analytics_v0_openapi_deparsed.json' with { type: 'json' } -import openApiSpec from '~/spec/transforms/api_v1_openapi_deparsed.json' with { type: 'json' } +import apiV1Spec from '~/spec/transforms/api_v1_openapi_deparsed.json' with { type: 'json' } +import apiV2Spec from '~/spec/transforms/api_v2_openapi_deparsed.json' with { type: 'json' } import { isPlainObject, keyBy } from 'lodash-es' import slugify from 'slugify' import { parse } from 'yaml' @@ -239,7 +240,25 @@ async function writeCliReferenceSections() { } async function writeApiReferenceSections() { - const endpointsById = mapEndpointsById(openApiSpec) + const mergedSpec = { + ...apiV1Spec, + paths: { + ...apiV1Spec.paths, + ...apiV2Spec.paths, + }, + components: { + ...apiV1Spec.components, + schemas: { + ...apiV1Spec.components?.schemas, + ...apiV2Spec.components?.schemas, + }, + securitySchemes: { + ...apiV1Spec.components?.securitySchemes, + ...apiV2Spec.components?.securitySchemes, + }, + }, + } + const endpointsById = mapEndpointsById(mergedSpec) const pendingEndpointsByIdWrite = writeFile( join(GENERATED_DIRECTORY, 'api.latest.endpointsById.json'), JSON.stringify(Array.from(endpointsById.entries())) diff --git a/apps/docs/package.json b/apps/docs/package.json index eb7450ad900..baef799f723 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -31,7 +31,7 @@ "postbuild": "pnpm run build:sitemap && pnpm run build:llms && ./../../scripts/upload-static-assets.sh", "prebuild": "pnpm run codegen:graphql && pnpm run codegen:references && pnpm run codegen:references:new && pnpm run codegen:examples && pnpm run build:guides-markdown && pnpm run build:gz-archive", "predev": "pnpm run codegen:graphql && pnpm run codegen:references && pnpm run codegen:references:new && pnpm run codegen:examples", - "preembeddings": "pnpm run codegen:references", + "preembeddings": "pnpm run codegen:references && pnpm run codegen:references:new", "preinstall": "npx only-allow pnpm", "presync": "pnpm run codegen:graphql", "pretest": "pnpm run codegen:examples", diff --git a/apps/docs/public/humans.txt b/apps/docs/public/humans.txt index 1d67411e716..c2f9a44d4ff 100644 --- a/apps/docs/public/humans.txt +++ b/apps/docs/public/humans.txt @@ -9,7 +9,6 @@ Adam Mokan AJ Matias Akash Manimaran Alaister Young -Alan De Los Santos Aleksi Immonen Alex Hall Alex Hsu @@ -93,7 +92,6 @@ Emmett Folger Eric Kharitonashvili Etienne Stalmans Eyal Ehrlich -Fabrizio Cataldo Fabrizio Fenoglio Fady A Fatuma Abdullahi diff --git a/apps/docs/spec/Makefile b/apps/docs/spec/Makefile index cb598870827..c7e125bffa4 100644 --- a/apps/docs/spec/Makefile +++ b/apps/docs/spec/Makefile @@ -13,7 +13,7 @@ download: download.api.v1 download.storage.v1 download.tsdoc.v2 download.api.v1: curl -sS https://api.supabase.com/api/v1-json > $(REPO_DIR)/api_v1_openapi.json - + curl -sS https://api.supabase.com/api/v2-json > $(REPO_DIR)/api_v2_openapi.json # This flow needs to be updated, so we'l comment out for the moment # Manual flow for now: @@ -58,6 +58,7 @@ transform: dereference.api.v1 dereference.auth.v1 dereference.storage.v0 dereference.api.v1: pnpm exec redocly bundle --dereferenced -o $(REPO_DIR)/transforms/api_v1_openapi_deparsed.json $(REPO_DIR)/api_v1_openapi.json + pnpm exec redocly bundle --dereferenced -o $(REPO_DIR)/transforms/api_v2_openapi_deparsed.json $(REPO_DIR)/api_v2_openapi.json dereference.auth.v1: pnpm exec redocly bundle --dereferenced -o $(REPO_DIR)/transforms/auth_v1_openapi_deparsed.json $(REPO_DIR)/auth_v1_openapi.json @@ -74,7 +75,10 @@ dereference.analytics.v0: generate: generate.sections.api.v1 generate.sections.api.v1: - npx tsx $(REPO_DIR)/sections/generateMgmtApiSections.cts $(REPO_DIR)/transforms/api_v1_openapi_deparsed.json $(REPO_DIR)/common-api-sections.json + npx tsx $(REPO_DIR)/sections/generateMgmtApiSections.cts \ + $(REPO_DIR)/transforms/api_v1_openapi_deparsed.json \ + $(REPO_DIR)/transforms/api_v2_openapi_deparsed.json \ + $(REPO_DIR)/common-api-sections.json ############################################################################### # Validate OpenAPI 3.0 diff --git a/apps/docs/spec/api_v2_openapi.json b/apps/docs/spec/api_v2_openapi.json new file mode 100644 index 00000000000..464289a5721 --- /dev/null +++ b/apps/docs/spec/api_v2_openapi.json @@ -0,0 +1,899 @@ +{ + "openapi": "3.0.0", + "paths": { + "/v2/projects/{ref}/analytics/log-drains": { + "get": { + "operationId": "v2-list-log-drains", + "parameters": [ + { + "name": "ref", + "required": true, + "in": "path", + "description": "Project ref", + "schema": { + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "example": "abcdefghijklmnopqrst", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ListLogDrainsResponse" } + } + } + }, + "401": { "description": "Unauthorized" }, + "403": { "description": "Forbidden action" }, + "429": { "description": "Rate limit exceeded" }, + "500": { "description": "Failed to fetch log drains" } + }, + "security": [{ "bearer": [] }, { "fga_permissions": ["analytics_config_read"] }], + "summary": "List project log drains", + "tags": ["Analytics"], + "x-badges": [{ "name": "OAuth scope: analytics_config:read", "position": "after" }], + "x-endpoint-owners": ["analytics"], + "x-oauth-scope": "analytics_config:read" + }, + "post": { + "operationId": "v2-create-log-drain", + "parameters": [ + { + "name": "ref", + "required": true, + "in": "path", + "description": "Project ref", + "schema": { + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "example": "abcdefghijklmnopqrst", + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CreateLogDrainRequestOpenApi" } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/LogDrainResponse" } } + } + }, + "401": { "description": "Unauthorized" }, + "402": { + "description": "This feature requires the Pro, Team, or Enterprise organization plan." + }, + "403": { "description": "Forbidden action" }, + "429": { "description": "Rate limit exceeded" }, + "500": { "description": "Failed to create a log drain" } + }, + "security": [{ "bearer": [] }, { "fga_permissions": ["analytics_config_write"] }], + "summary": "Create a log drain for a project", + "tags": ["Analytics"], + "x-allowed-plans": ["Pro", "Team", "Enterprise"], + "x-badges": [ + { "name": "Only available on Pro, Team, Enterprise", "position": "before" }, + { "name": "OAuth scope: analytics_config:write", "position": "after" } + ], + "x-endpoint-owners": ["analytics"], + "x-oauth-scope": "analytics_config:write" + } + }, + "/v2/projects/{ref}/analytics/log-drains/{id}": { + "put": { + "operationId": "v2-update-log-drain", + "parameters": [ + { + "name": "ref", + "required": true, + "in": "path", + "description": "Project ref", + "schema": { + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "example": "abcdefghijklmnopqrst", + "type": "string" + } + }, + { + "name": "id", + "required": true, + "in": "path", + "description": "Log drains identifier", + "schema": { "format": "uuid", "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/UpdateLogDrainRequestOpenApi" } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/LogDrainResponse" } } + } + }, + "401": { "description": "Unauthorized" }, + "403": { "description": "Forbidden action" }, + "429": { "description": "Rate limit exceeded" }, + "500": { "description": "Failed to update log drain" } + }, + "security": [{ "bearer": [] }, { "fga_permissions": ["analytics_config_write"] }], + "summary": "Update a project log drain", + "tags": ["Analytics"], + "x-badges": [{ "name": "OAuth scope: analytics_config:write", "position": "after" }], + "x-endpoint-owners": ["analytics"], + "x-oauth-scope": "analytics_config:write" + }, + "delete": { + "operationId": "v2-delete-log-drain", + "parameters": [ + { + "name": "ref", + "required": true, + "in": "path", + "description": "Project ref", + "schema": { + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "example": "abcdefghijklmnopqrst", + "type": "string" + } + }, + { + "name": "id", + "required": true, + "in": "path", + "description": "Log drains identifier", + "schema": { "format": "uuid", "type": "string" } + } + ], + "responses": { + "204": { "description": "" }, + "401": { "description": "Unauthorized" }, + "403": { "description": "Forbidden action" }, + "429": { "description": "Rate limit exceeded" }, + "500": { "description": "Failed to delete a log drain" } + }, + "security": [{ "bearer": [] }, { "fga_permissions": ["analytics_config_write"] }], + "summary": "Delete a project log drain", + "tags": ["Analytics"], + "x-badges": [{ "name": "OAuth scope: analytics_config:write", "position": "after" }], + "x-endpoint-owners": ["analytics"], + "x-oauth-scope": "analytics_config:write" + } + }, + "/v2/projects/{ref}/transfers/previews": { + "post": { + "operationId": "v2-preview-a-project-transfer", + "parameters": [ + { + "name": "ref", + "required": true, + "in": "path", + "description": "Project ref", + "schema": { + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "example": "abcdefghijklmnopqrst", + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/V2TransferProjectBody" } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/V2PreviewProjectTransferResponse" } + } + } + }, + "401": { "description": "Unauthorized" }, + "403": { "description": "Forbidden action" }, + "429": { "description": "Rate limit exceeded" } + }, + "security": [{ "bearer": [] }, { "fga_permissions": ["project_admin_read"] }], + "summary": "Previews transferring a project to a different organizations, shows eligibility and impact", + "tags": ["Projects"], + "x-endpoint-owners": ["management-api"] + } + }, + "/v2/projects/{ref}/transfers": { + "post": { + "operationId": "v2-transfer-a-project", + "parameters": [ + { + "name": "ref", + "required": true, + "in": "path", + "description": "Project ref", + "schema": { + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "example": "abcdefghijklmnopqrst", + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/V2TransferProjectBody" } + } + } + }, + "responses": { + "200": { "description": "" }, + "401": { "description": "Unauthorized" }, + "403": { "description": "Forbidden action" }, + "429": { "description": "Rate limit exceeded" } + }, + "security": [{ "bearer": [] }, { "fga_permissions": ["organization_admin_write"] }], + "summary": "Transfers a project to a different organization", + "tags": ["Projects"], + "x-endpoint-owners": ["management-api"] + } + } + }, + "info": { + "title": "Supabase API (v2)", + "description": "Supabase API generated from the OpenAPI specification.
Visit [https://supabase.com/docs](https://supabase.com/docs) for a complete documentation.", + "version": "1.0.0", + "contact": {} + }, + "tags": [], + "servers": [], + "components": { + "securitySchemes": { "bearer": { "scheme": "bearer", "bearerFormat": "JWT", "type": "http" } }, + "schemas": { + "ListLogDrainsResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["log_drain"], + "description": "Resource type." + }, + "id": { "type": "string" }, + "attributes": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "description": { "type": "string" }, + "config": { + "oneOf": [ + { + "type": "object", + "properties": { + "url": { "type": "string", "nullable": true }, + "schema": { "type": "string" }, + "username": { "type": "string", "nullable": true }, + "password": { "type": "string", "nullable": true }, + "port": { "type": "number", "nullable": true }, + "hostname": { "type": "string" } + }, + "additionalProperties": false, + "title": "postgres" + }, + { + "type": "object", + "properties": { + "url": { "type": "string" }, + "http": { "type": "string", "enum": ["http1", "http2"] }, + "gzip": { "type": "boolean" }, + "headers": { + "type": "object", + "additionalProperties": { "type": "string" } + } + }, + "additionalProperties": false, + "title": "webhook" + }, + { + "type": "object", + "properties": { + "project_id": { "type": "string" }, + "dataset_id": { "type": "string" } + }, + "additionalProperties": false, + "title": "bigquery" + }, + { + "type": "object", + "properties": { + "api_key": { "type": "string" }, + "region": { "type": "string" } + }, + "additionalProperties": false, + "title": "datadog" + }, + { + "type": "object", + "properties": { + "url": { "type": "string" }, + "username": { "type": "string", "nullable": true }, + "password": { "type": "string", "nullable": true }, + "headers": { + "type": "object", + "additionalProperties": { "type": "string" } + } + }, + "additionalProperties": false, + "title": "loki" + }, + { + "type": "object", + "properties": { "dsn": { "type": "string" } }, + "additionalProperties": false, + "title": "sentry" + }, + { + "type": "object", + "properties": { + "domain": { "type": "string" }, + "api_token": { "type": "string" }, + "dataset_name": { "type": "string" } + }, + "additionalProperties": false, + "title": "axiom" + }, + { + "type": "object", + "properties": { + "host": { "type": "string" }, + "port": { "type": "integer", "minimum": 0, "maximum": 65535 }, + "tls": { "default": false, "type": "boolean" }, + "structured_data": { "type": "string" }, + "cipher_key": { "type": "string" }, + "ca_cert": { "type": "string" }, + "client_cert": { "type": "string" }, + "client_key": { "type": "string" } + }, + "additionalProperties": false, + "title": "syslog" + } + ] + }, + "backend_type": { + "type": "string", + "enum": [ + "postgres", + "bigquery", + "clickhouse", + "webhook", + "datadog", + "loki", + "sentry", + "s3", + "axiom", + "last9", + "otlp", + "syslog" + ] + } + }, + "required": ["name", "config", "backend_type"] + } + }, + "required": ["type", "id", "attributes"] + } + } + }, + "required": ["data"] + }, + "CreateLogDrainRequestOpenApi": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "type": { "type": "string", "enum": ["log_drain"], "description": "Resource type." }, + "attributes": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "description": { "type": "string" }, + "config": { + "oneOf": [ + { + "type": "object", + "properties": { + "url": { "type": "string", "nullable": true }, + "schema": { "type": "string" }, + "username": { "type": "string", "nullable": true }, + "password": { "type": "string", "nullable": true }, + "port": { "type": "number", "nullable": true }, + "hostname": { "type": "string" } + }, + "additionalProperties": false, + "title": "postgres" + }, + { + "type": "object", + "properties": { + "url": { "type": "string" }, + "http": { "type": "string", "enum": ["http1", "http2"] }, + "gzip": { "type": "boolean" }, + "headers": { + "type": "object", + "additionalProperties": { "type": "string" } + } + }, + "additionalProperties": false, + "title": "webhook" + }, + { + "type": "object", + "properties": { + "project_id": { "type": "string" }, + "dataset_id": { "type": "string" } + }, + "additionalProperties": false, + "title": "bigquery" + }, + { + "type": "object", + "properties": { + "api_key": { "type": "string" }, + "region": { "type": "string" } + }, + "additionalProperties": false, + "title": "datadog" + }, + { + "type": "object", + "properties": { + "url": { "type": "string" }, + "username": { "type": "string", "nullable": true }, + "password": { "type": "string", "nullable": true }, + "headers": { + "type": "object", + "additionalProperties": { "type": "string" } + } + }, + "additionalProperties": false, + "title": "loki" + }, + { + "type": "object", + "properties": { "dsn": { "type": "string" } }, + "additionalProperties": false, + "title": "sentry" + }, + { + "type": "object", + "properties": { + "domain": { "type": "string" }, + "api_token": { "type": "string" }, + "dataset_name": { "type": "string" } + }, + "additionalProperties": false, + "title": "axiom" + }, + { + "type": "object", + "properties": { + "host": { "type": "string" }, + "port": { "type": "integer", "minimum": 0, "maximum": 65535 }, + "tls": { "default": false, "type": "boolean" }, + "structured_data": { "type": "string" }, + "cipher_key": { "type": "string" }, + "ca_cert": { "type": "string" }, + "client_cert": { "type": "string" }, + "client_key": { "type": "string" } + }, + "additionalProperties": false, + "title": "syslog" + } + ] + }, + "backend_type": { + "type": "string", + "enum": [ + "postgres", + "bigquery", + "clickhouse", + "webhook", + "datadog", + "loki", + "sentry", + "s3", + "axiom", + "last9", + "otlp", + "syslog" + ] + } + }, + "required": ["name", "config", "backend_type"] + } + }, + "required": ["type", "attributes"] + } + }, + "required": ["data"] + }, + "LogDrainResponse": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "type": { "type": "string", "enum": ["log_drain"], "description": "Resource type." }, + "id": { "type": "string" }, + "attributes": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "description": { "type": "string" }, + "config": { + "oneOf": [ + { + "type": "object", + "properties": { + "url": { "type": "string", "nullable": true }, + "schema": { "type": "string" }, + "username": { "type": "string", "nullable": true }, + "password": { "type": "string", "nullable": true }, + "port": { "type": "number", "nullable": true }, + "hostname": { "type": "string" } + }, + "additionalProperties": false, + "title": "postgres" + }, + { + "type": "object", + "properties": { + "url": { "type": "string" }, + "http": { "type": "string", "enum": ["http1", "http2"] }, + "gzip": { "type": "boolean" }, + "headers": { + "type": "object", + "additionalProperties": { "type": "string" } + } + }, + "additionalProperties": false, + "title": "webhook" + }, + { + "type": "object", + "properties": { + "project_id": { "type": "string" }, + "dataset_id": { "type": "string" } + }, + "additionalProperties": false, + "title": "bigquery" + }, + { + "type": "object", + "properties": { + "api_key": { "type": "string" }, + "region": { "type": "string" } + }, + "additionalProperties": false, + "title": "datadog" + }, + { + "type": "object", + "properties": { + "url": { "type": "string" }, + "username": { "type": "string", "nullable": true }, + "password": { "type": "string", "nullable": true }, + "headers": { + "type": "object", + "additionalProperties": { "type": "string" } + } + }, + "additionalProperties": false, + "title": "loki" + }, + { + "type": "object", + "properties": { "dsn": { "type": "string" } }, + "additionalProperties": false, + "title": "sentry" + }, + { + "type": "object", + "properties": { + "domain": { "type": "string" }, + "api_token": { "type": "string" }, + "dataset_name": { "type": "string" } + }, + "additionalProperties": false, + "title": "axiom" + }, + { + "type": "object", + "properties": { + "host": { "type": "string" }, + "port": { "type": "integer", "minimum": 0, "maximum": 65535 }, + "tls": { "default": false, "type": "boolean" }, + "structured_data": { "type": "string" }, + "cipher_key": { "type": "string" }, + "ca_cert": { "type": "string" }, + "client_cert": { "type": "string" }, + "client_key": { "type": "string" } + }, + "additionalProperties": false, + "title": "syslog" + } + ] + }, + "backend_type": { + "type": "string", + "enum": [ + "postgres", + "bigquery", + "clickhouse", + "webhook", + "datadog", + "loki", + "sentry", + "s3", + "axiom", + "last9", + "otlp", + "syslog" + ] + } + }, + "required": ["name", "config", "backend_type"] + } + }, + "required": ["type", "id", "attributes"] + } + }, + "required": ["data"] + }, + "UpdateLogDrainRequestOpenApi": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "type": { "type": "string", "enum": ["log_drain"], "description": "Resource type." }, + "attributes": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "description": { "type": "string" }, + "config": { + "oneOf": [ + { + "type": "object", + "properties": { + "url": { "type": "string", "nullable": true }, + "schema": { "type": "string" }, + "username": { "type": "string", "nullable": true }, + "password": { "type": "string", "nullable": true }, + "port": { "type": "number", "nullable": true }, + "hostname": { "type": "string" } + }, + "additionalProperties": false, + "title": "postgres" + }, + { + "type": "object", + "properties": { + "url": { "type": "string" }, + "http": { "type": "string", "enum": ["http1", "http2"] }, + "gzip": { "type": "boolean" }, + "headers": { + "type": "object", + "additionalProperties": { "type": "string" } + } + }, + "additionalProperties": false, + "title": "webhook" + }, + { + "type": "object", + "properties": { + "project_id": { "type": "string" }, + "dataset_id": { "type": "string" } + }, + "additionalProperties": false, + "title": "bigquery" + }, + { + "type": "object", + "properties": { + "api_key": { "type": "string" }, + "region": { "type": "string" } + }, + "additionalProperties": false, + "title": "datadog" + }, + { + "type": "object", + "properties": { + "url": { "type": "string" }, + "username": { "type": "string", "nullable": true }, + "password": { "type": "string", "nullable": true }, + "headers": { + "type": "object", + "additionalProperties": { "type": "string" } + } + }, + "additionalProperties": false, + "title": "loki" + }, + { + "type": "object", + "properties": { "dsn": { "type": "string" } }, + "additionalProperties": false, + "title": "sentry" + }, + { + "type": "object", + "properties": { + "domain": { "type": "string" }, + "api_token": { "type": "string" }, + "dataset_name": { "type": "string" } + }, + "additionalProperties": false, + "title": "axiom" + }, + { + "type": "object", + "properties": { + "host": { "type": "string" }, + "port": { "type": "integer", "minimum": 0, "maximum": 65535 }, + "tls": { "default": false, "type": "boolean" }, + "structured_data": { "type": "string" }, + "cipher_key": { "type": "string" }, + "ca_cert": { "type": "string" }, + "client_cert": { "type": "string" }, + "client_key": { "type": "string" } + }, + "additionalProperties": false, + "title": "syslog" + } + ] + }, + "backend_type": { + "type": "string", + "enum": [ + "postgres", + "bigquery", + "clickhouse", + "webhook", + "datadog", + "loki", + "sentry", + "s3", + "axiom", + "last9", + "otlp", + "syslog" + ] + } + }, + "required": ["backend_type"] + } + }, + "required": ["type", "attributes"] + } + }, + "required": ["data"] + }, + "V2TransferProjectBody": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["project_transfer_input"], + "description": "Resource type." + }, + "attributes": { + "type": "object", + "properties": { "target_organization_slug": { "type": "string" } }, + "required": ["target_organization_slug"] + } + }, + "required": ["type", "attributes"] + } + }, + "required": ["data"] + }, + "V2PreviewProjectTransferResponse": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["project_transfer_result"], + "description": "Resource type." + }, + "attributes": { + "type": "object", + "properties": { + "valid": { "type": "boolean" }, + "warnings": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { "type": "string" }, + "message": { "type": "string" } + }, + "required": ["key", "message"] + } + }, + "errors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { "type": "string" }, + "message": { "type": "string" } + }, + "required": ["key", "message"] + } + }, + "info": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { "type": "string" }, + "message": { "type": "string" } + }, + "required": ["key", "message"] + } + } + }, + "required": ["valid", "warnings", "errors", "info"] + } + }, + "required": ["type", "attributes"] + } + }, + "required": ["data"] + } + } + } +} diff --git a/apps/docs/spec/common-api-sections.json b/apps/docs/spec/common-api-sections.json index 67e1f055b4a..6d4aad0cd97 100644 --- a/apps/docs/spec/common-api-sections.json +++ b/apps/docs/spec/common-api-sections.json @@ -28,6 +28,18 @@ "type": "category", "title": "Analytics", "items": [ + { + "id": "v2-create-log-drain", + "title": "Create log drain", + "slug": "v2-create-log-drain", + "type": "operation" + }, + { + "id": "v2-delete-log-drain", + "title": "Delete log drain", + "slug": "v2-delete-log-drain", + "type": "operation" + }, { "id": "v1-get-project-function-combined-stats", "title": "Get project function combined stats", @@ -51,6 +63,18 @@ "title": "Get project usage request count", "slug": "v1-get-project-usage-request-count", "type": "operation" + }, + { + "id": "v2-list-log-drains", + "title": "List log drains", + "slug": "v2-list-log-drains", + "type": "operation" + }, + { + "id": "v2-update-log-drain", + "title": "Update log drain", + "slug": "v2-update-log-drain", + "type": "operation" } ] }, @@ -856,12 +880,24 @@ "slug": "v1-pause-a-project", "type": "operation" }, + { + "id": "v2-preview-a-project-transfer", + "title": "Preview a project transfer", + "slug": "v2-preview-a-project-transfer", + "type": "operation" + }, { "id": "v1-restore-a-project", "title": "Restore a project", "slug": "v1-restore-a-project", "type": "operation" }, + { + "id": "v2-transfer-a-project", + "title": "Transfer a project", + "slug": "v2-transfer-a-project", + "type": "operation" + }, { "id": "v1-update-a-project", "title": "Update a project", diff --git a/apps/docs/spec/sections/generateMgmtApiSections.cts b/apps/docs/spec/sections/generateMgmtApiSections.cts index 02deb853df4..ecb55ed9063 100644 --- a/apps/docs/spec/sections/generateMgmtApiSections.cts +++ b/apps/docs/spec/sections/generateMgmtApiSections.cts @@ -3,7 +3,6 @@ import path from 'path' function slugToTitle(slug) { if (!slug) return '' - // remove version prefix if available const prefixRegex = /^v\d+/ const title = slug.replace(prefixRegex, '').replace(/-/g, ' ').trimStart() return title.charAt(0).toUpperCase() + title.slice(1) @@ -14,100 +13,83 @@ function isValidSlug(slug) { return slugRegex.test(slug) } -function extractSectionsFromOpenApi(filePath, outputPath) { - fs.readFile(filePath, 'utf8', (err, data) => { - if (err) { - console.error(`Error reading file ${filePath}:`, err) - return - } +function readJson(filePath: string) { + return JSON.parse(fs.readFileSync(filePath, 'utf8')) +} + +function extractSectionsFromOpenApi(filePaths: string[], outputPath: string) { + try { + // Merge paths from all specs, later specs override earlier ones on conflict + const mergedPaths = Object.assign({}, ...filePaths.map((p) => readJson(p).paths ?? {})) - try { - const openApiJson = JSON.parse(data) - const categories: string[] = [] - const sections: Array<{ + const categories: string[] = [] + const sections: Array<{ + type: string + title: string + id?: string + slug?: string + items: Array<{ type: string title: string - id?: string - slug?: string - items: Array<{ - type: string - title: string - id: string - slug: string - }> - }> = [] + id: string + slug: string + }> + }> = [] - if (openApiJson.paths) { - for (const route in openApiJson.paths) { - const methods = openApiJson.paths[route] - for (const method in methods) { - // We are using `x-internal` to hide endpoints from the docs, - // but still have them included in the spec so they generate types and can be used. - if (methods[method]['x-internal']) { - continue - } - - const tag = methods[method].tags?.[0] - const operationId = methods[method].operationId - // If operationId is not in the form of a slug ignore it. - // This is intentional because operationId is not defined under the swagger - // spec and is extracted automatically from the function name. - if (!tag || !isValidSlug(operationId)) continue - - if (!categories.includes(tag)) { - categories.push(tag) - sections.push({ - type: 'category', - title: tag, - items: [], - }) - } - - const sectionCate = sections.find((i) => i.title === tag) - sectionCate?.items.push({ - id: operationId, - title: slugToTitle(operationId), - slug: operationId, - type: 'operation', - }) - } + for (const route in mergedPaths) { + const methods = mergedPaths[route] + for (const method in methods) { + if (methods[method]['x-internal']) { + continue + } + const tag = methods[method].tags?.[0] + const operationId = methods[method].operationId + if (!tag || !isValidSlug(operationId)) continue + if (!categories.includes(tag)) { + categories.push(tag) + sections.push({ + type: 'category', + title: tag, + items: [], + }) } + const sectionCate = sections.find((i) => i.title === tag) + sectionCate?.items.push({ + id: operationId, + title: slugToTitle(operationId), + slug: operationId, + type: 'operation', + }) } + } - // finalize sections - sections.sort((a, b) => a.title.localeCompare(b.title)) - sections.forEach((i) => i.items.sort((a, b) => a.title.localeCompare(b.title))) - sections.unshift({ - title: 'Introduction', - id: 'introduction', - slug: 'introduction', - type: 'markdown', - items: [], - }) + sections.sort((a, b) => a.title.localeCompare(b.title)) + sections.forEach((i) => i.items.sort((a, b) => a.title.localeCompare(b.title))) + sections.unshift({ + title: 'Introduction', + id: 'introduction', + slug: 'introduction', + type: 'markdown', + items: [], + }) - fs.writeFile(outputPath, JSON.stringify(sections, null, 2), 'utf8', (err) => { - if (err) { - console.error(`Error writing to file ${outputPath}:`, err) - return - } - console.log(`Sections successfully generated!!!`) - }) - } catch (error) { - console.error('Error parsing JSON:', error) - } - }) + fs.writeFileSync(outputPath, JSON.stringify(sections, null, 2), 'utf8') + console.log(`Sections successfully generated!!!`) + } catch (error) { + console.error('Error:', error) + } } -// Get file paths from command line arguments const args = process.argv.slice(2) if (args.length < 2) { - console.error('Please provide the openapi file path and output file path as arguments.') + console.error( + 'Please provide at least one openapi file path and an output file path as arguments.' + ) process.exit(1) } -const inputFilePath = path.resolve(args[0]) -const outputFilePath = path.resolve(args[1]) +// Last arg is output, everything before is input files +const outputFilePath = path.resolve(args[args.length - 1]) +const inputFilePaths = args.slice(0, -1).map((p) => path.resolve(p)) -;(async () => { - extractSectionsFromOpenApi(inputFilePath, outputFilePath) -})() +extractSectionsFromOpenApi(inputFilePaths, outputFilePath) diff --git a/apps/docs/spec/transforms/api_v2_openapi_deparsed.json b/apps/docs/spec/transforms/api_v2_openapi_deparsed.json new file mode 100644 index 00000000000..3e430f5e530 --- /dev/null +++ b/apps/docs/spec/transforms/api_v2_openapi_deparsed.json @@ -0,0 +1,2551 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Supabase API (v2)", + "description": "Supabase API generated from the OpenAPI specification.
Visit [https://supabase.com/docs](https://supabase.com/docs) for a complete documentation.", + "version": "1.0.0", + "contact": {} + }, + "servers": [], + "tags": [], + "paths": { + "/v2/projects/{ref}/analytics/log-drains": { + "get": { + "operationId": "v2-list-log-drains", + "parameters": [ + { + "name": "ref", + "required": true, + "in": "path", + "description": "Project ref", + "schema": { + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "example": "abcdefghijklmnopqrst", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["log_drain"], + "description": "Resource type." + }, + "id": { + "type": "string" + }, + "attributes": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "config": { + "oneOf": [ + { + "type": "object", + "properties": { + "url": { + "type": "string", + "nullable": true + }, + "schema": { + "type": "string" + }, + "username": { + "type": "string", + "nullable": true + }, + "password": { + "type": "string", + "nullable": true + }, + "port": { + "type": "number", + "nullable": true + }, + "hostname": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "postgres" + }, + { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "http": { + "type": "string", + "enum": ["http1", "http2"] + }, + "gzip": { + "type": "boolean" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "additionalProperties": false, + "title": "webhook" + }, + { + "type": "object", + "properties": { + "project_id": { + "type": "string" + }, + "dataset_id": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "bigquery" + }, + { + "type": "object", + "properties": { + "api_key": { + "type": "string" + }, + "region": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "datadog" + }, + { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "username": { + "type": "string", + "nullable": true + }, + "password": { + "type": "string", + "nullable": true + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "additionalProperties": false, + "title": "loki" + }, + { + "type": "object", + "properties": { + "dsn": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "sentry" + }, + { + "type": "object", + "properties": { + "domain": { + "type": "string" + }, + "api_token": { + "type": "string" + }, + "dataset_name": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "axiom" + }, + { + "type": "object", + "properties": { + "host": { + "type": "string" + }, + "port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "tls": { + "default": false, + "type": "boolean" + }, + "structured_data": { + "type": "string" + }, + "cipher_key": { + "type": "string" + }, + "ca_cert": { + "type": "string" + }, + "client_cert": { + "type": "string" + }, + "client_key": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "syslog" + } + ] + }, + "backend_type": { + "type": "string", + "enum": [ + "postgres", + "bigquery", + "clickhouse", + "webhook", + "datadog", + "loki", + "sentry", + "s3", + "axiom", + "last9", + "otlp", + "syslog" + ] + } + }, + "required": ["name", "config", "backend_type"] + } + }, + "required": ["type", "id", "attributes"] + } + } + }, + "required": ["data"] + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden action" + }, + "429": { + "description": "Rate limit exceeded" + }, + "500": { + "description": "Failed to fetch log drains" + } + }, + "security": [ + { + "bearer": [] + }, + { + "fga_permissions": ["analytics_config_read"] + } + ], + "summary": "List project log drains", + "tags": ["Analytics"], + "x-badges": [ + { + "name": "OAuth scope: analytics_config:read", + "position": "after" + } + ], + "x-endpoint-owners": ["analytics"], + "x-oauth-scope": "analytics_config:read" + }, + "post": { + "operationId": "v2-create-log-drain", + "parameters": [ + { + "name": "ref", + "required": true, + "in": "path", + "description": "Project ref", + "schema": { + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "example": "abcdefghijklmnopqrst", + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["log_drain"], + "description": "Resource type." + }, + "attributes": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "config": { + "oneOf": [ + { + "type": "object", + "properties": { + "url": { + "type": "string", + "nullable": true + }, + "schema": { + "type": "string" + }, + "username": { + "type": "string", + "nullable": true + }, + "password": { + "type": "string", + "nullable": true + }, + "port": { + "type": "number", + "nullable": true + }, + "hostname": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "postgres" + }, + { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "http": { + "type": "string", + "enum": ["http1", "http2"] + }, + "gzip": { + "type": "boolean" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "additionalProperties": false, + "title": "webhook" + }, + { + "type": "object", + "properties": { + "project_id": { + "type": "string" + }, + "dataset_id": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "bigquery" + }, + { + "type": "object", + "properties": { + "api_key": { + "type": "string" + }, + "region": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "datadog" + }, + { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "username": { + "type": "string", + "nullable": true + }, + "password": { + "type": "string", + "nullable": true + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "additionalProperties": false, + "title": "loki" + }, + { + "type": "object", + "properties": { + "dsn": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "sentry" + }, + { + "type": "object", + "properties": { + "domain": { + "type": "string" + }, + "api_token": { + "type": "string" + }, + "dataset_name": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "axiom" + }, + { + "type": "object", + "properties": { + "host": { + "type": "string" + }, + "port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "tls": { + "default": false, + "type": "boolean" + }, + "structured_data": { + "type": "string" + }, + "cipher_key": { + "type": "string" + }, + "ca_cert": { + "type": "string" + }, + "client_cert": { + "type": "string" + }, + "client_key": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "syslog" + } + ] + }, + "backend_type": { + "type": "string", + "enum": [ + "postgres", + "bigquery", + "clickhouse", + "webhook", + "datadog", + "loki", + "sentry", + "s3", + "axiom", + "last9", + "otlp", + "syslog" + ] + } + }, + "required": ["name", "config", "backend_type"] + } + }, + "required": ["type", "attributes"] + } + }, + "required": ["data"] + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["log_drain"], + "description": "Resource type." + }, + "id": { + "type": "string" + }, + "attributes": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "config": { + "oneOf": [ + { + "type": "object", + "properties": { + "url": { + "type": "string", + "nullable": true + }, + "schema": { + "type": "string" + }, + "username": { + "type": "string", + "nullable": true + }, + "password": { + "type": "string", + "nullable": true + }, + "port": { + "type": "number", + "nullable": true + }, + "hostname": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "postgres" + }, + { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "http": { + "type": "string", + "enum": ["http1", "http2"] + }, + "gzip": { + "type": "boolean" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "additionalProperties": false, + "title": "webhook" + }, + { + "type": "object", + "properties": { + "project_id": { + "type": "string" + }, + "dataset_id": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "bigquery" + }, + { + "type": "object", + "properties": { + "api_key": { + "type": "string" + }, + "region": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "datadog" + }, + { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "username": { + "type": "string", + "nullable": true + }, + "password": { + "type": "string", + "nullable": true + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "additionalProperties": false, + "title": "loki" + }, + { + "type": "object", + "properties": { + "dsn": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "sentry" + }, + { + "type": "object", + "properties": { + "domain": { + "type": "string" + }, + "api_token": { + "type": "string" + }, + "dataset_name": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "axiom" + }, + { + "type": "object", + "properties": { + "host": { + "type": "string" + }, + "port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "tls": { + "default": false, + "type": "boolean" + }, + "structured_data": { + "type": "string" + }, + "cipher_key": { + "type": "string" + }, + "ca_cert": { + "type": "string" + }, + "client_cert": { + "type": "string" + }, + "client_key": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "syslog" + } + ] + }, + "backend_type": { + "type": "string", + "enum": [ + "postgres", + "bigquery", + "clickhouse", + "webhook", + "datadog", + "loki", + "sentry", + "s3", + "axiom", + "last9", + "otlp", + "syslog" + ] + } + }, + "required": ["name", "config", "backend_type"] + } + }, + "required": ["type", "id", "attributes"] + } + }, + "required": ["data"] + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "402": { + "description": "This feature requires the Pro, Team, or Enterprise organization plan." + }, + "403": { + "description": "Forbidden action" + }, + "429": { + "description": "Rate limit exceeded" + }, + "500": { + "description": "Failed to create a log drain" + } + }, + "security": [ + { + "bearer": [] + }, + { + "fga_permissions": ["analytics_config_write"] + } + ], + "summary": "Create a log drain for a project", + "tags": ["Analytics"], + "x-allowed-plans": ["Pro", "Team", "Enterprise"], + "x-badges": [ + { + "name": "Only available on Pro, Team, Enterprise", + "position": "before" + }, + { + "name": "OAuth scope: analytics_config:write", + "position": "after" + } + ], + "x-endpoint-owners": ["analytics"], + "x-oauth-scope": "analytics_config:write" + } + }, + "/v2/projects/{ref}/analytics/log-drains/{id}": { + "put": { + "operationId": "v2-update-log-drain", + "parameters": [ + { + "name": "ref", + "required": true, + "in": "path", + "description": "Project ref", + "schema": { + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "example": "abcdefghijklmnopqrst", + "type": "string" + } + }, + { + "name": "id", + "required": true, + "in": "path", + "description": "Log drains identifier", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["log_drain"], + "description": "Resource type." + }, + "attributes": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "config": { + "oneOf": [ + { + "type": "object", + "properties": { + "url": { + "type": "string", + "nullable": true + }, + "schema": { + "type": "string" + }, + "username": { + "type": "string", + "nullable": true + }, + "password": { + "type": "string", + "nullable": true + }, + "port": { + "type": "number", + "nullable": true + }, + "hostname": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "postgres" + }, + { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "http": { + "type": "string", + "enum": ["http1", "http2"] + }, + "gzip": { + "type": "boolean" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "additionalProperties": false, + "title": "webhook" + }, + { + "type": "object", + "properties": { + "project_id": { + "type": "string" + }, + "dataset_id": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "bigquery" + }, + { + "type": "object", + "properties": { + "api_key": { + "type": "string" + }, + "region": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "datadog" + }, + { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "username": { + "type": "string", + "nullable": true + }, + "password": { + "type": "string", + "nullable": true + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "additionalProperties": false, + "title": "loki" + }, + { + "type": "object", + "properties": { + "dsn": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "sentry" + }, + { + "type": "object", + "properties": { + "domain": { + "type": "string" + }, + "api_token": { + "type": "string" + }, + "dataset_name": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "axiom" + }, + { + "type": "object", + "properties": { + "host": { + "type": "string" + }, + "port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "tls": { + "default": false, + "type": "boolean" + }, + "structured_data": { + "type": "string" + }, + "cipher_key": { + "type": "string" + }, + "ca_cert": { + "type": "string" + }, + "client_cert": { + "type": "string" + }, + "client_key": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "syslog" + } + ] + }, + "backend_type": { + "type": "string", + "enum": [ + "postgres", + "bigquery", + "clickhouse", + "webhook", + "datadog", + "loki", + "sentry", + "s3", + "axiom", + "last9", + "otlp", + "syslog" + ] + } + }, + "required": ["backend_type"] + } + }, + "required": ["type", "attributes"] + } + }, + "required": ["data"] + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["log_drain"], + "description": "Resource type." + }, + "id": { + "type": "string" + }, + "attributes": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "config": { + "oneOf": [ + { + "type": "object", + "properties": { + "url": { + "type": "string", + "nullable": true + }, + "schema": { + "type": "string" + }, + "username": { + "type": "string", + "nullable": true + }, + "password": { + "type": "string", + "nullable": true + }, + "port": { + "type": "number", + "nullable": true + }, + "hostname": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "postgres" + }, + { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "http": { + "type": "string", + "enum": ["http1", "http2"] + }, + "gzip": { + "type": "boolean" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "additionalProperties": false, + "title": "webhook" + }, + { + "type": "object", + "properties": { + "project_id": { + "type": "string" + }, + "dataset_id": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "bigquery" + }, + { + "type": "object", + "properties": { + "api_key": { + "type": "string" + }, + "region": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "datadog" + }, + { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "username": { + "type": "string", + "nullable": true + }, + "password": { + "type": "string", + "nullable": true + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "additionalProperties": false, + "title": "loki" + }, + { + "type": "object", + "properties": { + "dsn": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "sentry" + }, + { + "type": "object", + "properties": { + "domain": { + "type": "string" + }, + "api_token": { + "type": "string" + }, + "dataset_name": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "axiom" + }, + { + "type": "object", + "properties": { + "host": { + "type": "string" + }, + "port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "tls": { + "default": false, + "type": "boolean" + }, + "structured_data": { + "type": "string" + }, + "cipher_key": { + "type": "string" + }, + "ca_cert": { + "type": "string" + }, + "client_cert": { + "type": "string" + }, + "client_key": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "syslog" + } + ] + }, + "backend_type": { + "type": "string", + "enum": [ + "postgres", + "bigquery", + "clickhouse", + "webhook", + "datadog", + "loki", + "sentry", + "s3", + "axiom", + "last9", + "otlp", + "syslog" + ] + } + }, + "required": ["name", "config", "backend_type"] + } + }, + "required": ["type", "id", "attributes"] + } + }, + "required": ["data"] + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden action" + }, + "429": { + "description": "Rate limit exceeded" + }, + "500": { + "description": "Failed to update log drain" + } + }, + "security": [ + { + "bearer": [] + }, + { + "fga_permissions": ["analytics_config_write"] + } + ], + "summary": "Update a project log drain", + "tags": ["Analytics"], + "x-badges": [ + { + "name": "OAuth scope: analytics_config:write", + "position": "after" + } + ], + "x-endpoint-owners": ["analytics"], + "x-oauth-scope": "analytics_config:write" + }, + "delete": { + "operationId": "v2-delete-log-drain", + "parameters": [ + { + "name": "ref", + "required": true, + "in": "path", + "description": "Project ref", + "schema": { + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "example": "abcdefghijklmnopqrst", + "type": "string" + } + }, + { + "name": "id", + "required": true, + "in": "path", + "description": "Log drains identifier", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden action" + }, + "429": { + "description": "Rate limit exceeded" + }, + "500": { + "description": "Failed to delete a log drain" + } + }, + "security": [ + { + "bearer": [] + }, + { + "fga_permissions": ["analytics_config_write"] + } + ], + "summary": "Delete a project log drain", + "tags": ["Analytics"], + "x-badges": [ + { + "name": "OAuth scope: analytics_config:write", + "position": "after" + } + ], + "x-endpoint-owners": ["analytics"], + "x-oauth-scope": "analytics_config:write" + } + }, + "/v2/projects/{ref}/transfers/previews": { + "post": { + "operationId": "v2-preview-a-project-transfer", + "parameters": [ + { + "name": "ref", + "required": true, + "in": "path", + "description": "Project ref", + "schema": { + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "example": "abcdefghijklmnopqrst", + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["project_transfer_input"], + "description": "Resource type." + }, + "attributes": { + "type": "object", + "properties": { + "target_organization_slug": { + "type": "string" + } + }, + "required": ["target_organization_slug"] + } + }, + "required": ["type", "attributes"] + } + }, + "required": ["data"] + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["project_transfer_result"], + "description": "Resource type." + }, + "attributes": { + "type": "object", + "properties": { + "valid": { + "type": "boolean" + }, + "warnings": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["key", "message"] + } + }, + "errors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["key", "message"] + } + }, + "info": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["key", "message"] + } + } + }, + "required": ["valid", "warnings", "errors", "info"] + } + }, + "required": ["type", "attributes"] + } + }, + "required": ["data"] + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden action" + }, + "429": { + "description": "Rate limit exceeded" + } + }, + "security": [ + { + "bearer": [] + }, + { + "fga_permissions": ["project_admin_read"] + } + ], + "summary": "Previews transferring a project to a different organizations, shows eligibility and impact", + "tags": ["Projects"], + "x-endpoint-owners": ["management-api"] + } + }, + "/v2/projects/{ref}/transfers": { + "post": { + "operationId": "v2-transfer-a-project", + "parameters": [ + { + "name": "ref", + "required": true, + "in": "path", + "description": "Project ref", + "schema": { + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "example": "abcdefghijklmnopqrst", + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["project_transfer_input"], + "description": "Resource type." + }, + "attributes": { + "type": "object", + "properties": { + "target_organization_slug": { + "type": "string" + } + }, + "required": ["target_organization_slug"] + } + }, + "required": ["type", "attributes"] + } + }, + "required": ["data"] + } + } + } + }, + "responses": { + "200": { + "description": "" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden action" + }, + "429": { + "description": "Rate limit exceeded" + } + }, + "security": [ + { + "bearer": [] + }, + { + "fga_permissions": ["organization_admin_write"] + } + ], + "summary": "Transfers a project to a different organization", + "tags": ["Projects"], + "x-endpoint-owners": ["management-api"] + } + } + }, + "components": { + "securitySchemes": { + "bearer": { + "scheme": "bearer", + "bearerFormat": "JWT", + "type": "http" + } + }, + "schemas": { + "ListLogDrainsResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["log_drain"], + "description": "Resource type." + }, + "id": { + "type": "string" + }, + "attributes": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "config": { + "oneOf": [ + { + "type": "object", + "properties": { + "url": { + "type": "string", + "nullable": true + }, + "schema": { + "type": "string" + }, + "username": { + "type": "string", + "nullable": true + }, + "password": { + "type": "string", + "nullable": true + }, + "port": { + "type": "number", + "nullable": true + }, + "hostname": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "postgres" + }, + { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "http": { + "type": "string", + "enum": ["http1", "http2"] + }, + "gzip": { + "type": "boolean" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "additionalProperties": false, + "title": "webhook" + }, + { + "type": "object", + "properties": { + "project_id": { + "type": "string" + }, + "dataset_id": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "bigquery" + }, + { + "type": "object", + "properties": { + "api_key": { + "type": "string" + }, + "region": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "datadog" + }, + { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "username": { + "type": "string", + "nullable": true + }, + "password": { + "type": "string", + "nullable": true + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "additionalProperties": false, + "title": "loki" + }, + { + "type": "object", + "properties": { + "dsn": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "sentry" + }, + { + "type": "object", + "properties": { + "domain": { + "type": "string" + }, + "api_token": { + "type": "string" + }, + "dataset_name": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "axiom" + }, + { + "type": "object", + "properties": { + "host": { + "type": "string" + }, + "port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "tls": { + "default": false, + "type": "boolean" + }, + "structured_data": { + "type": "string" + }, + "cipher_key": { + "type": "string" + }, + "ca_cert": { + "type": "string" + }, + "client_cert": { + "type": "string" + }, + "client_key": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "syslog" + } + ] + }, + "backend_type": { + "type": "string", + "enum": [ + "postgres", + "bigquery", + "clickhouse", + "webhook", + "datadog", + "loki", + "sentry", + "s3", + "axiom", + "last9", + "otlp", + "syslog" + ] + } + }, + "required": ["name", "config", "backend_type"] + } + }, + "required": ["type", "id", "attributes"] + } + } + }, + "required": ["data"] + }, + "CreateLogDrainRequestOpenApi": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["log_drain"], + "description": "Resource type." + }, + "attributes": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "config": { + "oneOf": [ + { + "type": "object", + "properties": { + "url": { + "type": "string", + "nullable": true + }, + "schema": { + "type": "string" + }, + "username": { + "type": "string", + "nullable": true + }, + "password": { + "type": "string", + "nullable": true + }, + "port": { + "type": "number", + "nullable": true + }, + "hostname": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "postgres" + }, + { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "http": { + "type": "string", + "enum": ["http1", "http2"] + }, + "gzip": { + "type": "boolean" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "additionalProperties": false, + "title": "webhook" + }, + { + "type": "object", + "properties": { + "project_id": { + "type": "string" + }, + "dataset_id": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "bigquery" + }, + { + "type": "object", + "properties": { + "api_key": { + "type": "string" + }, + "region": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "datadog" + }, + { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "username": { + "type": "string", + "nullable": true + }, + "password": { + "type": "string", + "nullable": true + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "additionalProperties": false, + "title": "loki" + }, + { + "type": "object", + "properties": { + "dsn": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "sentry" + }, + { + "type": "object", + "properties": { + "domain": { + "type": "string" + }, + "api_token": { + "type": "string" + }, + "dataset_name": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "axiom" + }, + { + "type": "object", + "properties": { + "host": { + "type": "string" + }, + "port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "tls": { + "default": false, + "type": "boolean" + }, + "structured_data": { + "type": "string" + }, + "cipher_key": { + "type": "string" + }, + "ca_cert": { + "type": "string" + }, + "client_cert": { + "type": "string" + }, + "client_key": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "syslog" + } + ] + }, + "backend_type": { + "type": "string", + "enum": [ + "postgres", + "bigquery", + "clickhouse", + "webhook", + "datadog", + "loki", + "sentry", + "s3", + "axiom", + "last9", + "otlp", + "syslog" + ] + } + }, + "required": ["name", "config", "backend_type"] + } + }, + "required": ["type", "attributes"] + } + }, + "required": ["data"] + }, + "LogDrainResponse": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["log_drain"], + "description": "Resource type." + }, + "id": { + "type": "string" + }, + "attributes": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "config": { + "oneOf": [ + { + "type": "object", + "properties": { + "url": { + "type": "string", + "nullable": true + }, + "schema": { + "type": "string" + }, + "username": { + "type": "string", + "nullable": true + }, + "password": { + "type": "string", + "nullable": true + }, + "port": { + "type": "number", + "nullable": true + }, + "hostname": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "postgres" + }, + { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "http": { + "type": "string", + "enum": ["http1", "http2"] + }, + "gzip": { + "type": "boolean" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "additionalProperties": false, + "title": "webhook" + }, + { + "type": "object", + "properties": { + "project_id": { + "type": "string" + }, + "dataset_id": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "bigquery" + }, + { + "type": "object", + "properties": { + "api_key": { + "type": "string" + }, + "region": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "datadog" + }, + { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "username": { + "type": "string", + "nullable": true + }, + "password": { + "type": "string", + "nullable": true + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "additionalProperties": false, + "title": "loki" + }, + { + "type": "object", + "properties": { + "dsn": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "sentry" + }, + { + "type": "object", + "properties": { + "domain": { + "type": "string" + }, + "api_token": { + "type": "string" + }, + "dataset_name": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "axiom" + }, + { + "type": "object", + "properties": { + "host": { + "type": "string" + }, + "port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "tls": { + "default": false, + "type": "boolean" + }, + "structured_data": { + "type": "string" + }, + "cipher_key": { + "type": "string" + }, + "ca_cert": { + "type": "string" + }, + "client_cert": { + "type": "string" + }, + "client_key": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "syslog" + } + ] + }, + "backend_type": { + "type": "string", + "enum": [ + "postgres", + "bigquery", + "clickhouse", + "webhook", + "datadog", + "loki", + "sentry", + "s3", + "axiom", + "last9", + "otlp", + "syslog" + ] + } + }, + "required": ["name", "config", "backend_type"] + } + }, + "required": ["type", "id", "attributes"] + } + }, + "required": ["data"] + }, + "UpdateLogDrainRequestOpenApi": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["log_drain"], + "description": "Resource type." + }, + "attributes": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "config": { + "oneOf": [ + { + "type": "object", + "properties": { + "url": { + "type": "string", + "nullable": true + }, + "schema": { + "type": "string" + }, + "username": { + "type": "string", + "nullable": true + }, + "password": { + "type": "string", + "nullable": true + }, + "port": { + "type": "number", + "nullable": true + }, + "hostname": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "postgres" + }, + { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "http": { + "type": "string", + "enum": ["http1", "http2"] + }, + "gzip": { + "type": "boolean" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "additionalProperties": false, + "title": "webhook" + }, + { + "type": "object", + "properties": { + "project_id": { + "type": "string" + }, + "dataset_id": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "bigquery" + }, + { + "type": "object", + "properties": { + "api_key": { + "type": "string" + }, + "region": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "datadog" + }, + { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "username": { + "type": "string", + "nullable": true + }, + "password": { + "type": "string", + "nullable": true + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "additionalProperties": false, + "title": "loki" + }, + { + "type": "object", + "properties": { + "dsn": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "sentry" + }, + { + "type": "object", + "properties": { + "domain": { + "type": "string" + }, + "api_token": { + "type": "string" + }, + "dataset_name": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "axiom" + }, + { + "type": "object", + "properties": { + "host": { + "type": "string" + }, + "port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "tls": { + "default": false, + "type": "boolean" + }, + "structured_data": { + "type": "string" + }, + "cipher_key": { + "type": "string" + }, + "ca_cert": { + "type": "string" + }, + "client_cert": { + "type": "string" + }, + "client_key": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "syslog" + } + ] + }, + "backend_type": { + "type": "string", + "enum": [ + "postgres", + "bigquery", + "clickhouse", + "webhook", + "datadog", + "loki", + "sentry", + "s3", + "axiom", + "last9", + "otlp", + "syslog" + ] + } + }, + "required": ["backend_type"] + } + }, + "required": ["type", "attributes"] + } + }, + "required": ["data"] + }, + "V2TransferProjectBody": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["project_transfer_input"], + "description": "Resource type." + }, + "attributes": { + "type": "object", + "properties": { + "target_organization_slug": { + "type": "string" + } + }, + "required": ["target_organization_slug"] + } + }, + "required": ["type", "attributes"] + } + }, + "required": ["data"] + }, + "V2PreviewProjectTransferResponse": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["project_transfer_result"], + "description": "Resource type." + }, + "attributes": { + "type": "object", + "properties": { + "valid": { + "type": "boolean" + }, + "warnings": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["key", "message"] + } + }, + "errors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["key", "message"] + } + }, + "info": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["key", "message"] + } + } + }, + "required": ["valid", "warnings", "errors", "info"] + } + }, + "required": ["type", "attributes"] + } + }, + "required": ["data"] + } + } + } +} diff --git a/apps/studio/components/grid/components/grid/Grid.tsx b/apps/studio/components/grid/components/grid/Grid.tsx index 50f38de5c2a..f4d9f757a00 100644 --- a/apps/studio/components/grid/components/grid/Grid.tsx +++ b/apps/studio/components/grid/components/grid/Grid.tsx @@ -401,7 +401,11 @@ export const Grid = memo( )} - import('./OAuthEndpointsTable').then((mod) => ({ default: mod.OAuthEndpointsTable })) -) - const configUrlSchema = z.object({ id: z.string(), name: z.string(), diff --git a/apps/studio/components/interfaces/Integrations/Landing/useAvailableIntegrations.tsx b/apps/studio/components/interfaces/Integrations/Landing/useAvailableIntegrations.tsx index 82d1a407f7f..581c4cf3e15 100644 --- a/apps/studio/components/interfaces/Integrations/Landing/useAvailableIntegrations.tsx +++ b/apps/studio/components/interfaces/Integrations/Landing/useAvailableIntegrations.tsx @@ -81,7 +81,7 @@ export const useAvailableIntegrations = () => { edge_function_secret_name: edgeFunctionSecretName, images, content, - partner_name: authorName, + built_by: authorName, listing_logo: listingLogo, } = integration @@ -175,7 +175,7 @@ export const useAvailableIntegrations = () => { documentation_url: docsUrl, website_url: siteUrl, images, - partner_name: authorName, + built_by: authorName, listing_logo: listingLogo, } = marketplaceWrapper diff --git a/apps/studio/components/interfaces/Integrations/Wrappers/Wrappers.constants.ts b/apps/studio/components/interfaces/Integrations/Wrappers/Wrappers.constants.ts index 30146f8a57c..3c3a48f045c 100644 --- a/apps/studio/components/interfaces/Integrations/Wrappers/Wrappers.constants.ts +++ b/apps/studio/components/interfaces/Integrations/Wrappers/Wrappers.constants.ts @@ -1427,7 +1427,7 @@ export const WRAPPERS: WrapperMeta[] = [ description: 'Cloud storage service for high-dimensional vectors', extensionName: 'S3VectorsFdw', label: 'S3 Vectors', - docsUrl: `${DOCS_URL}/guides/database/extensions/wrappers/s3-vectors`, + docsUrl: `${DOCS_URL}/guides/database/extensions/wrappers/s3_vectors`, categories: ['ai_vectors', 'storage'], minimumExtensionVersion: '0.5.6', server: { diff --git a/apps/studio/components/interfaces/Settings/Logs/LogColumnRenderers/MultigresColumnRender.tsx b/apps/studio/components/interfaces/Settings/Logs/LogColumnRenderers/MultigresColumnRender.tsx new file mode 100644 index 00000000000..71022e17b06 --- /dev/null +++ b/apps/studio/components/interfaces/Settings/Logs/LogColumnRenderers/MultigresColumnRender.tsx @@ -0,0 +1,34 @@ +import { Column } from 'react-data-grid' +import { TimestampInfo } from 'ui-patterns/TimestampInfo' + +import type { LogData } from '../Logs.types' +import { parseMultigresEventMessage } from '../Logs.utils' +import { RowLayout, SeverityFormatter, TextFormatter } from '../LogsFormatters' +import { defaultRenderCell } from './DefaultPreviewColumnRenderer' + +const columns: Column[] = [ + { + name: 'multigres-first-column', + key: 'multigres-first-column', + renderHeaderCell: () => null, + renderCell: (props) => { + const parsed = parseMultigresEventMessage(props.row.event_message) + const level = typeof parsed?.level === 'string' ? parsed.level : undefined + const msg = typeof parsed?.msg === 'string' ? parsed.msg : undefined + + if (!level && !msg) { + return defaultRenderCell(props) + } + + return ( + + {props.row.timestamp && } + {level && } + + + ) + }, + }, +] + +export default columns diff --git a/apps/studio/components/interfaces/Settings/Logs/LogSelection.tsx b/apps/studio/components/interfaces/Settings/Logs/LogSelection.tsx index db9895400c8..d53c652d3f7 100644 --- a/apps/studio/components/interfaces/Settings/Logs/LogSelection.tsx +++ b/apps/studio/components/interfaces/Settings/Logs/LogSelection.tsx @@ -12,8 +12,8 @@ import { import { CodeBlock } from 'ui-patterns/CodeBlock' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' -import type { LogData, QueryType } from './Logs.types' -import { apiKey, role as extractRole, jwtAPIKey } from './Logs.utils' +import type { LogData, PreviewLogData, QueryType } from './Logs.types' +import { apiKey, role as extractRole, jwtAPIKey, parseMultigresEventMessage } from './Logs.utils' import DefaultPreviewSelectionRenderer from './LogSelectionRenderers/DefaultPreviewSelectionRenderer' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' @@ -70,6 +70,16 @@ const LogSelection = ({ log, onClose, queryType, isLoading, error }: LogSelectio return + case 'multigres': { + const parsedMultigresMessage = parseMultigresEventMessage(log.event_message) + // Spread the log last so its canonical fields (id, timestamp, event_message) + // always win over any same-named keys inside the parsed event_message. + const multigresLog = ( + parsedMultigresMessage ? { ...parsedMultigresMessage, ...log } : log + ) as PreviewLogData + return + } + case 'database': const hint = log?.metadata?.[0]?.parsed?.[0]?.hint const detail = log?.metadata?.[0]?.parsed?.[0]?.detail diff --git a/apps/studio/components/interfaces/Settings/Logs/LogTable.tsx b/apps/studio/components/interfaces/Settings/Logs/LogTable.tsx index d09662119bf..4052f11372d 100644 --- a/apps/studio/components/interfaces/Settings/Logs/LogTable.tsx +++ b/apps/studio/components/interfaces/Settings/Logs/LogTable.tsx @@ -24,6 +24,7 @@ import DatabasePostgresColumnRender from './LogColumnRenderers/DatabasePostgresC import DefaultPreviewColumnRenderer from './LogColumnRenderers/DefaultPreviewColumnRenderer' import FunctionsEdgeColumnRender from './LogColumnRenderers/FunctionsEdgeColumnRender' import FunctionsLogsColumnRender from './LogColumnRenderers/FunctionsLogsColumnRender' +import MultigresColumnRender from './LogColumnRenderers/MultigresColumnRender' import type { LogData, LogQueryError, QueryType } from './Logs.types' import { formatLogsAsCsv, @@ -265,6 +266,9 @@ export const LogTable = ({ case 'pg_cron': columns = DatabasePostgresColumnRender break + case 'multigres': + columns = MultigresColumnRender + break default: if (firstRow && isDefaultLogPreviewFormat(firstRow)) { columns = DefaultPreviewColumnRenderer diff --git a/apps/studio/components/interfaces/Settings/Logs/Logs.constants.ts b/apps/studio/components/interfaces/Settings/Logs/Logs.constants.ts index 5cf09e190c3..4a00919e14b 100644 --- a/apps/studio/components/interfaces/Settings/Logs/Logs.constants.ts +++ b/apps/studio/components/interfaces/Settings/Logs/Logs.constants.ts @@ -396,6 +396,7 @@ export enum LogsTableName { PG_UPGRADE = 'pg_upgrade_logs', PG_CRON = 'pg_cron_logs', ETL = 'etl_replication_logs', + MULTIGRES = 'multigres_logs', } export const LOGS_TABLES = { @@ -412,6 +413,7 @@ export const LOGS_TABLES = { pg_cron: LogsTableName.POSTGRES, pgbouncer: LogsTableName.PGBOUNCER, etl: LogsTableName.ETL, + multigres: LogsTableName.MULTIGRES, } export const LOGS_SOURCE_DESCRIPTION = { @@ -429,6 +431,7 @@ export const LOGS_SOURCE_DESCRIPTION = { [LogsTableName.PG_UPGRADE]: 'Logs generated by the Postgres version upgrade process', [LogsTableName.PG_CRON]: 'Postgres logs from pg_cron cron jobs', [LogsTableName.ETL]: 'Logs from the replication process', + [LogsTableName.MULTIGRES]: 'Logs from the Multigres high availability service', } export const FILTER_OPTIONS: FilterTableSet = { diff --git a/apps/studio/components/interfaces/Settings/Logs/Logs.types.ts b/apps/studio/components/interfaces/Settings/Logs/Logs.types.ts index 1ce297b8aaa..f29dc87a703 100644 --- a/apps/studio/components/interfaces/Settings/Logs/Logs.types.ts +++ b/apps/studio/components/interfaces/Settings/Logs/Logs.types.ts @@ -96,6 +96,7 @@ export type QueryType = | 'pg_cron' | 'pgbouncer' | 'etl' + | 'multigres' export type Mode = 'simple' | 'custom' diff --git a/apps/studio/components/interfaces/Settings/Logs/Logs.utils.test.ts b/apps/studio/components/interfaces/Settings/Logs/Logs.utils.test.ts index 8023a487d1f..5022103693b 100644 --- a/apps/studio/components/interfaces/Settings/Logs/Logs.utils.test.ts +++ b/apps/studio/components/interfaces/Settings/Logs/Logs.utils.test.ts @@ -7,6 +7,7 @@ import { formatLogsAsCsv, formatLogsAsJson, formatLogsAsMarkdown, + parseMultigresEventMessage, } from './Logs.utils' const createLog = (overrides: Partial = {}): LogData => ({ @@ -182,4 +183,40 @@ describe('Logs.utils', () => { expect(extractEdgeFunctionName('/functions/v1/hello-world-1/')).toBe('hello-world-1') }) }) + + describe('parseMultigresEventMessage', () => { + test('parses a JSON object event_message into a plain object', () => { + const eventMessage = JSON.stringify({ + time: '2026-06-02T15:44:52.84043038Z', + level: 'ERROR', + msg: 'Failed to write heartbeat', + error: 'context deadline exceeded', + }) + expect(parseMultigresEventMessage(eventMessage)).toEqual({ + time: '2026-06-02T15:44:52.84043038Z', + level: 'ERROR', + msg: 'Failed to write heartbeat', + error: 'context deadline exceeded', + }) + }) + + test('returns null when event_message is not valid JSON', () => { + expect(parseMultigresEventMessage('connection closed')).toBeNull() + }) + + test('returns null when event_message parses to an array', () => { + expect(parseMultigresEventMessage('[1, 2, 3]')).toBeNull() + }) + + test('returns null when event_message parses to a primitive', () => { + expect(parseMultigresEventMessage('42')).toBeNull() + expect(parseMultigresEventMessage('"a string"')).toBeNull() + }) + + test('returns null for non-string input', () => { + expect(parseMultigresEventMessage(undefined)).toBeNull() + expect(parseMultigresEventMessage(null)).toBeNull() + expect(parseMultigresEventMessage(42)).toBeNull() + }) + }) }) diff --git a/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts b/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts index 0636cd65fbf..4c9a90e720a 100644 --- a/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts +++ b/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts @@ -303,13 +303,34 @@ export const LOG_TABLE_SQL: Record = { [LogsTableName.PG_UPGRADE]: safeSql`pg_upgrade_logs`, [LogsTableName.PG_CRON]: safeSql`pg_cron_logs`, [LogsTableName.ETL]: safeSql`etl_replication_logs`, + [LogsTableName.MULTIGRES]: safeSql`multigres_logs`, } /** * SQL query to retrieve only one log */ -export const genSingleLogQuery = (table: LogsTableName, id: string): SafeLogSqlFragment => - safeSql`select id, timestamp, event_message, metadata from ${LOG_TABLE_SQL[table]} where id = ${analyticsLiteral(id)} limit 1` +export const genSingleLogQuery = (table: LogsTableName, id: string): SafeLogSqlFragment => { + // multigres logs have no metadata column + const metadataColumn = table === LogsTableName.MULTIGRES ? safeSql`` : safeSql`, metadata` + return safeSql`select id, timestamp, event_message${metadataColumn} from ${LOG_TABLE_SQL[table]} where id = ${analyticsLiteral(id)} limit 1` +} + +/** + * Multigres logs store their structured payload as a JSON string in + * `event_message`. Parse it into a plain object, returning `null` when the + * value is missing, not valid JSON, or not a plain object (e.g. an array). + */ +export const parseMultigresEventMessage = ( + eventMessage: unknown +): Record | null => { + if (typeof eventMessage !== 'string') return null + try { + const parsed = JSON.parse(eventMessage) + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null + } catch { + return null + } +} /** * Determine if we should show the user an upgrade prompt while browsing logs @@ -699,6 +720,8 @@ function getErrorCondition(table: LogsTableName): SafeLogSqlFragment { return safeSql`metadata.level IN ('error', 'fatal')` case 'pg_cron_logs': return safeSql`parsed.error_severity IN ('ERROR', 'FATAL', 'PANIC')` + case 'multigres_logs': + return safeSql`JSON_VALUE(event_message, '$.level') IN ('ERROR', 'FATAL', 'PANIC')` default: return safeSql`false` } @@ -716,6 +739,8 @@ function getWarningCondition(table: LogsTableName): SafeLogSqlFragment { return safeSql`response.status_code >= 400 AND response.status_code < 500` case 'function_logs': return safeSql`metadata.level IN ('warning')` + case 'multigres_logs': + return safeSql`JSON_VALUE(event_message, '$.level') IN ('WARN', 'WARNING')` default: return safeSql`false` } @@ -830,6 +855,7 @@ const QUERY_TYPE_LABELS: Record = { pg_cron: 'pg_cron', pgbouncer: 'PgBouncer', etl: 'ETL', + multigres: 'Multigres', } const LOG_TABLE_TO_SERVICE_LABEL: Record = { @@ -847,6 +873,7 @@ const LOG_TABLE_TO_SERVICE_LABEL: Record = { pg_upgrade_logs: 'Postgres upgrade', pg_cron_logs: 'pg_cron', etl_replication_logs: 'ETL', + multigres_logs: 'Multigres', } const isLogsTableName = (value: string): value is LogsTableName => diff --git a/apps/studio/components/interfaces/Settings/Logs/LogsFormatters.tsx b/apps/studio/components/interfaces/Settings/Logs/LogsFormatters.tsx index 0f1f279c725..cda950344e3 100644 --- a/apps/studio/components/interfaces/Settings/Logs/LogsFormatters.tsx +++ b/apps/studio/components/interfaces/Settings/Logs/LogsFormatters.tsx @@ -185,6 +185,7 @@ export const SeverityFormatter = ({ ) break + case 'WARN': case 'WARNING': return ( diff --git a/apps/studio/components/interfaces/Settings/Logs/LogsQueryPanel.tsx b/apps/studio/components/interfaces/Settings/Logs/LogsQueryPanel.tsx index 61492a08984..335fa7d4916 100644 --- a/apps/studio/components/interfaces/Settings/Logs/LogsQueryPanel.tsx +++ b/apps/studio/components/interfaces/Settings/Logs/LogsQueryPanel.tsx @@ -38,6 +38,7 @@ import { DatePickerValue, LogsDatePicker } from './Logs.DatePickers' import { LogsWarning, LogTemplate } from './Logs.types' import Table from '@/components/to-be-cleaned/Table' import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' +import { useShowMultigresLogs } from '@/hooks/misc/useShowMultigresLogs' import { DOCS_URL } from '@/lib/constants' export interface LogsQueryPanelProps { @@ -98,7 +99,13 @@ const LogsQueryPanel = ({ }, [value.from, value.to, value.text, value.isHelper]) const [open, setOpen] = useState(false) - const [selectedSchema, setSelectedSchema] = useState(logConstants.schemas[0]) + + const showMultigresLogs = useShowMultigresLogs() + const schemas = logConstants.schemas.filter( + (schema) => schema.reference !== 'multigres_logs' || showMultigresLogs + ) + + const [selectedSchema, setSelectedSchema] = useState(schemas[0]) return (
@@ -285,7 +292,7 @@ const LogsQueryPanel = ({ No source found. - {logConstants.schemas.map((schema) => ( + {schemas.map((schema) => ( + Learn more . diff --git a/apps/studio/components/layouts/LogsLayout/LogsSidebarMenuV2.tsx b/apps/studio/components/layouts/LogsLayout/LogsSidebarMenuV2.tsx index b152f6abb09..b2daa4da0f3 100644 --- a/apps/studio/components/layouts/LogsLayout/LogsSidebarMenuV2.tsx +++ b/apps/studio/components/layouts/LogsLayout/LogsSidebarMenuV2.tsx @@ -34,6 +34,7 @@ import { useContentQuery } from '@/data/content/content-query' import { useReplicationSourcesQuery } from '@/data/replication/sources-query' import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements' import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' +import { useShowMultigresLogs } from '@/hooks/misc/useShowMultigresLogs' export function SidebarCollapsible({ children, @@ -100,6 +101,7 @@ export function LogsSidebarMenuV2() { const showETLLogs = enablePgReplicate && (etlData?.sources?.length ?? 0) > 0 && !isETLLoading const { hasAccess: hasDedicatedPooler } = useCheckEntitlements('dedicated_pooler') + const showMultigresLogs = useShowMultigresLogs() const { data: savedQueriesRes, isPending: savedQueriesLoading } = useContentQuery({ projectRef: ref, @@ -193,6 +195,14 @@ export function LogsSidebarMenuV2() { items: [], } : null, + showMultigresLogs + ? { + name: 'Multigres', + key: 'multigres-logs', + url: `/project/${ref}/logs/multigres-logs`, + items: [], + } + : null, ].filter((x) => x !== null) const OPERATIONAL_COLLECTIONS = IS_PLATFORM diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx index acfc57dcc52..35c7021f9cb 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx @@ -176,11 +176,6 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { [privateSnippetsTreeState] ) - const validExpandedFolderIds = useMemo( - () => expandedFolderIds.filter((id) => privateSnippetsTreeNodeIds.has(id)), - [expandedFolderIds, privateSnippetsTreeNodeIds] - ) - const privateSnippetsLastItemIds = useMemo( () => getLastItemIds(privateSnippetsTreeState), [privateSnippetsTreeState] @@ -407,7 +402,11 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { } else if (snippet.visibility === 'user') { setSectionVisibility({ ...sectionVisibility, private: true }) } - if (snippet.folder_id && !expandedFolderIds.includes(snippet.folder_id)) { + if ( + snippet.folder_id && + !expandedFolderIds.includes(snippet.folder_id) && + privateSnippetsTreeNodeIds.has(snippet.folder_id) + ) { setExpandedFolderIds([...expandedFolderIds, snippet.folder_id]) } } @@ -654,7 +653,7 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { setExpandedFolderIds(expandedFolderIds.filter((x) => x !== folderId)) } }} - expandedIds={validExpandedFolderIds} + expandedIds={expandedFolderIds} nodeRenderer={({ element, ...props }) => { const isOpened = Object.values(tabs.tabsMap).some( (tab) => tab.metadata?.sqlId === element.metadata?.id diff --git a/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.tsx b/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.tsx index fde0e64638f..7622d057825 100644 --- a/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.tsx +++ b/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.tsx @@ -15,6 +15,7 @@ import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/L import { useProjectLintsQuery } from '@/data/lint/lint-query' import { Notification, useNotificationsV2Query } from '@/data/notifications/notifications-v2-query' import { useNotificationsV2UpdateMutation } from '@/data/notifications/notifications-v2-update-mutation' +import { useProjectsInfiniteQuery } from '@/data/projects/projects-infinite-query' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { IS_PLATFORM } from '@/lib/constants' import { useTrack } from '@/lib/telemetry/track' @@ -95,6 +96,18 @@ export const AdvisorPanel = () => { return notificationsData?.pages.flatMap((page) => page) ?? [] }, [notificationsData?.pages]) + const { data: projectsData } = useProjectsInfiniteQuery({}, { enabled: shouldLoadNotifications }) + + const projectNameByRef = useMemo(() => { + const map = new Map() + projectsData?.pages.forEach((page) => { + page.projects.forEach((project) => { + if (project.ref) map.set(project.ref, project.name) + }) + }) + return map + }, [projectsData?.pages]) + const markNotificationsRead = () => { if (markedRead.current.length > 0) { updateNotifications({ ids: markedRead.current, status: 'seen' }) @@ -282,6 +295,7 @@ export const AdvisorPanel = () => { hiddenItemsCount={hiddenItemsCount} hasAnyFilters={hasAnyFilters} hasProjectRef={hasProjectRef} + projectNameByRef={projectNameByRef} />
diff --git a/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.types.ts b/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.types.ts index c9281727c77..17f8fd43ea2 100644 --- a/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.types.ts +++ b/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.types.ts @@ -26,6 +26,7 @@ export type AdvisorLintItem = AdvisorBaseItem & { export type AdvisorNotificationItem = AdvisorBaseItem & { source: 'notification' original: Notification + project_ref?: string } export type AdvisorSignalItem = AdvisorBaseItem & { diff --git a/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.utils.test.ts b/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.utils.test.ts index d9e9eb5eff3..23366adc4ec 100644 --- a/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.utils.test.ts +++ b/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.utils.test.ts @@ -71,4 +71,44 @@ describe('AdvisorPanel.utils', () => { const bannedIpSignal = createBannedIPSignalItem('203.0.113.10') expect(getAdvisorItemSecondaryText(bannedIpSignal)).toBe('Database · 203.0.113.10') }) + + describe('notification secondary text', () => { + const [notificationWithProject] = createAdvisorNotificationItems([ + createNotification({ + id: 'notification-with-project', + data: { + title: 'CPU usage is high on my-project.', + message: 'Project my-project has high CPU usage.', + project_ref: 'abcd1234', + actions: [], + }, + }), + ]) + + const [notificationWithoutProject] = createAdvisorNotificationItems([ + createNotification({ + id: 'notification-without-project', + data: { title: 'Generic notification', message: 'Body', actions: [] }, + }), + ]) + + it('returns the resolved project name when available in the map', () => { + const projectNameByRef = new Map([['abcd1234', 'my-production-db']]) + expect(getAdvisorItemSecondaryText(notificationWithProject, projectNameByRef)).toBe( + 'my-production-db' + ) + }) + + it('falls back to the project ref when the name is missing from the map', () => { + expect(getAdvisorItemSecondaryText(notificationWithProject, new Map())).toBe('abcd1234') + }) + + it('falls back to the project ref when no map is provided', () => { + expect(getAdvisorItemSecondaryText(notificationWithProject)).toBe('abcd1234') + }) + + it('returns undefined for notifications without a project_ref', () => { + expect(getAdvisorItemSecondaryText(notificationWithoutProject)).toBeUndefined() + }) + }) }) diff --git a/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.utils.ts b/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.utils.ts index dc0f8a9a765..9ee683d0e20 100644 --- a/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.utils.ts +++ b/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.utils.ts @@ -83,6 +83,7 @@ export const createAdvisorNotificationItems = ( tab: 'messages' as const, source: 'notification' as const, original: notification, + project_ref: data.project_ref, } }) } @@ -129,7 +130,10 @@ export const getAdvisorPanelItemDisplayTitle = (item: AdvisorItem): string => { return getAdvisorItemDisplayTitle(item) } -export const getAdvisorItemSecondaryText = (item: AdvisorItem): string | undefined => { +export const getAdvisorItemSecondaryText = ( + item: AdvisorItem, + projectNameByRef?: ReadonlyMap +): string | undefined => { if (item.source === 'lint') { return getLintEntityString(item.original) } @@ -138,6 +142,11 @@ export const getAdvisorItemSecondaryText = (item: AdvisorItem): string | undefin return `Database · ${item.sourceData.ip}` } + if (item.source === 'notification') { + if (!item.project_ref) return undefined + return projectNameByRef?.get(item.project_ref) ?? item.project_ref + } + return undefined } diff --git a/apps/studio/components/ui/AdvisorPanel/AdvisorPanelBody.tsx b/apps/studio/components/ui/AdvisorPanel/AdvisorPanelBody.tsx index dda95a37fa0..af5efd141a9 100644 --- a/apps/studio/components/ui/AdvisorPanel/AdvisorPanelBody.tsx +++ b/apps/studio/components/ui/AdvisorPanel/AdvisorPanelBody.tsx @@ -41,6 +41,7 @@ interface AdvisorPanelBodyProps { hiddenItemsCount: number hasAnyFilters: boolean hasProjectRef?: boolean + projectNameByRef?: ReadonlyMap } export const AdvisorPanelBody = ({ @@ -54,6 +55,7 @@ export const AdvisorPanelBody = ({ hiddenItemsCount, hasAnyFilters, hasProjectRef = true, + projectNameByRef, }: AdvisorPanelBodyProps) => { // Show notice if no project ref and trying to view project-specific tabs if (!hasProjectRef && activeTab !== 'messages' && activeTab !== 'all') { @@ -101,7 +103,7 @@ export const AdvisorPanelBody = ({ const isUnread = notification?.status === 'new' const primaryText = getAdvisorPanelItemDisplayTitle(item) - const secondaryText = getAdvisorItemSecondaryText(item) + const secondaryText = getAdvisorItemSecondaryText(item, projectNameByRef) const metadataText = secondaryText ?? (item.createdAt ? formatItemDate(item.createdAt) : undefined) // Date strings (e.g. "a few seconds ago") come from formatItemDate and diff --git a/apps/studio/components/ui/AdvisorPanel/NotificationDetail.tsx b/apps/studio/components/ui/AdvisorPanel/NotificationDetail.tsx index b7e6606a163..acae061fdd4 100644 --- a/apps/studio/components/ui/AdvisorPanel/NotificationDetail.tsx +++ b/apps/studio/components/ui/AdvisorPanel/NotificationDetail.tsx @@ -70,9 +70,9 @@ export const NotificationDetail = ({ notification, onUpdateStatus }: Notificatio const key = `${notification.id}-action-${idx}` if (action.url !== undefined) { const url = action.url.includes('[ref]') - ? action.url.replace('[ref]', project?.ref ?? '_') + ? action.url.replace('[ref]', project?.ref ?? data.project_ref ?? '_') : action.url.includes('[slug]') - ? action.url.replace('[slug]', organization?.slug ?? '_') + ? action.url.replace('[slug]', organization?.slug ?? data.org_slug ?? '_') : action.url return (