diff --git a/apps/docs/app/guides/ai/python/[slug]/page.tsx b/apps/docs/app/guides/ai/python/[slug]/page.tsx index b65b95f86a186..e31bc6b159546 100644 --- a/apps/docs/app/guides/ai/python/[slug]/page.tsx +++ b/apps/docs/app/guides/ai/python/[slug]/page.tsx @@ -4,7 +4,7 @@ import rehypeSlug from 'rehype-slug' import { GuideTemplate, newEditLink } from '~/features/docs/GuidesMdx.template' import { genGuideMeta, removeRedundantH1 } from '~/features/docs/GuidesMdx.utils' -import { fetchRevalidatePerDay } from '~/features/helpers.fetch' +import { getGitHubFileContents } from '~/lib/octokit' import { UrlTransformFunction, linkTransform } from '~/lib/mdx/plugins/rehypeLinkTransform' import remarkMkDocsAdmonition from '~/lib/mdx/plugins/remarkAdmonition' import { removeTitle } from '~/lib/mdx/plugins/remarkRemoveTitle' @@ -83,23 +83,9 @@ const getContent = async ({ slug }: Params) => { const editLink = newEditLink(`${org}/${repo}/blob/${branch}/${docsDir}/${remoteFile}`) - let response: Response - try { - response = await fetchRevalidatePerDay( - `https://raw.githubusercontent.com/${org}/${repo}/${branch}/${docsDir}/${remoteFile}` - ) - } catch (err) { - throw new Error(`Failed to fetch Python vecs docs from GitHub (network error): ${err}`) - } - - if (!response.ok) { - throw new Error( - `Failed to fetch Python vecs docs from GitHub: ${response.status} ${response.statusText}` - ) - } - - let content = await response.text() - content = removeRedundantH1(content) + const content = removeRedundantH1( + await getGitHubFileContents({ org, repo, path: `${docsDir}/${remoteFile}`, branch }) + ) return { pathname: `/guides/ai/python/${slug}` satisfies `/${string}`, diff --git a/apps/docs/app/guides/database/database-advisors/page.tsx b/apps/docs/app/guides/database/database-advisors/page.tsx index aedf3e3396b8d..630c7c3ee5922 100644 --- a/apps/docs/app/guides/database/database-advisors/page.tsx +++ b/apps/docs/app/guides/database/database-advisors/page.tsx @@ -1,4 +1,3 @@ -import { Octokit } from '@octokit/core' import { capitalize } from 'lodash-es' import rehypeSlug from 'rehype-slug' import { Heading } from 'ui' @@ -7,7 +6,7 @@ import { Admonition } from 'ui-patterns' import { GuideTemplate, newEditLink } from '~/features/docs/GuidesMdx.template' import { genGuideMeta } from '~/features/docs/GuidesMdx.utils' import { MDXRemoteBase } from '~/features/docs/MdxBase' -import { fetchRevalidatePerDay } from '~/features/helpers.fetch' +import { getGitHubFileContents, octokit } from '~/lib/octokit' import { TabPanel, Tabs } from '~/features/ui/Tabs' import { UrlTransformFunction, linkTransform } from '~/lib/mdx/plugins/rehypeLinkTransform' import remarkMkDocsAdmonition from '~/lib/mdx/plugins/remarkAdmonition' @@ -144,24 +143,15 @@ const urlTransform: (lints: Array<{ path: string }>) => UrlTransformFunction = ( * Fetch lint remediation Markdown from external repo */ const getLints = async () => { - const octokit = new Octokit({ request: { fetch: fetchRevalidatePerDay } }) - - let response: Awaited> - try { - response = await octokit.request('GET /repos/{owner}/{repo}/contents/{path}', { - owner: org, - repo: repo, - path: docsDir, - ref: branch, - headers: { - 'X-GitHub-Api-Version': '2022-11-28', - }, - }) - } catch (err) { - throw new Error( - `Failed to fetch database advisors lint list from GitHub (network error): ${err}` - ) - } + const response = await octokit().request('GET /repos/{owner}/{repo}/contents/{path}', { + owner: org, + repo: repo, + path: docsDir, + ref: branch, + headers: { + 'X-GitHub-Api-Version': '2022-11-28', + }, + }) if (response.status >= 400) { throw new Error( @@ -179,24 +169,7 @@ const getLints = async () => { const lints = await Promise.all( lintsList.map(async ({ path }) => { - let fileResponse: Response - try { - fileResponse = await fetchRevalidatePerDay( - `https://raw.githubusercontent.com/${org}/${repo}/${branch}/${path}` - ) - } catch (err) { - throw new Error( - `Failed to fetch database advisors lint file ${path} from GitHub (network error): ${err}` - ) - } - - if (response.status >= 400) { - throw new Error( - `Failed to fetch ${org}/${repo}/${branch}/${path} docs from GitHub: ${response.status}` - ) - } - - const content = await fileResponse.text() + const content = await getGitHubFileContents({ org, repo, path, branch }) return { path: getBasename(path), diff --git a/apps/docs/app/guides/deployment/ci/[slug]/page.tsx b/apps/docs/app/guides/deployment/ci/[slug]/page.tsx index 9876928553ed8..76152dc0aa1ab 100644 --- a/apps/docs/app/guides/deployment/ci/[slug]/page.tsx +++ b/apps/docs/app/guides/deployment/ci/[slug]/page.tsx @@ -4,7 +4,7 @@ import rehypeSlug from 'rehype-slug' import { GuideTemplate, newEditLink } from '~/features/docs/GuidesMdx.template' import { genGuideMeta, removeRedundantH1 } from '~/features/docs/GuidesMdx.utils' -import { fetchRevalidatePerDay } from '~/features/helpers.fetch' +import { getGitHubFileContents } from '~/lib/octokit' import { UrlTransformFunction, linkTransform } from '~/lib/mdx/plugins/rehypeLinkTransform' import remarkMkDocsAdmonition from '~/lib/mdx/plugins/remarkAdmonition' import { removeTitle } from '~/lib/mdx/plugins/remarkRemoveTitle' @@ -82,22 +82,9 @@ const getContent = async ({ slug }: Params) => { const editLink = newEditLink(`${org}/${repo}/blob/${branch}/${docsDir}/${remoteFile}`) - let response: Response - try { - response = await fetchRevalidatePerDay( - `https://raw.githubusercontent.com/${org}/${repo}/${branch}/${docsDir}/${remoteFile}` - ) - } catch (err) { - throw new Error(`Failed to fetch CI docs from GitHub (network error): ${err}`) - } - - if (!response.ok) { - throw new Error( - `Failed to fetch CI docs from GitHub: ${response.status} ${response.statusText}` - ) - } - - const content = removeRedundantH1(await response.text()) + const content = removeRedundantH1( + await getGitHubFileContents({ org, repo, path: `${docsDir}/${remoteFile}`, branch }) + ) return { pathname: `/guides/cli/github-action/${slug}` satisfies `/${string}`, diff --git a/apps/docs/app/guides/deployment/terraform/[[...slug]]/page.tsx b/apps/docs/app/guides/deployment/terraform/[[...slug]]/page.tsx index 8fd187bfeb2be..c97957170c2df 100644 --- a/apps/docs/app/guides/deployment/terraform/[[...slug]]/page.tsx +++ b/apps/docs/app/guides/deployment/terraform/[[...slug]]/page.tsx @@ -4,7 +4,7 @@ import rehypeSlug from 'rehype-slug' import { GuideTemplate, newEditLink } from '~/features/docs/GuidesMdx.template' import { genGuideMeta, removeRedundantH1 } from '~/features/docs/GuidesMdx.utils' -import { fetchRevalidatePerDay } from '~/features/helpers.fetch' +import { getGitHubFileContents } from '~/lib/octokit' import { isValidGuideFrontmatter } from '~/lib/docs' import { UrlTransformFunction, linkTransform } from '~/lib/mdx/plugins/rehypeLinkTransform' import remarkMkDocsAdmonition from '~/lib/mdx/plugins/remarkAdmonition' @@ -115,22 +115,12 @@ const getContent = async ({ slug }: Params) => { `${terraformDocsOrg}/${terraformDocsRepo}/blob/${terraformDocsBranch}/${useRoot ? '' : `${terraformDocsDocsDir}/`}${remoteFile}` ) - let response: Response - try { - response = await fetchRevalidatePerDay( - `https://raw.githubusercontent.com/${terraformDocsOrg}/${terraformDocsRepo}/${terraformDocsBranch}/${useRoot ? '' : `${terraformDocsDocsDir}/`}${remoteFile}` - ) - } catch (err) { - throw new Error(`Failed to fetch Terraform docs from GitHub (network error): ${err}`) - } - - if (!response.ok) { - throw new Error( - `Failed to fetch Terraform docs from GitHub: ${response.status} ${response.statusText}` - ) - } - - let rawContent = await response.text() + let rawContent = await getGitHubFileContents({ + org: terraformDocsOrg, + repo: terraformDocsRepo, + path: useRoot ? remoteFile : `${terraformDocsDocsDir}/${remoteFile}`, + branch: terraformDocsBranch, + }) // Strip out HTML comments rawContent = rawContent.replace(//, '') let { content, data } = matter(rawContent) diff --git a/apps/docs/app/guides/deployment/terraform/reference/page.tsx b/apps/docs/app/guides/deployment/terraform/reference/page.tsx index ae9c9b66daab4..b8877c2e92a41 100644 --- a/apps/docs/app/guides/deployment/terraform/reference/page.tsx +++ b/apps/docs/app/guides/deployment/terraform/reference/page.tsx @@ -13,7 +13,7 @@ import { import { genGuideMeta } from '~/features/docs/GuidesMdx.utils' import { GuideTemplate, newEditLink } from '~/features/docs/GuidesMdx.template' -import { fetchRevalidatePerDay } from '~/features/helpers.fetch' +import { getGitHubFileContents } from '~/lib/octokit' import { TabPanel, Tabs } from '~/features/ui/Tabs' import { terraformDocsBranch, @@ -394,21 +394,14 @@ const TerraformReferencePage = async () => { * Fetch JSON schema from external repo */ const getSchema = async () => { - let response: Response - try { - response = await fetchRevalidatePerDay( - `https://raw.githubusercontent.com/${terraformDocsOrg}/${terraformDocsRepo}/${terraformDocsBranch}/${terraformDocsDocsDir}/schema.json` - ) - } catch (err) { - throw new Error(`Failed to fetch Terraform JSON schema from GitHub (network error): ${err}`) - } - - if (!response.ok) - throw Error( - `Failed to fetch Terraform JSON schema from GitHub: ${response.status} ${response.statusText}` - ) - - const schema = await response.json() + const schema = JSON.parse( + await getGitHubFileContents({ + org: terraformDocsOrg, + repo: terraformDocsRepo, + path: `${terraformDocsDocsDir}/schema.json`, + branch: terraformDocsBranch, + }) + ) return { schema, diff --git a/apps/docs/app/guides/getting-started/ai-skills/AiSkills.utils.ts b/apps/docs/app/guides/getting-started/ai-skills/AiSkills.utils.ts new file mode 100644 index 0000000000000..ca7f7ff1b675d --- /dev/null +++ b/apps/docs/app/guides/getting-started/ai-skills/AiSkills.utils.ts @@ -0,0 +1,61 @@ +import matter from 'gray-matter' +import { cache } from 'react' + +import { getGitHubFileContents, octokit } from '~/lib/octokit' + +const SKILLS_REPO = { + org: 'supabase', + repo: 'agent-skills', + branch: 'main', + path: 'skills', +} + +interface SkillMetadata { + name?: string + title?: string + description?: string +} + +interface SkillSummary { + name: string + description: string + installCommand: string +} + +async function getAiSkillsImpl(): Promise { + const { data: contents } = await octokit().request('GET /repos/{owner}/{repo}/contents/{path}', { + owner: SKILLS_REPO.org, + repo: SKILLS_REPO.repo, + path: SKILLS_REPO.path, + ref: SKILLS_REPO.branch, + }) + + if (!Array.isArray(contents)) { + throw new Error('Expected directory listing from GitHub agent skills repo') + } + + const skillDirs = contents.filter((item) => item.type === 'dir') + + const skills = await Promise.all( + skillDirs.map(async (item) => { + const skillPath = `${SKILLS_REPO.path}/${item.name}/SKILL.md` + const rawContent = await getGitHubFileContents({ + org: SKILLS_REPO.org, + repo: SKILLS_REPO.repo, + branch: SKILLS_REPO.branch, + path: skillPath, + }) + const { data } = matter(rawContent) as { data: SkillMetadata } + + return { + name: item.name, + description: data.description || '', + installCommand: `npx skills add supabase/agent-skills --skill ${item.name}`, + } + }) + ) + + return skills.sort((a, b) => a.name.localeCompare(b.name)) +} + +export const getAiSkills = cache(getAiSkillsImpl) diff --git a/apps/docs/app/guides/getting-started/ai-skills/AiSkillsIndex.tsx b/apps/docs/app/guides/getting-started/ai-skills/AiSkillsIndex.tsx new file mode 100644 index 0000000000000..1f5311a563ce2 --- /dev/null +++ b/apps/docs/app/guides/getting-started/ai-skills/AiSkillsIndex.tsx @@ -0,0 +1,60 @@ +import { getAiSkills } from './AiSkills.utils' +import { CopyButton } from './CopyButton' + +export async function AiSkillsIndex() { + let skills: Awaited> = [] + + try { + skills = await getAiSkills() + } catch { + // Swallow errors from getAiSkills to keep the page usable + } + + if (!skills.length) { + return ( +
+ Unable to load AI skills at the moment. +
+ ) + } + return ( +
+ + + + + + + + + + {skills.map((skill) => ( + + + + + + ))} + +
SkillDescriptionInstall command
+ + {skill.name} + + {skill.description} +
+
+ + + {skill.installCommand} + +
+
+
+
+ ) +} diff --git a/apps/docs/app/guides/getting-started/ai-skills/CopyButton.tsx b/apps/docs/app/guides/getting-started/ai-skills/CopyButton.tsx new file mode 100644 index 0000000000000..1f9740676554e --- /dev/null +++ b/apps/docs/app/guides/getting-started/ai-skills/CopyButton.tsx @@ -0,0 +1,29 @@ +'use client' + +import { useState } from 'react' +import { Check, Copy } from 'lucide-react' +import { cn } from 'ui' + +export function CopyButton({ text }: { text: string }) { + const [copied, setCopied] = useState(false) + + const handleCopy = async () => { + await navigator.clipboard.writeText(text) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + return ( + + ) +} diff --git a/apps/docs/app/guides/getting-started/layout.tsx b/apps/docs/app/guides/getting-started/layout.tsx index ba83678f3b7d0..431edbe45888e 100644 --- a/apps/docs/app/guides/getting-started/layout.tsx +++ b/apps/docs/app/guides/getting-started/layout.tsx @@ -3,7 +3,9 @@ import Layout from '~/layouts/guides' import { getAiPrompts } from '../getting-started/ai-prompts/[slug]/AiPrompts.utils' export default async function GettingStartedLayout({ children }: { children: React.ReactNode }) { - const additionalNavItems = { prompts: await getPrompts() } + const additionalNavItems = { + prompts: await getPrompts(), + } return {children} } diff --git a/apps/docs/app/guides/graphql/[[...slug]]/page.tsx b/apps/docs/app/guides/graphql/[[...slug]]/page.tsx index 57ca0b26eae98..5368816194c2b 100644 --- a/apps/docs/app/guides/graphql/[[...slug]]/page.tsx +++ b/apps/docs/app/guides/graphql/[[...slug]]/page.tsx @@ -4,7 +4,7 @@ import rehypeSlug from 'rehype-slug' import { GuideTemplate, newEditLink } from '~/features/docs/GuidesMdx.template' import { genGuideMeta } from '~/features/docs/GuidesMdx.utils' -import { REVALIDATION_TAGS } from '~/features/helpers.fetch' +import { getGitHubFileContents } from '~/lib/octokit' import { UrlTransformFunction, linkTransform } from '~/lib/mdx/plugins/rehypeLinkTransform' import remarkMkDocsAdmonition from '~/lib/mdx/plugins/remarkAdmonition' import { removeTitle } from '~/lib/mdx/plugins/remarkRemoveTitle' @@ -136,23 +136,12 @@ const getContent = async ({ slug }: Params) => { const editLink = newEditLink(`${org}/${repo}/blob/${branch}/${docsDir}/${remoteFile}`) - let response: Response - try { - response = await fetch( - `https://raw.githubusercontent.com/${org}/${repo}/${branch}/${docsDir}/${remoteFile}`, - { cache: 'force-cache', next: { tags: [REVALIDATION_TAGS.GRAPHQL] } } - ) - } catch (err) { - throw new Error(`Failed to fetch GraphQL docs from GitHub (network error): ${err}`) - } - - if (!response.ok) { - throw new Error( - `Failed to fetch GraphQL docs from GitHub: ${response.status} ${response.statusText}` - ) - } - - const content = await response.text() + const content = await getGitHubFileContents({ + org, + repo, + path: `${docsDir}/${remoteFile}`, + branch, + }) return { pathname: `/guides/graphql${slug?.length ? `/${slug.join('/')}` : ''}` satisfies `/${string}`, diff --git a/apps/docs/components/Breadcrumbs.tsx b/apps/docs/components/Breadcrumbs.tsx index 2f04f9c186963..b9bde64e769f0 100644 --- a/apps/docs/components/Breadcrumbs.tsx +++ b/apps/docs/components/Breadcrumbs.tsx @@ -176,6 +176,18 @@ function useBreadcrumbs() { return breadcrumbs } + // TODO: Breadcrumbs currently can't infer the "AI Tools" parent for /guides/getting-started/ai-* routes, + // so we special-case these paths here. Remove when Breadcrumbs can derive this hierarchy from NavigationMenu. + const isAiSkillsPage = pathname.startsWith('/guides/getting-started/ai-skills') + if (isAiSkillsPage) { + const breadcrumbs = [ + { name: 'Getting started', url: '/guides/getting-started' }, + { name: 'AI Tools' }, + { name: 'Agent Skills', url: '/guides/getting-started/ai-skills' }, + ] + return breadcrumbs + } + const menuId = getMenuId(pathname) const menu = NavItems[menuId] return findMenuItemByUrl(menu, pathname, []) diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts index 37a38306f9467..6dd2fadc00c52 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts @@ -499,6 +499,10 @@ export const gettingstarted: NavMenuConstant = { name: 'Prompts', url: '/guides/getting-started/ai-prompts' as `/${string}`, }, + { + name: 'Agent Skills', + url: '/guides/getting-started/ai-skills' as `/${string}`, + }, { name: 'Supabase MCP server', url: '/guides/getting-started/mcp' as `/${string}`, diff --git a/apps/docs/content/guides/getting-started/ai-skills.mdx b/apps/docs/content/guides/getting-started/ai-skills.mdx new file mode 100644 index 0000000000000..1552d6ce8caec --- /dev/null +++ b/apps/docs/content/guides/getting-started/ai-skills.mdx @@ -0,0 +1,48 @@ +--- +title: Agent Skills +--- + +Agent Skills are folders of instructions, scripts, and resources that agents can discover and use to do things more accurately and efficiently. Agents are increasingly capable, but often don't have the context they need to do real work reliably. Skills solve this by giving agents access to procedural knowledge and company-, team-, and user-specific context they can load on demand. Agents with access to a set of skills can extend their capabilities based on the task they're working on. + +## Installing skills + +Install all Supabase skills using the skills CLI: + +```bash +npx skills add supabase/agent-skills +``` + +To install a specific skill from the repository: + +```bash +npx skills add supabase/agent-skills --skill SKILL_NAME +``` + +### Claude Code plugin + +You can also install the skills as Claude Code plugins: + +```bash +/plugin marketplace add supabase/agent-skills +/plugin install postgres-best-practices@supabase-agent-skills +``` + +Skills work with 18+ AI agents including Claude Code, GitHub Copilot, Cursor, Cline, and many others. + +## Available skills + + + +## Finding more skills + +Browse the [skills.sh directory](https://skills.sh) to discover skills from the community. You can also search for skills using the CLI: + +```bash +npx skills find QUERY +``` + +## Learn more + +- [Agent Skills Repository](https://github.com/supabase/agent-skills) +- [Agent Skills Documentation](https://agentskills.io/home) +- [Agent Skills Overview](https://platform.claude.com/docs/en/agents-and-tools/agent-skills/overview) diff --git a/apps/docs/content/guides/platform/migrating-within-supabase/backup-restore.mdx b/apps/docs/content/guides/platform/migrating-within-supabase/backup-restore.mdx index c4c2275fcb91e..8bcf027f60656 100644 --- a/apps/docs/content/guides/platform/migrating-within-supabase/backup-restore.mdx +++ b/apps/docs/content/guides/platform/migrating-within-supabase/backup-restore.mdx @@ -4,6 +4,8 @@ subtitle: 'Learn how to backup and restore projects using the Supabase CLI' breadcrumb: 'Migrations' --- +# Migrating the database + ## Backup database using the CLI @@ -21,11 +23,11 @@ breadcrumb: 'Migrations' - On your project dashboard, click [Connect](/dashboard/project/_?showConnect=true). + On your project dashboard, click [Connect](/dashboard/project/_?showConnect=true&method=session). - Use the [Session pooler](/dashboard/project/_?showConnect=true&method=session) connection string by default. If your ISP supports IPv6 or you have the IPv4 add-on enabled, use the direct connection string. + Use the [Session pooler](/dashboard/project/_?showConnect=true&method=session) connection string by default. If your network supports [IPv6](https://test-ipv6.com/) or you have the [IPv4 add-on](/docs/guides/platform/ipv4-address) enabled, use the direct connection string. @@ -65,7 +67,7 @@ breadcrumb: 'Migrations' ``` ```bash - supabase db dump --db-url [CONNECTION_STRING] -f data.sql --use-copy --data-only + supabase db dump --db-url [CONNECTION_STRING] -f data.sql --use-copy --data-only -x "storage.buckets_vectors" -x "storage.vector_indexes" ``` @@ -104,18 +106,17 @@ breadcrumb: 'Migrations' - If Webhooks were used in the old database, enable [Database Webhooks](/dashboard/project/_/database/hooks). - If any non-default extensions were used in the old database, enable the [Extensions](/dashboard/project/_/database/extensions). - - If Replication for Realtime was used in the old database, enable [Publication](/dashboard/project/_/database/publications) on the tables necessary - Go to the [project page](/dashboard/project/_/) and click the "**Connect**" button at the top of the page for the connection string. + Go to [the **Connect** panel](/dashboard/project/_?showConnect=true&method=session) for the connection string. - Use the Session pooler connection string by default. If your ISP supports IPv6, use the direct connection string. + Use the Session pooler connection string by default. If your ISP [supports IPv6](https://test-ipv6.com/), use the direct connection string. @@ -135,15 +136,14 @@ breadcrumb: 'Migrations' - Reset the password in the [project connect page](/dashboard/project/_?showConnect=true). - Replace ```[YOUR-PASSWORD]``` in the connection string with the database password. + Replace ```[YOUR-PASSWORD]``` in the connection string with the database password. If you do not remember your password, you can reset it on [the **Database > Settings**](/dashboard/project/_/database/settings) page of the Dashboard. - + - + + + If replication for Supabase Realtime was used in the old database, enable publication on [the **Database > Publications**](/dashboard/project/_/database/publications) section of the Dashboard on the tables necessary. + -## Important project restoration notes + -### Troubleshooting notes + -- Setting the `session_replication_role` to `replica` disables all triggers so that columns are not double encrypted. -- If you have created any [custom roles](/dashboard/project/_/database/roles) with `login` attribute, you have to manually set their passwords in the new project. -- If you run into any permission errors related to `supabase_admin` during restore, edit the `schema.sql` file and comment out any lines containing `ALTER ... OWNER TO "supabase_admin"`. +## Special considerations -### Preserving migration history +#### Preserving migration history If you were using Supabase CLI for managing migrations on your old database and would like to preserve the migration history in your newly restored project, you need to insert the migration records separately using the following commands. @@ -213,7 +214,7 @@ psql \ --dbname "$NEW_DB_URL" ``` -### Schema changes to `auth` and `storage` +#### Schema changes to `auth` and `storage` If you have modified the `auth` and `storage` schemas in your old project, such as adding triggers or Row Level Security(RLS) policies, you have to restore them separately. The Supabase CLI can help you diff the changes to these schemas using the following commands. @@ -222,339 +223,591 @@ supabase link --project-ref "$OLD_PROJECT_REF" supabase db diff --linked --schema auth,storage > changes.sql ``` -### Migrate storage objects - -The new project has the old project's Storage buckets, but the Storage objects need to be migrated manually. Use this script to move storage objects from one project to another. - -```js -// npm install @supabase/supabase-js@2 -const { createClient } = require('@supabase/supabase-js') - -const OLD_PROJECT_URL = 'https://xxx.supabase.co' -const OLD_PROJECT_SERVICE_KEY = 'old-project-service-key-xxx' - -const NEW_PROJECT_URL = 'https://yyy.supabase.co' -const NEW_PROJECT_SERVICE_KEY = 'new-project-service-key-yyy' - -const oldSupabase = createClient(OLD_PROJECT_URL, OLD_PROJECT_SERVICE_KEY) -const newSupabase = createClient(NEW_PROJECT_URL, NEW_PROJECT_SERVICE_KEY) - -function createLoadingAnimation(message) { - const readline = require('readline') - const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] - let i = 0 - let timer - let stopped = false - - const animate = () => { - if (stopped) return - process.stdout.write(`\r${frames[i]} ${message}`) - i = (i + 1) % frames.length - timer = setTimeout(animate, 80) - } - - animate() - - return { - stop: (finalMessage = '') => { - stopped = true - clearTimeout(timer) - readline.clearLine(process.stdout, 0) - readline.cursorTo(process.stdout, 0) - process.stdout.write(`✓ ${finalMessage || message}\n`) - }, - } -} - -/** - * Lists all files in a bucket, handling nested folders recursively. - */ -async function listAllFiles(bucket, path = '') { - const loader = createLoadingAnimation(`Listing files in '${bucket}${path ? '/' + path : ''}'...`) - - try { - const { data, error } = await oldSupabase.storage.from(bucket).list(path, { limit: 1000 }) - if (error) { - loader.stop(`Error listing files in '${bucket}${path ? '/' + path : ''}'`) - throw new Error(`❌ Error listing files in bucket '${bucket}': ${error.message}`) - } - - if (!data || data.length === 0) { - loader.stop(`No files found in '${bucket}${path ? '/' + path : ''}'`) - return [] - } - - let files = [] - for (const item of data) { - if (!item.metadata) { - loader.stop(`Found folder '${item.name}' in '${bucket}${path ? '/' + path : ''}'`) - const subFiles = await listAllFiles(bucket, `${path}${item.name}/`) - files = files.concat(subFiles) - } else { - files.push({ fullPath: `${path}${item.name}`, metadata: item.metadata }) - } - } - - loader.stop(`Found ${files.length} files in '${bucket}${path ? '/' + path : ''}'`) - return files - } catch (error) { - loader.stop() - throw error - } -} - -/** - * Creates a bucket in the new Supabase project if it doesn't exist. - */ -async function ensureBucketExists(bucketName, options = {}) { - const { data: existingBucket, error: getBucketError } = - await newSupabase.storage.getBucket(bucketName) - - if (getBucketError && !getBucketError.message.includes('not found')) { - throw new Error(`❌ Error checking if bucket '${bucketName}' exists: ${getBucketError.message}`) - } - - if (!existingBucket) { - console.log(`🪣 Creating bucket '${bucketName}' in new project...`) - const { error } = await newSupabase.storage.createBucket(bucketName, options) - if (error) throw new Error(`❌ Failed to create bucket '${bucketName}': ${error.message}`) - console.log(`✅ Created bucket '${bucketName}'`) - } else { - console.log(`ℹ️ Bucket '${bucketName}' already exists in new project`) - } -} - -/** - * Migrates a single file from the old project to the new one. - */ -async function migrateFile(sourceBucketName, targetBucketName, file) { - const loader = createLoadingAnimation( - `Migrating ${file.fullPath} in bucket '${sourceBucketName}' to '${targetBucketName}'...` - ) - - try { - const { data, error: downloadError } = await oldSupabase.storage - .from(sourceBucketName) - .download(file.fullPath) - if (downloadError) { - loader.stop(`Failed to migrate ${file.fullPath}: Download error`) - throw new Error(`Download failed: ${downloadError.message}`) - } - - // Preserve all available metadata from the original file - const uploadOptions = { - upsert: true, - contentType: file.metadata?.mimetype, - cacheControl: file.metadata?.cacheControl, - } - - const { error: uploadError } = await newSupabase.storage - .from(targetBucketName) - .upload(file.fullPath, data, uploadOptions) - if (uploadError) { - loader.stop(`Failed to migrate ${file.fullPath}: Upload error`) - throw new Error(`Upload failed: ${uploadError.message}`) - } - - loader.stop( - `Migrated ${file.fullPath} in bucket '${sourceBucketName}' to '${targetBucketName}'` - ) - return { success: true, path: file.fullPath } - } catch (err) { - console.error( - `❌ Error migrating ${file.fullPath} in bucket '${targetBucketName}':`, - err.message - ) - return { success: false, path: file.fullPath, error: err.message } - } -} - -function chunkArray(array, size) { - const chunks = [] - for (let i = 0; i < array.length; i += size) { - chunks.push(array.slice(i, i + size)) - } - return chunks -} - -/** - * Migrates all buckets and files from the old Supabase project to the new one. - * Processes files in parallel within batches for efficiency. - */ -async function migrateBuckets() { - console.log('🔄 Starting Supabase Storage migration...') - console.log(`📦 Source project: ${OLD_PROJECT_URL}`) - console.log(`📦 Target project: ${NEW_PROJECT_URL}`) - - const readline = require('readline').createInterface({ - input: process.stdin, - output: process.stdout, - }) - - console.log( - '\n⚠️ WARNING: This migration may overwrite files in the target project if they have the same paths.' - ) - console.log('⚠️ It is recommended to back up your target project before proceeding.') - - const answer = await new Promise((resolve) => { - readline.question('Do you want to proceed with the migration? (yes/no): ', resolve) - }) - - readline.close() - - if (answer.toLowerCase() !== 'yes') { - console.log('Migration canceled by user.') - return { canceled: true } - } - - console.log('\n📦 Fetching all buckets from old project...') - - const { data: oldBuckets, error: bucketListError } = await oldSupabase.storage.listBuckets() - - if (bucketListError) throw new Error(`❌ Error fetching buckets: ${bucketListError.message}`) - console.log(`✅ Found ${oldBuckets.length} buckets to migrate.`) - - const { data: existingBuckets, error: existingBucketsError } = - await newSupabase.storage.listBuckets() - if (existingBucketsError) - throw new Error(`❌ Error fetching existing buckets: ${existingBucketsError.message}`) - - const existingBucketNames = existingBuckets.map((b) => b.name) - const conflictingBuckets = oldBuckets.filter((b) => existingBucketNames.includes(b.name)) - - let conflictStrategy = 2 - - if (conflictingBuckets.length > 0) { - console.log('\n⚠️ The following buckets already exist in the target project:') - conflictingBuckets.forEach((b) => console.log(` - ${b.name}`)) - - const conflictAnswer = await new Promise((resolve) => { - const rl = require('readline').createInterface({ - input: process.stdin, - output: process.stdout, - }) - rl.question( - '\nHow do you want to handle existing buckets?\n' + - '1. Skip existing buckets\n' + - '2. Merge files (may overwrite existing files)\n' + - '3. Rename buckets in target (add suffix "_migrated")\n' + - '4. Cancel migration\n' + - 'Enter your choice (1-4): ', - (answer) => { - rl.close() - resolve(answer) - } - ) - }) - - if (conflictAnswer === '4') { - console.log('Migration canceled by user.') - return { canceled: true } - } - - conflictStrategy = parseInt(conflictAnswer) - if (isNaN(conflictStrategy) || conflictStrategy < 1 || conflictStrategy > 3) { - console.log('Invalid choice. Migration canceled.') - return { canceled: true } - } - } - - const migrationStats = { - totalBuckets: oldBuckets.length, - processedBuckets: 0, - skippedBuckets: 0, - totalFiles: 0, - successfulFiles: 0, - failedFiles: 0, - failedFilesList: [], - } - - for (const bucket of oldBuckets) { - const bucketName = bucket.name - console.log(`\n📁 Processing bucket: ${bucketName}`) - - let targetBucketName = bucketName - - if (existingBucketNames.includes(bucketName)) { - if (conflictStrategy === 1) { - console.log(`⏩ Skipping bucket '${bucketName}' as it already exists in target project`) - migrationStats.skippedBuckets++ - continue - } else if (conflictStrategy === 3) { - targetBucketName = `${bucketName}_migrated` - console.log(`🔄 Renaming bucket to '${targetBucketName}' in target project`) - } else { - console.log(`🔄 Merging files into existing bucket '${bucketName}' in target project`) - } - } - - // Preserve bucket configuration when creating in the new project - if (targetBucketName !== bucketName || !existingBucketNames.includes(bucketName)) { - await ensureBucketExists(targetBucketName, { - public: bucket.public, - fileSizeLimit: bucket.file_size_limit, - allowedMimeTypes: bucket.allowed_mime_types, - }) - } - - const files = await listAllFiles(bucketName) - console.log(`✅ Found ${files.length} files in bucket '${bucketName}'.`) - migrationStats.totalFiles += files.length - - const batches = chunkArray(files, 10) - - for (let i = 0; i < batches.length; i++) { - console.log(`\n🚀 Processing batch ${i + 1}/${batches.length} (${batches[i].length} files)`) - - const results = await Promise.all( - batches[i].map((file) => migrateFile(bucketName, targetBucketName, file)) - ) - - const batchSuccesses = results.filter((r) => r.success).length - const batchFailures = results.filter((r) => !r.success) - - migrationStats.successfulFiles += batchSuccesses - migrationStats.failedFiles += batchFailures.length - migrationStats.failedFilesList.push(...batchFailures.map((f) => f.path)) - - console.log( - `✅ Completed batch ${i + 1}/${batches.length}: ${batchSuccesses} succeeded, ${batchFailures.length} failed` - ) - } - - migrationStats.processedBuckets++ - console.log(`✅ Completed bucket '${bucketName}' migration`) - } - - console.log('\n📊 Migration Summary:') - console.log( - `Buckets: ${migrationStats.processedBuckets}/${migrationStats.totalBuckets} processed, ${migrationStats.skippedBuckets} skipped` - ) - console.log( - `Files: ${migrationStats.successfulFiles} succeeded, ${migrationStats.failedFiles} failed (${migrationStats.totalFiles} total)` - ) - - if (migrationStats.failedFiles > 0) { - console.log('\n⚠️ Failed files:') - migrationStats.failedFilesList.forEach((path) => console.log(` - ${path}`)) - return migrationStats - } - - return migrationStats -} - -migrateBuckets() - .then((stats) => { - if (stats.failedFiles > 0) { - console.log(`\n⚠️ Migration completed with ${stats.failedFiles} failed files.`) - process.exit(1) - } else { - console.log('\n🎉 Migration completed successfully!') - process.exit(0) - } - }) - .catch((err) => { - console.error('❌ Fatal error during migration:', err.message) - process.exit(1) - }) +## Troubleshooting notes + +#### Disabling triggers during restore: + +Setting `session_replication_role` to `replica` disables triggers during the migration, preventing columns from being double encrypted. + +#### Custom roles require passwords + +If you created any [custom roles](/dashboard/project/_/database/roles) with the `LOGIN` attribute, you must manually set their passwords in the new project. This can be done with the SQL command: + +```sql +alter user "YOUR_USER" with password 'SOME_NEW_PASSWORD'; ``` + +#### `supabase_admin` permission errors + +If you encounter permission errors related to `supabase_admin` during restore: + +- Open `schema.sql` +- Comment out any lines containing: + +```sql + ALTER ... OWNER TO "supabase_admin" +``` + +#### `cli_login_postgres` role grant error + +If you encounter the error: + +```sh +ERROR: permission denied to grant role "postgres" +DETAIL: Only roles with the ADMIN option on role "postgres" may grant this role. +``` + +- Open `roles.sql` +- Comment out the line: + +```sql +GRANT "postgres" TO "cli_login_postgres" WITH INHERIT FALSE GRANTED BY "supabase_admin"; +``` + +#### `cli_login_postgres` role issues after cloning + +The `cli_login_role` must be created by the `supabase_admin` role. If the migration process cloned over the role before the CLI could generate its own version, it may encounter the error: + +```sh +"message":"Failed to create login role: +ERROR: 0LP01: role "postgres" is a member of role "cli_login_postgres" +``` + +To resolve the issue, drop the custom `cli_login_postgres` role. Then the CLI can recreate it with the right privileges: + +```sql +DROP ROLE IF EXISTS cli_login_postgres; +``` + +# Migrating edge functions + +## Steps (using the Supabase CLI): + + + + + With the Supabase CLI [Supabase CLI](/docs/guides/local-development/cli/getting-started), run: + ```bash + supabase login + ``` + + + + + ```bash + supabase functions list --project-ref your_project_ref + ``` + + + + + You can download an individual function with the following command: + ```bash + supabase functions download YOUR_FUNCTION_NAME --project-ref your_project_ref + ``` + + + The command will not download [import maps](/docs/guides/functions/dependencies#using-import-maps-legacy) nor [deno.json](/docs/guides/functions/dependencies#using-denojson-recommended) files. If your edge functions rely on them for dependency management, you will have to add them back manually. + + + + + + + + + ```bash + supabase functions deploy --project-ref your_target_project_ref + ``` + This deploys all functions within the `supabase/functions` to the target project. You can confirm by checking your Edge Functions on [the project dashboard](/dashboard/project/_/functions) + + + + + + +## Steps (using the Supabase Dashboard): + + + +Dependencies defined through [import maps](/docs/guides/functions/dependencies#using-import-maps-legacy) and [deno.json](/docs/guides/functions/dependencies#using-denojson-recommended) files will need to be rewritten to rely on their [direct import paths](/docs/guides/functions/dependencies#importing-dependencies) when using this approach. + + + + + + + In the source project, navigate to **Edge Functions** from the side menu + + + + + Using the `Download` button, download your desired function as zip: ![Download Edge + Function](/docs/img/troubleshooting/download-edge-function-via-dashboard.gif) + + + + + In the target project, navigate to **Edge Functions** from the side menu + + + + + Click on the `Deploy a new function` button, select **Via Editor** operation + + + + + Drag and drop your downloaded function (the zip function from step 2) into the editor + + + + + Add your function name and click on the `Deploy function` button to deploy the function: + ![Upload Edge Function](/docs/img/troubleshooting/upload-edge-function-via-dashboard.gif) + + + + +# Migrating storage objects + + + + + Using your preferred JavaScript package manager, create a new project with the `supabase` client package + + + + + + + ```bash + npm init -y + npm install @supabase/supabase-js + ``` + + + ```bash + pnpm init -y + pnpm install @supabase/supabase-js + ``` + + + ```bash + yarn init -y + yarn add @supabase/supabase-js + ``` + + + ```bash + bun init -y + bun install @supabase/supabase-js + ``` + + + + + + + + + Add the example script to it. + + + ```js name=index.js + // npm install @supabase/supabase-js@2 + const { createClient } = require('@supabase/supabase-js') + + const OLD_PROJECT_URL = 'https://xxx.supabase.co' + const OLD_PROJECT_SERVICE_KEY = 'old-project-service-key-xxx' + + const NEW_PROJECT_URL = 'https://yyy.supabase.co' + const NEW_PROJECT_SERVICE_KEY = 'new-project-service-key-yyy' + + const oldSupabase = createClient(OLD_PROJECT_URL, OLD_PROJECT_SERVICE_KEY) + const newSupabase = createClient(NEW_PROJECT_URL, NEW_PROJECT_SERVICE_KEY) + + function createLoadingAnimation(message) { + const readline = require('readline') + const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] + let i = 0 + let timer + let stopped = false + + const animate = () => { + if (stopped) return + process.stdout.write(`\r${frames[i]} ${message}`) + i = (i + 1) % frames.length + timer = setTimeout(animate, 80) + } + + animate() + + return { + stop: (finalMessage = '') => { + stopped = true + clearTimeout(timer) + readline.clearLine(process.stdout, 0) + readline.cursorTo(process.stdout, 0) + process.stdout.write(`✓ ${finalMessage || message}\n`) + }, + } + } + + /** + * Lists all files in a bucket, handling nested folders recursively. + */ + async function listAllFiles(bucket, path = '') { + const loader = createLoadingAnimation(`Listing files in '${bucket}${path ? '/' + path : ''}'...`) + + try { + const { data, error } = await oldSupabase.storage.from(bucket).list(path, { limit: 1000 }) + if (error) { + loader.stop(`Error listing files in '${bucket}${path ? '/' + path : ''}'`) + throw new Error(`❌ Error listing files in bucket '${bucket}': ${error.message}`) + } + + if (!data || data.length === 0) { + loader.stop(`No files found in '${bucket}${path ? '/' + path : ''}'`) + return [] + } + + let files = [] + for (const item of data) { + if (!item.metadata) { + loader.stop(`Found folder '${item.name}' in '${bucket}${path ? '/' + path : ''}'`) + const subFiles = await listAllFiles(bucket, `${path}${item.name}/`) + files = files.concat(subFiles) + } else { + files.push({ fullPath: `${path}${item.name}`, metadata: item.metadata }) + } + } + + loader.stop(`Found ${files.length} files in '${bucket}${path ? '/' + path : ''}'`) + return files + } catch (error) { + loader.stop() + throw error + } + } + + /** + * Creates a bucket in the new Supabase project if it doesn't exist. + */ + async function ensureBucketExists(bucketName, options = {}) { + const { data: existingBucket, error: getBucketError } = + await newSupabase.storage.getBucket(bucketName) + + if (getBucketError && !getBucketError.message.includes('not found')) { + throw new Error(`❌ Error checking if bucket '${bucketName}' exists: ${getBucketError.message}`) + } + + if (!existingBucket) { + console.log(`🪣 Creating bucket '${bucketName}' in new project...`) + const { error } = await newSupabase.storage.createBucket(bucketName, options) + if (error) throw new Error(`❌ Failed to create bucket '${bucketName}': ${error.message}`) + console.log(`✅ Created bucket '${bucketName}'`) + } else { + console.log(`ℹ️ Bucket '${bucketName}' already exists in new project`) + } + } + + /** + * Migrates a single file from the old project to the new one. + */ + async function migrateFile(sourceBucketName, targetBucketName, file) { + const loader = createLoadingAnimation( + `Migrating ${file.fullPath} in bucket '${sourceBucketName}' to '${targetBucketName}'...` + ) + + try { + const { data, error: downloadError } = await oldSupabase.storage + .from(sourceBucketName) + .download(file.fullPath) + if (downloadError) { + loader.stop(`Failed to migrate ${file.fullPath}: Download error`) + throw new Error(`Download failed: ${downloadError.message}`) + } + + // Preserve all available metadata from the original file + const uploadOptions = { + upsert: true, + contentType: file.metadata?.mimetype, + cacheControl: file.metadata?.cacheControl, + } + + const { error: uploadError } = await newSupabase.storage + .from(targetBucketName) + .upload(file.fullPath, data, uploadOptions) + if (uploadError) { + loader.stop(`Failed to migrate ${file.fullPath}: Upload error`) + throw new Error(`Upload failed: ${uploadError.message}`) + } + + loader.stop( + `Migrated ${file.fullPath} in bucket '${sourceBucketName}' to '${targetBucketName}'` + ) + return { success: true, path: file.fullPath } + } catch (err) { + console.error( + `❌ Error migrating ${file.fullPath} in bucket '${targetBucketName}':`, + err.message + ) + return { success: false, path: file.fullPath, error: err.message } + } + } + + function chunkArray(array, size) { + const chunks = [] + for (let i = 0; i < array.length; i += size) { + chunks.push(array.slice(i, i + size)) + } + return chunks + } + + /** + * Migrates all buckets and files from the old Supabase project to the new one. + * Processes files in parallel within batches for efficiency. + */ + async function migrateBuckets() { + console.log('🔄 Starting Supabase Storage migration...') + console.log(`📦 Source project: ${OLD_PROJECT_URL}`) + console.log(`📦 Target project: ${NEW_PROJECT_URL}`) + + const readline = require('readline').createInterface({ + input: process.stdin, + output: process.stdout, + }) + + console.log( + '\n⚠️ WARNING: This migration may overwrite files in the target project if they have the same paths.' + ) + console.log('⚠️ It is recommended to back up your target project before proceeding.') + + const answer = await new Promise((resolve) => { + readline.question('Do you want to proceed with the migration? (yes/no): ', resolve) + }) + + readline.close() + + if (answer.toLowerCase() !== 'yes') { + console.log('Migration canceled by user.') + return { canceled: true } + } + + console.log('\n📦 Fetching all buckets from old project...') + + const { data: oldBuckets, error: bucketListError } = await oldSupabase.storage.listBuckets() + + if (bucketListError) throw new Error(`❌ Error fetching buckets: ${bucketListError.message}`) + console.log(`✅ Found ${oldBuckets.length} buckets to migrate.`) + + const { data: existingBuckets, error: existingBucketsError } = + await newSupabase.storage.listBuckets() + if (existingBucketsError) + throw new Error(`❌ Error fetching existing buckets: ${existingBucketsError.message}`) + + const existingBucketNames = existingBuckets.map((b) => b.name) + const conflictingBuckets = oldBuckets.filter((b) => existingBucketNames.includes(b.name)) + + let conflictStrategy = 2 + + if (conflictingBuckets.length > 0) { + console.log('\n⚠️ The following buckets already exist in the target project:') + conflictingBuckets.forEach((b) => console.log(` - ${b.name}`)) + + const conflictAnswer = await new Promise((resolve) => { + const rl = require('readline').createInterface({ + input: process.stdin, + output: process.stdout, + }) + rl.question( + '\nHow do you want to handle existing buckets?\n' + + '1. Skip existing buckets\n' + + '2. Merge files (may overwrite existing files)\n' + + '3. Rename buckets in target (add suffix "_migrated")\n' + + '4. Cancel migration\n' + + 'Enter your choice (1-4): ', + (answer) => { + rl.close() + resolve(answer) + } + ) + }) + + if (conflictAnswer === '4') { + console.log('Migration canceled by user.') + return { canceled: true } + } + + conflictStrategy = parseInt(conflictAnswer) + if (isNaN(conflictStrategy) || conflictStrategy < 1 || conflictStrategy > 3) { + console.log('Invalid choice. Migration canceled.') + return { canceled: true } + } + } + + const migrationStats = { + totalBuckets: oldBuckets.length, + processedBuckets: 0, + skippedBuckets: 0, + totalFiles: 0, + successfulFiles: 0, + failedFiles: 0, + failedFilesList: [], + } + + for (const bucket of oldBuckets) { + const bucketName = bucket.name + console.log(`\n📁 Processing bucket: ${bucketName}`) + + let targetBucketName = bucketName + + if (existingBucketNames.includes(bucketName)) { + if (conflictStrategy === 1) { + console.log(`⏩ Skipping bucket '${bucketName}' as it already exists in target project`) + migrationStats.skippedBuckets++ + continue + } else if (conflictStrategy === 3) { + targetBucketName = `${bucketName}_migrated` + console.log(`🔄 Renaming bucket to '${targetBucketName}' in target project`) + } else { + console.log(`🔄 Merging files into existing bucket '${bucketName}' in target project`) + } + } + + // Preserve bucket configuration when creating in the new project + if (targetBucketName !== bucketName || !existingBucketNames.includes(bucketName)) { + await ensureBucketExists(targetBucketName, { + public: bucket.public, + fileSizeLimit: bucket.file_size_limit, + allowedMimeTypes: bucket.allowed_mime_types, + }) + } + + const files = await listAllFiles(bucketName) + console.log(`✅ Found ${files.length} files in bucket '${bucketName}'.`) + migrationStats.totalFiles += files.length + + const batches = chunkArray(files, 10) + + for (let i = 0; i < batches.length; i++) { + console.log(`\n🚀 Processing batch ${i + 1}/${batches.length} (${batches[i].length} files)`) + + const results = await Promise.all( + batches[i].map((file) => migrateFile(bucketName, targetBucketName, file)) + ) + + const batchSuccesses = results.filter((r) => r.success).length + const batchFailures = results.filter((r) => !r.success) + + migrationStats.successfulFiles += batchSuccesses + migrationStats.failedFiles += batchFailures.length + migrationStats.failedFilesList.push(...batchFailures.map((f) => f.path)) + + console.log( + `✅ Completed batch ${i + 1}/${batches.length}: ${batchSuccesses} succeeded, ${batchFailures.length} failed` + ) + } + + migrationStats.processedBuckets++ + console.log(`✅ Completed bucket '${bucketName}' migration`) + } + + console.log('\n📊 Migration Summary:') + console.log( + `Buckets: ${migrationStats.processedBuckets}/${migrationStats.totalBuckets} processed, ${migrationStats.skippedBuckets} skipped` + ) + console.log( + `Files: ${migrationStats.successfulFiles} succeeded, ${migrationStats.failedFiles} failed (${migrationStats.totalFiles} total)` + ) + + if (migrationStats.failedFiles > 0) { + console.log('\n⚠️ Failed files:') + migrationStats.failedFilesList.forEach((path) => console.log(` - ${path}`)) + return migrationStats + } + + return migrationStats + } + + migrateBuckets() + .then((stats) => { + if (stats.failedFiles > 0) { + console.log(`\n⚠️ Migration completed with ${stats.failedFiles} failed files.`) + process.exit(1) + } else { + console.log('\n🎉 Migration completed successfully!') + process.exit(0) + } + }) + .catch((err) => { + console.error('❌ Fatal error during migration:', err.message) + process.exit(1) + }) + ``` + + + + + + + Get the [secret keys](/dashboard/project/_/settings/api-keys) or [service_role keys](/dashboard/project/_/settings/api-keys/legacy) for both your new and old projects, then substitute them into the script. + From the [Data API settings](/dashboard/project/_/integrations/data_api/overview), copy your project URL and add it to the script as well. + + + ```js name='index.js' + //rest of code + ... + + // add relevant details for old project + const OLD_PROJECT_URL = 'https://xxx.supabase.co' + const OLD_PROJECT_SERVICE_KEY = 'old-project-service-key-xxx' + + // add relevant details for new project + const NEW_PROJECT_URL = 'https://yyy.supabase.co' + const NEW_PROJECT_SERVICE_KEY = 'new-project-service-key-yyy' + + ... + //rest of code + ``` + + + + + + + + + + + ```bash + node index.js + ``` + + + ```bash + bun index.js + ``` + + + + + + + +## Resources + +- [Connecting with PSQL](/docs/guides/database/psql) diff --git a/apps/docs/features/docs/MdxBase.shared.tsx b/apps/docs/features/docs/MdxBase.shared.tsx index 3502b92392d98..3a1484efc4f1d 100644 --- a/apps/docs/features/docs/MdxBase.shared.tsx +++ b/apps/docs/features/docs/MdxBase.shared.tsx @@ -7,6 +7,7 @@ import { IconPanel } from 'ui-patterns/IconPanel' import SqlToRest from 'ui-patterns/SqlToRest' import { Heading } from 'ui/src/components/CustomHTMLElements' import { AiPromptsIndex } from '~/app/guides/getting-started/ai-prompts/[slug]/AiPromptsIndex' +import { AiSkillsIndex } from '~/app/guides/getting-started/ai-skills/AiSkillsIndex' import { AppleSecretGenerator } from '~/components/AppleSecretGenerator' import AuthProviders from '~/components/AuthProviders' import { AuthSmsProviderConfig } from '~/components/AuthSmsProviderConfig' @@ -44,6 +45,7 @@ const components = { AccordionItem, Admonition: AdmonitionWithMargin, AiPromptsIndex, + AiSkillsIndex, AuthSmsProviderConfig, AppleSecretGenerator, AuthProviders, diff --git a/apps/docs/lib/octokit.ts b/apps/docs/lib/octokit.ts index 022af9d655f40..4cf5499566c23 100644 --- a/apps/docs/lib/octokit.ts +++ b/apps/docs/lib/octokit.ts @@ -34,19 +34,13 @@ export function octokit() { return octokitInstance } -async function getGitHubFileContents({ - org, - repo, - path, - branch, - options: { onError, fetch }, -}: { +type GithubFileRequest = { org: string repo: string path: string branch: string - options: { - onError: (err?: unknown) => void + options?: { + onError?: (err?: unknown) => void /** * * A custom fetch implementation to control Next.js caching. @@ -55,13 +49,25 @@ async function getGitHubFileContents({ */ fetch?: (info: RequestInfo, init?: RequestInit) => Promise } -}) { +} + +export async function getGitHubFileContents({ + org, + repo, + path, + branch, + options: { onError, fetch } = {}, +}: GithubFileRequest) { if (path.startsWith('/')) { path = path.slice(1) } + const client = octokit() + let response: Awaited< + ReturnType> + > try { - const response = await octokit().request('GET /repos/{owner}/{repo}/contents/{path}', { + response = await client.request('GET /repos/{owner}/{repo}/contents/{path}', { owner: org, repo: repo, path: path, @@ -70,19 +76,34 @@ async function getGitHubFileContents({ fetch: fetch ?? fetchRevalidatePerDay, }, }) - if (response.status !== 200 || !response.data) { - throw Error(`Could not find contents of ${path} in ${org}/${repo}`) - } - if (!('type' in response.data) || response.data.type !== 'file') { - throw Error(`${path} in ${org}/${repo} is not a file`) - } - const content = Buffer.from(response.data.content, 'base64').toString('utf-8') - return content } catch (err) { - console.error('Error fetching GitHub file: %o', err) - onError?.(err) + const error = new Error( + `getGitHubFileContents: request failed for ${org}/${repo}/${path}@${branch}`, + { cause: err } + ) + console.error(error) + onError?.(error) + return '' + } + + if (Array.isArray(response.data)) { + const error = new Error( + `getGitHubFileContents: ${path} in ${org}/${repo} is a directory, not a file` + ) + console.error(error) + onError?.(error) return '' } + if (!('content' in response.data) || response.data.type !== 'file') { + const error = new Error( + `getGitHubFileContents: unexpected response for ${path} in ${org}/${repo} (type: ${'type' in response.data ? response.data.type : 'unknown'})` + ) + console.error(error) + onError?.(error) + return '' + } + + return Buffer.from(response.data.content, 'base64').toString('utf-8') } export async function getGitHubFileContentsImmutableOnly({ @@ -90,17 +111,8 @@ export async function getGitHubFileContentsImmutableOnly({ repo, branch, path, - options: { onError, fetch }, -}: { - org: string - repo: string - branch: string - path: string - options: { - onError: (error: unknown) => void - fetch: (url: string) => Promise - } -}): Promise { + options: { onError, fetch } = {}, +}: GithubFileRequest): Promise { const isImmutableCommit = await checkForImmutableCommit({ org, repo, diff --git a/apps/studio/components/grid/components/editor/TextEditor.tsx b/apps/studio/components/grid/components/editor/TextEditor.tsx index 3554e02ca17bb..ddfc2229a61ce 100644 --- a/apps/studio/components/grid/components/editor/TextEditor.tsx +++ b/apps/studio/components/grid/components/editor/TextEditor.tsx @@ -1,17 +1,17 @@ -import { Maximize } from 'lucide-react' -import { useCallback, useState } from 'react' -import type { RenderEditCellProps } from 'react-data-grid' -import { toast } from 'sonner' - import { useParams } from 'common' -import { useIsQueueOperationsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' +import { useTableRowOperations } from 'components/grid/hooks/useTableRowOperations' import { isValueTruncated } from 'components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.utils' import { useTableEditorQuery } from 'data/table-editor/table-editor-query' import { isTableLike } from 'data/table-editor/table-editor-types' import { useGetCellValueMutation } from 'data/table-rows/get-cell-value-mutation' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' -import { Button, Popover, Tooltip, TooltipContent, TooltipTrigger, cn } from 'ui' +import { Maximize } from 'lucide-react' +import { useCallback, useState } from 'react' +import type { RenderEditCellProps } from 'react-data-grid' +import { toast } from 'sonner' +import { Button, cn, Popover, Tooltip, TooltipContent, TooltipTrigger } from 'ui' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' + import { BlockKeys } from '../common/BlockKeys' import { EmptyValue } from '../common/EmptyValue' import { MonacoEditor } from '../common/MonacoEditor' @@ -45,7 +45,7 @@ export const TextEditor = ({ const [isPopoverOpen, setIsPopoverOpen] = useState(true) const [value, setValue] = useState(initialValue) const [isConfirmNextModalOpen, setIsConfirmNextModalOpen] = useState(false) - const isQueueOperationsEnabled = useIsQueueOperationsEnabled() + const { isQueueEnabled } = useTableRowOperations() const { mutate: getCellValue, isPending, isSuccess } = useGetCellValueMutation() @@ -58,7 +58,7 @@ export const TextEditor = ({ } const pkMatch = selectedTable.primary_keys.reduce((a, b) => { - return { ...a, [b.name]: (row as any)[b.name] } + return { ...a, [b.name]: row[b.name as keyof typeof row] } }, {}) getCellValue( @@ -172,8 +172,8 @@ export const TextEditor = ({ type="default" htmlType="button" onClick={() => { - if (isQueueOperationsEnabled) { - // Skip confirmation when queue mode is enabled - changes can be reviewed/cancelled + // Skip confirmation when queue mode is enabled - changes can be reviewed/cancelled + if (isQueueEnabled) { saveChanges(null) } else { setIsConfirmNextModalOpen(true) diff --git a/apps/studio/components/grid/components/grid/Grid.utils.tsx b/apps/studio/components/grid/components/grid/Grid.utils.tsx index 161bf2bd29722..354c39012bfa7 100644 --- a/apps/studio/components/grid/components/grid/Grid.utils.tsx +++ b/apps/studio/components/grid/components/grid/Grid.utils.tsx @@ -1,91 +1,22 @@ -import { QueryKey, useQueryClient } from '@tanstack/react-query' import { SupaRow } from 'components/grid/types' -import { queueCellEditWithOptimisticUpdate } from 'components/grid/utils/queueOperationUtils' -import { useIsQueueOperationsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { convertByteaToHex } from 'components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.utils' import { DocsButton } from 'components/ui/DocsButton' import { isTableLike } from 'data/table-editor/table-editor-types' -import { tableRowKeys } from 'data/table-rows/keys' -import { useTableRowUpdateMutation } from 'data/table-rows/table-row-update-mutation' -import type { TableRowsData } from 'data/table-rows/table-rows-query' -import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { DOCS_URL } from 'lib/constants' import { useCallback } from 'react' import { RowsChangeData } from 'react-data-grid' import { toast } from 'sonner' -import { useGetImpersonatedRoleState } from 'state/role-impersonation-state' import { useTableEditorTableStateSnapshot } from 'state/table-editor-table' import type { Dictionary } from 'types' -import { useTableEditorStateSnapshot } from '@/state/table-editor' +import { useTableRowOperations } from '../../hooks/useTableRowOperations' export function useOnRowsChange(rows: SupaRow[]) { - const isQueueOperationsEnabled = useIsQueueOperationsEnabled() - const queryClient = useQueryClient() - const { data: project } = useSelectedProjectQuery() const snap = useTableEditorTableStateSnapshot() - const tableEditorSnap = useTableEditorStateSnapshot() - const getImpersonatedRoleState = useGetImpersonatedRoleState() - - const { mutate: mutateUpdateTableRow } = useTableRowUpdateMutation({ - async onMutate({ projectRef, table, configuration, payload }) { - const primaryKeyColumns = new Set(Object.keys(configuration.identifiers)) - - const queryKey = tableRowKeys.tableRows(projectRef, { table: { id: table.id } }) - - await queryClient.cancelQueries({ queryKey }) - - const previousRowsQueries = queryClient.getQueriesData({ queryKey }) - - queryClient.setQueriesData({ queryKey }, (old) => { - if (!old) return old - - return { - rows: old.rows.map((row) => { - // match primary keys - if ( - Object.entries(row) - .filter(([key]) => primaryKeyColumns.has(key)) - .every(([key, value]) => value === configuration.identifiers[key]) - ) { - return { ...row, ...payload } - } - - return row - }), - } - }) - - return { previousRowsQueries } - }, - onError(error, _variables, context) { - const { previousRowsQueries } = context as { - previousRowsQueries: [ - QueryKey, - ( - | { - result: any[] - } - | undefined - ), - ][] - } - - previousRowsQueries.forEach(([queryKey, previousRows]) => { - if (previousRows) { - queryClient.setQueriesData({ queryKey }, previousRows) - } - queryClient.invalidateQueries({ queryKey }) - }) - - toast.error(error?.message ?? error) - }, - }) + const { editCell } = useTableRowOperations() return useCallback( (_rows: SupaRow[], data: RowsChangeData) => { - if (!project) return - const rowData = _rows[data.indexes[0]] const previousRow = rows.find((x) => x.idx == rowData.idx) const changedColumn = Object.keys(rowData).find( @@ -127,44 +58,17 @@ export function useOnRowsChange(rows: SupaRow[]) { }) } - const configuration = { identifiers } - - if (isQueueOperationsEnabled) { - queueCellEditWithOptimisticUpdate({ - queueOperation: tableEditorSnap.queueOperation, - tableId: snap.table.id, - table: snap.originalTable, - row: previousRow, - rowIdentifiers: identifiers, - columnName: changedColumn, - oldValue: previousRow[changedColumn], - newValue: rowData[changedColumn], - enumArrayColumns, - }) - } else { - // Default behavior: immediately save the change - const updatedData = { [changedColumn]: rowData[changedColumn] } - - mutateUpdateTableRow({ - projectRef: project.ref, - connectionString: project.connectionString, - table: snap.originalTable, - configuration, - payload: updatedData, - enumArrayColumns, - roleImpersonationState: getImpersonatedRoleState(), - }) - } + editCell({ + tableId: snap.table.id, + table: snap.originalTable, + row: previousRow, + rowIdentifiers: identifiers, + columnName: changedColumn, + oldValue: previousRow[changedColumn], + newValue: rowData[changedColumn], + enumArrayColumns, + }) }, - [ - getImpersonatedRoleState, - isQueueOperationsEnabled, - mutateUpdateTableRow, - project, - rows, - snap.originalTable, - snap.table.id, - tableEditorSnap, - ] + [editCell, rows, snap.originalTable, snap.table.id] ) } diff --git a/apps/studio/components/grid/components/header/Header.tsx b/apps/studio/components/grid/components/header/Header.tsx index 055ac85976c21..1507ec69d4688 100644 --- a/apps/studio/components/grid/components/header/Header.tsx +++ b/apps/studio/components/grid/components/header/Header.tsx @@ -2,9 +2,8 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { keepPreviousData, useQueryClient } from '@tanstack/react-query' import { useParams } from 'common' import { useTableFilter } from 'components/grid/hooks/useTableFilter' +import { useTableRowOperations } from 'components/grid/hooks/useTableRowOperations' import { useTableSort } from 'components/grid/hooks/useTableSort' -import { queueRowDeletesWithOptimisticUpdate } from 'components/grid/utils/queueOperationUtils' -import { useIsQueueOperationsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { GridHeaderActions } from 'components/interfaces/TableGridEditor/GridHeaderActions' import { formatTableRowsToSQL } from 'components/interfaces/TableGridEditor/TableEntity.utils' import { @@ -225,11 +224,10 @@ const RowHeader = ({ tableQueriesEnabled = true }: RowHeaderProps) => { const { id: _id } = useParams() const tableId = _id ? Number(_id) : undefined - const queryClient = useQueryClient() const { data: project } = useSelectedProjectQuery() const tableEditorSnap = useTableEditorStateSnapshot() const snap = useTableEditorTableStateSnapshot() - const isQueueOperationsEnabled = useIsQueueOperationsEnabled() + const { deleteRows } = useTableRowOperations() const roleImpersonationState = useRoleImpersonationStateSnapshot() const isImpersonatingRole = roleImpersonationState.role !== undefined @@ -278,22 +276,11 @@ const RowHeader = ({ tableQueriesEnabled = true }: RowHeaderProps) => { const rowIdxs = Array.from(snap.selectedRows) as number[] const rows = allRows.filter((x) => rowIdxs.includes(x.idx)) - // Queue delete operations directly if queue mode is enabled (and not all rows selected) - if (isQueueOperationsEnabled && !snap.allRowsSelected) { - queueRowDeletesWithOptimisticUpdate({ - rows, - table: snap.originalTable, - queueOperation: tableEditorSnap.queueOperation, - projectRef: project?.ref, - }) - snap.resetSelectedRows() - return - } - - // Fall back to confirmation dialog - tableEditorSnap.onDeleteRows(rows, { + deleteRows({ + rows, + table: snap.originalTable, allRowsSelected: snap.allRowsSelected, - numRows: snap.allRowsSelected ? totalRows : rows.length, + totalRows, callback: () => { snap.resetSelectedRows() }, diff --git a/apps/studio/components/grid/components/header/HeaderNew.tsx b/apps/studio/components/grid/components/header/HeaderNew.tsx index f53468eb0916d..3f8d743c22289 100644 --- a/apps/studio/components/grid/components/header/HeaderNew.tsx +++ b/apps/studio/components/grid/components/header/HeaderNew.tsx @@ -1,9 +1,8 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import { keepPreviousData, useQueryClient } from '@tanstack/react-query' +import { keepPreviousData } from '@tanstack/react-query' import { useParams } from 'common' +import { useTableRowOperations } from 'components/grid/hooks/useTableRowOperations' import { useTableSort } from 'components/grid/hooks/useTableSort' -import { queueRowDeletesWithOptimisticUpdate } from 'components/grid/utils/queueOperationUtils' -import { useIsQueueOperationsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { GridHeaderActions } from 'components/interfaces/TableGridEditor/GridHeaderActions' import { formatTableRowsToSQL } from 'components/interfaces/TableGridEditor/TableEntity.utils' import { @@ -219,11 +218,10 @@ const RowHeader = ({ tableQueriesEnabled = true }: RowHeaderProps) => { const { id: _id } = useParams() const tableId = _id ? Number(_id) : undefined - const queryClient = useQueryClient() const { data: project } = useSelectedProjectQuery() const tableEditorSnap = useTableEditorStateSnapshot() const snap = useTableEditorTableStateSnapshot() - const isQueueOperationsEnabled = useIsQueueOperationsEnabled() + const { deleteRows } = useTableRowOperations() const roleImpersonationState = useRoleImpersonationStateSnapshot() const isImpersonatingRole = roleImpersonationState.role !== undefined @@ -272,22 +270,11 @@ const RowHeader = ({ tableQueriesEnabled = true }: RowHeaderProps) => { const rowIdxs = Array.from(snap.selectedRows) as number[] const rows = allRows.filter((x) => rowIdxs.includes(x.idx)) - // Queue delete operations directly if queue mode is enabled (and not all rows selected) - if (isQueueOperationsEnabled && !snap.allRowsSelected) { - queueRowDeletesWithOptimisticUpdate({ - rows, - table: snap.originalTable, - queueOperation: tableEditorSnap.queueOperation, - projectRef: project?.ref, - }) - snap.resetSelectedRows() - return - } - - const numRows = snap.allRowsSelected ? totalRows : snap.selectedRows.size - tableEditorSnap.onDeleteRows(rows, { + deleteRows({ + rows, + table: snap.originalTable, allRowsSelected: snap.allRowsSelected, - numRows, + totalRows, callback: () => { snap.resetSelectedRows() }, diff --git a/apps/studio/components/grid/components/menu/RowContextMenu.tsx b/apps/studio/components/grid/components/menu/RowContextMenu.tsx index 1873509ca0396..864e3616cf21d 100644 --- a/apps/studio/components/grid/components/menu/RowContextMenu.tsx +++ b/apps/studio/components/grid/components/menu/RowContextMenu.tsx @@ -1,12 +1,6 @@ -import { useQueryClient } from '@tanstack/react-query' import { ROW_CONTEXT_MENU_ID } from 'components/grid/constants' import type { SupaRow } from 'components/grid/types' -import { queueRowDeletesWithOptimisticUpdate } from 'components/grid/utils/queueOperationUtils' -import { - useIsQueueOperationsEnabled, - useIsTableFilterBarEnabled, -} from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' -import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { useIsTableFilterBarEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { Copy, Edit, ListFilter, Trash } from 'lucide-react' import { useCallback } from 'react' import { Item, ItemParams, Menu } from 'react-contexify' @@ -15,6 +9,7 @@ import { useTableEditorStateSnapshot } from 'state/table-editor' import { useTableEditorTableStateSnapshot } from 'state/table-editor-table' import { copyToClipboard, DialogSectionSeparator } from 'ui' +import { useTableRowOperations } from '../../hooks/useTableRowOperations' import { formatClipboardValue } from '../../utils/common' import { buildFilterFromCellValue, isComplexValue } from '../header/filter/FilterPopoverNew.utils' @@ -25,11 +20,10 @@ type RowContextMenuProps = { type RowContextMenuItemProps = ItemParams<{ rowIdx: number }, string> export const RowContextMenu = ({ rows }: RowContextMenuProps) => { - const { data: project } = useSelectedProjectQuery() const tableEditorSnap = useTableEditorStateSnapshot() const snap = useTableEditorTableStateSnapshot() - const isQueueOperationsEnabled = useIsQueueOperationsEnabled() const isTableFilterBarEnabled = useIsTableFilterBarEnabled() + const { deleteRows } = useTableRowOperations() function onDeleteRow(p: RowContextMenuItemProps) { const rowIdx = p.props?.rowIdx @@ -41,17 +35,7 @@ export const RowContextMenu = ({ rows }: RowContextMenuProps) => { return } - if (isQueueOperationsEnabled) { - queueRowDeletesWithOptimisticUpdate({ - rows: [row], - table: snap.originalTable, - queueOperation: tableEditorSnap.queueOperation, - projectRef: project?.ref, - }) - return - } - - tableEditorSnap.onDeleteRows([row]) + deleteRows({ rows: [row], table: snap.originalTable }) } function onEditRowClick(p: RowContextMenuItemProps) { diff --git a/apps/studio/components/grid/hooks/useTableRowOperations.ts b/apps/studio/components/grid/hooks/useTableRowOperations.ts new file mode 100644 index 0000000000000..2c3e463e6d68a --- /dev/null +++ b/apps/studio/components/grid/hooks/useTableRowOperations.ts @@ -0,0 +1,253 @@ +import { QueryKey, useQueryClient } from '@tanstack/react-query' +import type { SupaRow } from 'components/grid/types' +import { + queueCellEditWithOptimisticUpdate, + queueRowAddWithOptimisticUpdate, + queueRowDeletesWithOptimisticUpdate, +} from 'components/grid/utils/queueOperationUtils' +import { useIsQueueOperationsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' +import { isTableLike, type Entity } from 'data/table-editor/table-editor-types' +import { tableRowKeys } from 'data/table-rows/keys' +import { useTableRowCreateMutation } from 'data/table-rows/table-row-create-mutation' +import { useTableRowUpdateMutation } from 'data/table-rows/table-row-update-mutation' +import type { TableRowsData } from 'data/table-rows/table-rows-query' +import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { useCallback } from 'react' +import { toast } from 'sonner' +import { useGetImpersonatedRoleState } from 'state/role-impersonation-state' +import { useTableEditorStateSnapshot } from 'state/table-editor' +import type { Dictionary } from 'types' + +import type { PendingAddRow } from '../types' + +export interface EditCellParams { + table: Entity + tableId: number + row: SupaRow + rowIdentifiers: Dictionary + columnName: string + oldValue: unknown + newValue: unknown + enumArrayColumns?: string[] + /** When true, shows a success toast on non-queue save (used by side panel, not grid inline edits) */ + onSuccess?: () => void +} + +export interface AddRowParams { + table: Entity + tableId: number + rowData: PendingAddRow + enumArrayColumns?: string[] +} + +export interface UpdateRowParams { + table: Entity + tableId: number + row: SupaRow + rowIdentifiers: Dictionary + payload: Dictionary + enumArrayColumns?: string[] + onSuccess?: () => void +} + +export interface DeleteRowsParams { + rows: SupaRow[] + table: Entity + allRowsSelected?: boolean + totalRows?: number + callback?: () => void +} + +export function useTableRowOperations() { + const isQueueEnabled = useIsQueueOperationsEnabled() + const queryClient = useQueryClient() + const { data: project } = useSelectedProjectQuery() + const tableEditorSnap = useTableEditorStateSnapshot() + const getImpersonatedRoleState = useGetImpersonatedRoleState() + + // Non-queue mutation for cell edits with optimistic updates + const { mutateAsync: mutateUpdateTableRow, isPending: isEditPending } = useTableRowUpdateMutation( + { + async onMutate({ projectRef, table, configuration, payload }) { + const primaryKeyColumns = new Set(Object.keys(configuration.identifiers)) + const queryKey = tableRowKeys.tableRows(projectRef, { table: { id: table.id } }) + + await queryClient.cancelQueries({ queryKey }) + + const previousRowsQueries = queryClient.getQueriesData({ queryKey }) + + queryClient.setQueriesData({ queryKey }, (old) => { + if (!old) return old + return { + rows: old.rows.map((row) => { + if ( + Object.entries(row) + .filter(([key]) => primaryKeyColumns.has(key)) + .every(([key, value]) => value === configuration.identifiers[key]) + ) { + return { ...row, ...payload } + } + return row + }), + } + }) + + return { previousRowsQueries } + }, + onError(error, _variables, context) { + const { previousRowsQueries } = (context ?? { previousRowsQueries: [] }) as { + previousRowsQueries: [QueryKey, TableRowsData | undefined][] + } + + previousRowsQueries.forEach(([queryKey, previousRows]) => { + if (previousRows) { + queryClient.setQueriesData({ queryKey }, previousRows) + } + queryClient.invalidateQueries({ queryKey }) + }) + + toast.error(error?.message ?? error) + }, + } + ) + + // Non-queue mutation for row creation + const { mutateAsync: mutateCreateTableRow } = useTableRowCreateMutation({ + onSuccess() { + toast.success('Successfully created row') + }, + }) + + const editCell = useCallback( + async (params: EditCellParams) => { + if (isQueueEnabled) { + queueCellEditWithOptimisticUpdate({ + queueOperation: tableEditorSnap.queueOperation, + tableId: params.tableId, + table: params.table, + row: params.row, + rowIdentifiers: params.rowIdentifiers, + columnName: params.columnName, + oldValue: params.oldValue, + newValue: params.newValue, + enumArrayColumns: params.enumArrayColumns, + }) + return + } + + if (!project) return + + const updatedData = { [params.columnName]: params.newValue } + await mutateUpdateTableRow({ + projectRef: project.ref, + connectionString: project.connectionString, + table: params.table, + configuration: { identifiers: params.rowIdentifiers }, + payload: updatedData, + enumArrayColumns: params.enumArrayColumns ?? [], + roleImpersonationState: getImpersonatedRoleState(), + }) + params.onSuccess?.() + }, + [isQueueEnabled, project, tableEditorSnap, mutateUpdateTableRow, getImpersonatedRoleState] + ) + + const updateRow = useCallback( + async (params: UpdateRowParams) => { + if (isQueueEnabled) { + // Queue individual cell edits per changed column + for (const columnName of Object.keys(params.payload)) { + queueCellEditWithOptimisticUpdate({ + queueOperation: tableEditorSnap.queueOperation, + tableId: params.tableId, + table: params.table, + row: params.row, + rowIdentifiers: params.rowIdentifiers, + columnName, + oldValue: params.row[columnName], + newValue: params.payload[columnName], + enumArrayColumns: params.enumArrayColumns, + }) + } + return + } + + if (!project) return + + await mutateUpdateTableRow({ + projectRef: project.ref, + connectionString: project.connectionString, + table: params.table, + configuration: { identifiers: params.rowIdentifiers }, + payload: params.payload, + enumArrayColumns: params.enumArrayColumns ?? [], + roleImpersonationState: getImpersonatedRoleState(), + }) + params.onSuccess?.() + }, + [isQueueEnabled, project, tableEditorSnap, mutateUpdateTableRow, getImpersonatedRoleState] + ) + + const addRow = useCallback( + async (params: AddRowParams) => { + // Only queue if the table has primary keys (required for queue conflict resolution) + const hasPrimaryKeys = isTableLike(params.table) && params.table.primary_keys.length > 0 + + if (isQueueEnabled && hasPrimaryKeys) { + queueRowAddWithOptimisticUpdate({ + queueOperation: tableEditorSnap.queueOperation, + tableId: params.tableId, + table: params.table, + rowData: params.rowData, + enumArrayColumns: params.enumArrayColumns, + }) + return + } + + if (!project) return + + await mutateCreateTableRow({ + projectRef: project.ref, + connectionString: project.connectionString, + table: params.table, + payload: params.rowData, + enumArrayColumns: params.enumArrayColumns ?? [], + roleImpersonationState: getImpersonatedRoleState(), + }) + }, + [isQueueEnabled, project, tableEditorSnap, mutateCreateTableRow, getImpersonatedRoleState] + ) + + const deleteRows = useCallback( + (params: DeleteRowsParams) => { + // When queue is enabled and not all rows are selected, queue the deletes + if (isQueueEnabled && !params.allRowsSelected) { + queueRowDeletesWithOptimisticUpdate({ + rows: params.rows, + table: params.table, + queueOperation: tableEditorSnap.queueOperation, + projectRef: project?.ref, + }) + params.callback?.() + return + } + + // Otherwise, open the confirmation dialog + tableEditorSnap.onDeleteRows(params.rows, { + allRowsSelected: params.allRowsSelected ?? false, + numRows: params.allRowsSelected ? params.totalRows : params.rows.length, + callback: params.callback, + }) + }, + [isQueueEnabled, project, tableEditorSnap] + ) + + return { + editCell, + updateRow, + addRow, + deleteRows, + isQueueEnabled, + isEditPending, + } +} diff --git a/apps/studio/components/grid/utils/queueOperationUtils.test.ts b/apps/studio/components/grid/utils/queueOperationUtils.test.ts index 46ec20de0442e..07209e500c664 100644 --- a/apps/studio/components/grid/utils/queueOperationUtils.test.ts +++ b/apps/studio/components/grid/utils/queueOperationUtils.test.ts @@ -265,8 +265,7 @@ describe('formatGridDataWithOperationValues', () => { expect(result[1].name).toBe('Updated Bob') }) - test('last edit wins when multiple operations target the same row', () => { - // Each operation spreads from the original row, so only the last op's column change is preserved + test('multiple operations targeting the same row preserve all column changes', () => { const rows = [makeRow(0, { id: 1, name: 'Alice', email: 'alice@test.com' })] const op1 = makeEditOp({ id: 'op-1', @@ -290,8 +289,8 @@ describe('formatGridDataWithOperationValues', () => { }) const result = formatGridDataWithOperationValues({ operations: [op1, op2], rows }) - // The second op overwrites the first since both spread from the original row - expect(result[0].name).toBe('Alice') + // Both column edits should be preserved + expect(result[0].name).toBe('Updated') expect(result[0].email).toBe('updated@test.com') }) diff --git a/apps/studio/components/grid/utils/queueOperationUtils.ts b/apps/studio/components/grid/utils/queueOperationUtils.ts index 86b047369c138..ced361f1c842e 100644 --- a/apps/studio/components/grid/utils/queueOperationUtils.ts +++ b/apps/studio/components/grid/utils/queueOperationUtils.ts @@ -152,9 +152,9 @@ export const formatGridDataWithOperationValues = ({ operations.forEach((op) => { if (op.type === QueuedOperationType.EDIT_CELL_CONTENT) { const { rowIdentifiers, columnName, newValue } = op.payload - const rowMatches = rows.find((row) => rowMatchesIdentifiers(row, rowIdentifiers)) - if (rowMatches) { - formattedRows[rowMatches.idx] = { ...rowMatches, [columnName]: newValue } + const rowIdx = rows.findIndex((row) => rowMatchesIdentifiers(row, rowIdentifiers)) + if (rowIdx !== -1) { + formattedRows[rowIdx] = { ...formattedRows[rowIdx], [columnName]: newValue } } } else if (op.type === QueuedOperationType.ADD_ROW) { const { tempId, rowData } = op.payload diff --git a/apps/studio/components/interfaces/App/UpdateBillingAddressModal.tsx b/apps/studio/components/interfaces/App/UpdateBillingAddressModal.tsx index d82405c6a7784..468d846aa725b 100644 --- a/apps/studio/components/interfaces/App/UpdateBillingAddressModal.tsx +++ b/apps/studio/components/interfaces/App/UpdateBillingAddressModal.tsx @@ -128,6 +128,7 @@ export function UpdateBillingAddressModal() { > e.preventDefault()} onEscapeKeyDown={(e) => e.preventDefault()} > diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.constants.ts b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.constants.ts index f8492a1c6947d..887fa663533f4 100644 --- a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.constants.ts +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.constants.ts @@ -34,6 +34,7 @@ export const INVOCATION_TABS: InvocationTab[] = [ language: 'js', hideLineNumbers: true, code: ({ functionName }) => `import { createClient } from '@supabase/supabase-js' + const supabase = createClient(process.env.SUPABASE_URL, process.env.SUPABASE_ANON_KEY) const { data, error } = await supabase.functions.invoke('${functionName}', { body: { name: 'Functions' }, diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx index 2fc17538c5ce0..1fcf9c254d823 100644 --- a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx @@ -13,6 +13,7 @@ import { Card, CardContent, CardFooter, + CardHeader, cn, CodeBlock, copyToClipboard, @@ -182,11 +183,22 @@ export const EdgeFunctionDetails = () => { layout="flex-row-reverse" description={ <> - Requires a JWT signed{' '} - only by the legacy secret{' '} - in the Authorization header. The anon key - satisfies this. Recommended: OFF with JWT and custom auth logic in - your function code. +

+ Requires a JWT signed{' '} + + only by the legacy secret + {' '} + in the{' '} + + Authorization + {' '} + header. The anon key + satisfies this. +

+

+ Recommended: OFF with JWT and custom auth logic in your function + code. +

} > @@ -265,15 +277,16 @@ export const EdgeFunctionDetails = () => { }) return ( - + code]:!whitespace-pre-wrap', + 'p-0 text-xs !mt-0 border-none ', showKey ? '[&>code]:break-all' : '[&>code]:break-words' )} language={tab.language} - wrapLines={true} + wrapLines={false} hideLineNumbers={tab.hideLineNumbers} handleCopy={() => { copyToClipboard( @@ -313,7 +326,7 @@ export const EdgeFunctionDetails = () => { description: 'Download the function to your local machine', jsx: () => ( <> - supabase functions download{' '} + supabase functions download{' '} {selectedFunction?.slug} ), diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.utils.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.utils.tsx index f83befe18a27f..448e73ebf1cc6 100644 --- a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.utils.tsx +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.utils.tsx @@ -16,8 +16,7 @@ export const generateCLICommands = ({ jsx: () => { return ( <> - supabase functions deploy{' '} - {selectedFunction?.slug} + supabase functions deploy {selectedFunction?.slug} ) }, @@ -29,8 +28,7 @@ export const generateCLICommands = ({ jsx: () => { return ( <> - supabase functions delete{' '} - {selectedFunction?.slug} + supabase functions delete {selectedFunction?.slug} ) }, @@ -45,7 +43,7 @@ export const generateCLICommands = ({ jsx: () => { return ( <> - supabase secrets list + supabase secrets list ) }, @@ -57,7 +55,7 @@ export const generateCLICommands = ({ jsx: () => { return ( <> - supabase secrets set NAME1=VALUE1 NAME2=VALUE2 + supabase secrets set NAME1=VALUE1 NAME2=VALUE2 ) }, @@ -69,7 +67,7 @@ export const generateCLICommands = ({ jsx: () => { return ( <> - supabase secrets unset NAME1 NAME2 + supabase secrets unset NAME1 NAME2 ) }, @@ -86,7 +84,7 @@ export const generateCLICommands = ({ jsx: () => { return ( <> - curl -L -X POST '{functionUrl}'{' '} + curl -L -X POST '{functionUrl}'{' '} {selectedFunction?.verify_jwt ? `-H 'Authorization: Bearer [YOUR ANON KEY]' ` diff --git a/apps/studio/components/interfaces/LogDrains/LogDrainDestinationSheetForm.tsx b/apps/studio/components/interfaces/LogDrains/LogDrainDestinationSheetForm.tsx index bf04fae4380bf..acc2d839c81df 100644 --- a/apps/studio/components/interfaces/LogDrains/LogDrainDestinationSheetForm.tsx +++ b/apps/studio/components/interfaces/LogDrains/LogDrainDestinationSheetForm.tsx @@ -135,6 +135,7 @@ const formUnion = z.discriminatedUnion('type', [ gzip: z.boolean().optional().default(true), headers: z.record(z.string(), z.string()).optional(), }), + z.object({ type: z.literal('syslog') }), ]) const formSchema = z @@ -316,9 +317,7 @@ export function LogDrainDestinationSheetForm({ } form.handleSubmit(onSubmit)(e) - track('log_drain_save_button_clicked', { - destination: form.getValues('type'), - }) + track('log_drain_save_button_clicked', { destination: form.getValues('type') }) }} >
diff --git a/apps/studio/components/interfaces/Organization/PrivateApps/Apps/Apps.constants.ts b/apps/studio/components/interfaces/Organization/PrivateApps/Apps/Apps.constants.ts index 5dbcf8f52fa2b..100dd4f2f02cd 100644 --- a/apps/studio/components/interfaces/Organization/PrivateApps/Apps/Apps.constants.ts +++ b/apps/studio/components/interfaces/Organization/PrivateApps/Apps/Apps.constants.ts @@ -1,403 +1,15 @@ +import { permissions } from '@supabase/shared-types' + export interface Permission { id: string label: string description: string - group: 'organization' | 'project' } -export const PERMISSIONS: Permission[] = [ - // Organization permissions - { - id: 'organizations_read', - label: 'organizations_read', - description: 'View organization details', - group: 'organization', - }, - { - id: 'organizations_create', - label: 'organizations_create', - description: 'Create organizations', - group: 'organization', - }, - { - id: 'organization_admin_read', - label: 'organization_admin_read', - description: 'View organization admin settings', - group: 'organization', - }, - { - id: 'organization_admin_write', - label: 'organization_admin_write', - description: 'Manage organization admin settings', - group: 'organization', - }, - { - id: 'members_read', - label: 'members_read', - description: 'View organization members', - group: 'organization', - }, - { - id: 'members_write', - label: 'members_write', - description: 'Manage organization members', - group: 'organization', - }, - { - id: 'organization_projects_read', - label: 'organization_projects_read', - description: 'View organization projects', - group: 'organization', - }, - { - id: 'organization_projects_create', - label: 'organization_projects_create', - description: 'Create projects in organization', - group: 'organization', - }, - { - id: 'snippets_read', - label: 'snippets_read', - description: 'View SQL snippets', - group: 'organization', - }, - { - id: 'analytics_usage_read', - label: 'analytics_usage_read', - description: 'View usage analytics', - group: 'organization', - }, - // Project permissions - { - id: 'projects_read', - label: 'projects_read', - description: 'View project details', - group: 'project', - }, - { - id: 'project_admin_read', - label: 'project_admin_read', - description: 'View project admin settings', - group: 'project', - }, - { - id: 'project_admin_write', - label: 'project_admin_write', - description: 'Manage project admin settings', - group: 'project', - }, - { - id: 'action_runs_read', - label: 'action_runs_read', - description: 'View action runs', - group: 'project', - }, - { - id: 'action_runs_write', - label: 'action_runs_write', - description: 'Manage action runs', - group: 'project', - }, - { - id: 'advisors_read', - label: 'advisors_read', - description: 'View advisor recommendations', - group: 'project', - }, - { - id: 'analytics_logs_read', - label: 'analytics_logs_read', - description: 'View analytics logs', - group: 'project', - }, - { - id: 'api_gateway_keys_read', - label: 'api_gateway_keys_read', - description: 'View API gateway keys', - group: 'project', - }, - { - id: 'api_gateway_keys_write', - label: 'api_gateway_keys_write', - description: 'Manage API gateway keys', - group: 'project', - }, - { - id: 'auth_config_read', - label: 'auth_config_read', - description: 'View auth configuration', - group: 'project', - }, - { - id: 'auth_config_write', - label: 'auth_config_write', - description: 'Manage auth settings', - group: 'project', - }, - { - id: 'auth_signing_keys_read', - label: 'auth_signing_keys_read', - description: 'View auth signing keys', - group: 'project', - }, - { - id: 'auth_signing_keys_write', - label: 'auth_signing_keys_write', - description: 'Manage auth signing keys', - group: 'project', - }, - { id: 'backups_read', label: 'backups_read', description: 'View backups', group: 'project' }, - { id: 'backups_write', label: 'backups_write', description: 'Manage backups', group: 'project' }, - { - id: 'branching_development_read', - label: 'branching_development_read', - description: 'View development branches', - group: 'project', - }, - { - id: 'branching_development_create', - label: 'branching_development_create', - description: 'Create development branches', - group: 'project', - }, - { - id: 'branching_development_write', - label: 'branching_development_write', - description: 'Manage development branches', - group: 'project', - }, - { - id: 'branching_development_delete', - label: 'branching_development_delete', - description: 'Delete development branches', - group: 'project', - }, - { - id: 'branching_production_read', - label: 'branching_production_read', - description: 'View production branches', - group: 'project', - }, - { - id: 'branching_production_create', - label: 'branching_production_create', - description: 'Create production branches', - group: 'project', - }, - { - id: 'branching_production_write', - label: 'branching_production_write', - description: 'Manage production branches', - group: 'project', - }, - { - id: 'branching_production_delete', - label: 'branching_production_delete', - description: 'Delete production branches', - group: 'project', - }, - { - id: 'custom_domain_read', - label: 'custom_domain_read', - description: 'View custom domain settings', - group: 'project', - }, - { - id: 'custom_domain_write', - label: 'custom_domain_write', - description: 'Manage custom domain', - group: 'project', - }, - { - id: 'data_api_config_read', - label: 'data_api_config_read', - description: 'View Data API configuration', - group: 'project', - }, - { - id: 'data_api_config_write', - label: 'data_api_config_write', - description: 'Manage Data API settings', - group: 'project', - }, - { - id: 'database_read', - label: 'database_read', - description: 'Read database data', - group: 'project', - }, - { - id: 'database_write', - label: 'database_write', - description: 'Write database data', - group: 'project', - }, - { - id: 'database_config_read', - label: 'database_config_read', - description: 'View database configuration', - group: 'project', - }, - { - id: 'database_config_write', - label: 'database_config_write', - description: 'Manage database settings', - group: 'project', - }, - { - id: 'database_migrations_read', - label: 'database_migrations_read', - description: 'View database migrations', - group: 'project', - }, - { - id: 'database_migrations_write', - label: 'database_migrations_write', - description: 'Run database migrations', - group: 'project', - }, - { - id: 'database_pooling_config_read', - label: 'database_pooling_config_read', - description: 'View connection pooling settings', - group: 'project', - }, - { - id: 'database_pooling_config_write', - label: 'database_pooling_config_write', - description: 'Manage connection pooling', - group: 'project', - }, - { - id: 'database_ssl_config_read', - label: 'database_ssl_config_read', - description: 'View SSL configuration', - group: 'project', - }, - { - id: 'database_ssl_config_write', - label: 'database_ssl_config_write', - description: 'Manage SSL settings', - group: 'project', - }, - { - id: 'database_webhooks_config_read', - label: 'database_webhooks_config_read', - description: 'View database webhooks', - group: 'project', - }, - { - id: 'database_webhooks_config_write', - label: 'database_webhooks_config_write', - description: 'Manage database webhooks', - group: 'project', - }, - { - id: 'edge_functions_read', - label: 'edge_functions_read', - description: 'View Edge Functions', - group: 'project', - }, - { - id: 'edge_functions_write', - label: 'edge_functions_write', - description: 'Deploy and manage Edge Functions', - group: 'project', - }, - { - id: 'edge_functions_secrets_read', - label: 'edge_functions_secrets_read', - description: 'View Edge Function secrets', - group: 'project', - }, - { - id: 'edge_functions_secrets_write', - label: 'edge_functions_secrets_write', - description: 'Manage Edge Function secrets', - group: 'project', - }, - { - id: 'infra_add_ons_read', - label: 'infra_add_ons_read', - description: 'View infrastructure add-ons', - group: 'project', - }, - { - id: 'infra_add_ons_write', - label: 'infra_add_ons_write', - description: 'Manage infrastructure add-ons', - group: 'project', - }, - { - id: 'infra_disk_config_read', - label: 'infra_disk_config_read', - description: 'View disk configuration', - group: 'project', - }, - { - id: 'infra_disk_config_write', - label: 'infra_disk_config_write', - description: 'Manage disk settings', - group: 'project', - }, - { - id: 'infra_read_replicas_read', - label: 'infra_read_replicas_read', - description: 'View read replicas', - group: 'project', - }, - { - id: 'infra_read_replicas_write', - label: 'infra_read_replicas_write', - description: 'Manage read replicas', - group: 'project', - }, - { - id: 'project_snippets_read', - label: 'project_snippets_read', - description: 'View project SQL snippets', - group: 'project', - }, - { - id: 'project_snippets_write', - label: 'project_snippets_write', - description: 'Manage project SQL snippets', - group: 'project', - }, - { - id: 'storage_read', - label: 'storage_read', - description: 'Read storage objects', - group: 'project', - }, - { - id: 'storage_write', - label: 'storage_write', - description: 'Write storage objects', - group: 'project', - }, - { - id: 'storage_config_read', - label: 'storage_config_read', - description: 'View storage configuration', - group: 'project', - }, - { - id: 'storage_config_write', - label: 'storage_config_write', - description: 'Manage storage settings', - group: 'project', - }, - { - id: 'vanity_subdomain_read', - label: 'vanity_subdomain_read', - description: 'View vanity subdomain settings', - group: 'project', - }, - { - id: 'vanity_subdomain_write', - label: 'vanity_subdomain_write', - description: 'Manage vanity subdomain', - group: 'project', - }, -] +export const PERMISSIONS: Permission[] = Object.values(permissions.FgaPermissions.PROJECT).map( + (perm) => ({ + id: perm.id, + label: perm.id, + description: perm.title, + }) +) diff --git a/apps/studio/components/interfaces/Organization/PrivateApps/Apps/CreateAppSheet/CreateAppSheet.tsx b/apps/studio/components/interfaces/Organization/PrivateApps/Apps/CreateAppSheet/CreateAppSheet.tsx index ebb0cefa032a1..fa60b82be5945 100644 --- a/apps/studio/components/interfaces/Organization/PrivateApps/Apps/CreateAppSheet/CreateAppSheet.tsx +++ b/apps/studio/components/interfaces/Organization/PrivateApps/Apps/CreateAppSheet/CreateAppSheet.tsx @@ -212,7 +212,7 @@ export function CreateAppSheet({ visible, onClose, onCreated }: CreateAppSheetPr No permissions found.
- {PERMISSIONS.filter((p) => p.group === 'project').map((perm) => ( + {PERMISSIONS.map((perm) => ( i.app_id === app?.id) + const installation = installations.find((i) => i.app_id === app?.id) + const isInstalled = installation !== undefined const [showDeleteModal, setShowDeleteModal] = useState(false) const { data: detail, isLoading: isLoadingDetail } = usePlatformAppQuery( @@ -73,7 +74,7 @@ export function ViewAppSheet({ app, visible, onClose, onDeleted }: ViewAppSheetP {app && (
- + setShowDeleteModal(true)} />
diff --git a/apps/studio/components/interfaces/Organization/PrivateApps/Apps/ViewAppSheet/ViewAppSheetInfo.tsx b/apps/studio/components/interfaces/Organization/PrivateApps/Apps/ViewAppSheet/ViewAppSheetInfo.tsx index bbe2845e9b8ee..bee6b4cf05ecd 100644 --- a/apps/studio/components/interfaces/Organization/PrivateApps/Apps/ViewAppSheet/ViewAppSheetInfo.tsx +++ b/apps/studio/components/interfaces/Organization/PrivateApps/Apps/ViewAppSheet/ViewAppSheetInfo.tsx @@ -1,4 +1,7 @@ import { formatDistanceToNow } from 'date-fns' +import { Check, Copy } from 'lucide-react' +import { useState } from 'react' +import { toast } from 'sonner' import { Badge, Card, @@ -9,15 +12,48 @@ import { TableHead, TableHeader, TableRow, + copyToClipboard, } from 'ui' -import type { PrivateApp } from '../../PrivateAppsContext' +import type { Installation, PrivateApp } from '../../PrivateAppsContext' interface ViewAppSheetInfoProps { app: PrivateApp isInstalled: boolean + installation: Installation | undefined } -export function ViewAppSheetInfo({ app, isInstalled }: ViewAppSheetInfoProps) { +function CopyableId({ id, label }: { id: string; label: string }) { + const [isCopied, setIsCopied] = useState(false) + + function handleCopy(e: React.MouseEvent) { + e.stopPropagation() + copyToClipboard(id, () => { + setIsCopied(true) + toast.success(`Copied ${label} to clipboard`) + setTimeout(() => setIsCopied(false), 2000) + }) + } + + return ( + + ) +} + +export function ViewAppSheetInfo({ app, isInstalled, installation }: ViewAppSheetInfoProps) { return (

Metadata

@@ -26,10 +62,10 @@ export function ViewAppSheetInfo({ app, isInstalled }: ViewAppSheetInfoProps) { - + Field - + Value @@ -69,6 +105,26 @@ export function ViewAppSheetInfo({ app, isInstalled }: ViewAppSheetInfoProps) { )} + + +

App ID

+
+ + + +
+ + +

Installation ID

+
+ + {installation ? ( + + ) : ( +

+ )} +
+
diff --git a/apps/studio/components/interfaces/Organization/PrivateApps/Apps/ViewAppSheet/ViewAppSheetPermissions.tsx b/apps/studio/components/interfaces/Organization/PrivateApps/Apps/ViewAppSheet/ViewAppSheetPermissions.tsx index eb63be23a6d79..0211186632a6b 100644 --- a/apps/studio/components/interfaces/Organization/PrivateApps/Apps/ViewAppSheet/ViewAppSheetPermissions.tsx +++ b/apps/studio/components/interfaces/Organization/PrivateApps/Apps/ViewAppSheet/ViewAppSheetPermissions.tsx @@ -27,10 +27,10 @@ export function ViewAppSheetPermissions({ permissions, isLoading }: ViewAppSheet - + Permission - + Description diff --git a/apps/studio/components/interfaces/Realtime/Inspector/EmptyRealtime.tsx b/apps/studio/components/interfaces/Realtime/Inspector/EmptyRealtime.tsx index 721825b7a04ff..2d0b725d1cb9d 100644 --- a/apps/studio/components/interfaces/Realtime/Inspector/EmptyRealtime.tsx +++ b/apps/studio/components/interfaces/Realtime/Inspector/EmptyRealtime.tsx @@ -6,6 +6,7 @@ import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state' import { AiIconAnimation, Button, Card, cn } from 'ui' import { AnimatedCursors } from './AnimatedCursors' +import { DocsButton } from '@/components/ui/DocsButton' /** * Acts as a container component for the entire log display @@ -93,13 +94,10 @@ export const EmptyRealtime = ({ projectRef }: { projectRef: string }) => {

Receive realtime messages in your application by listening to a channel

- + diff --git a/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GitHubIntegrationConnectionForm.tsx b/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GitHubIntegrationConnectionForm.tsx index 1d9d98afe9d6c..2d1208afb4557 100644 --- a/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GitHubIntegrationConnectionForm.tsx +++ b/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GitHubIntegrationConnectionForm.tsx @@ -1,6 +1,6 @@ import { zodResolver } from '@hookform/resolvers/zod' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { IS_PLATFORM } from 'common' +import { InlineLink } from 'components/ui/InlineLink' import { useBranchCreateMutation } from 'data/branches/branch-create-mutation' import { useBranchUpdateMutation } from 'data/branches/branch-update-mutation' import { useBranchesQuery } from 'data/branches/branches-query' @@ -11,6 +11,7 @@ import { useGitHubConnectionDeleteMutation } from 'data/integrations/github-conn import { useGitHubConnectionUpdateMutation } from 'data/integrations/github-connection-update-mutation' import { useGitHubRepositoriesQuery } from 'data/integrations/github-repositories-query' import type { GitHubConnection } from 'data/integrations/integrations.types' +import { AnimatePresence, motion } from 'framer-motion' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' @@ -22,9 +23,6 @@ import { useEffect, useMemo, useState } from 'react' import { useForm } from 'react-hook-form' import { toast } from 'sonner' import { - Alert_Shadcn_, - AlertDescription_Shadcn_, - AlertTitle_Shadcn_, Button, Card, CardContent, @@ -46,10 +44,12 @@ import { PopoverTrigger_Shadcn_, Switch, } from 'ui' -import { InlineLink } from 'components/ui/InlineLink' +import { Admonition } from 'ui-patterns' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import * as z from 'zod' + +import { UpgradeToPro } from '@/components/ui/UpgradeToPro' import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements' const GITHUB_ICON = ( @@ -65,12 +65,10 @@ const GITHUB_ICON = ( ) interface GitHubIntegrationConnectionFormProps { - disabled?: boolean connection?: GitHubConnection } -const GitHubIntegrationConnectionForm = ({ - disabled = false, +export const GitHubIntegrationConnectionForm = ({ connection, }: GitHubIntegrationConnectionFormProps) => { const { data: selectedProject } = useSelectedProjectQuery() @@ -82,8 +80,8 @@ const GitHubIntegrationConnectionForm = ({ const { hasAccess: hasAccessToGitHubIntegration, isLoading: isLoadingEntitlements } = useCheckEntitlements('integrations.github_connections') - const promptProPlanUpgrade = - IS_PLATFORM && !isLoadingEntitlements && !hasAccessToGitHubIntegration + + const { hasAccess: hasAccessToBranching } = useCheckEntitlements('branching_limit') const { can: canUpdateGitHubConnection } = useAsyncCheckPermissions( PermissionAction.UPDATE, @@ -460,7 +458,7 @@ const GitHubIntegrationConnectionForm = ({ - )} - -
- {githubSettingsForm.formState.isDirty && ( - - )} - -
- + ( + + + + + + )} + /> + + ( + + + field.onChange(val)} + disabled={ + !hasAccessToBranching || + !newBranchPerPr || + !canUpdateGitHubConnection + } + /> + + + )} + /> + + + + +
+ {connection && ( + + )} +
+
+ {githubSettingsForm.formState.isDirty && ( + + )} + +
+
+ + )} + @@ -801,5 +830,3 @@ const GitHubIntegrationConnectionForm = ({ ) } - -export default GitHubIntegrationConnectionForm diff --git a/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GithubSection.tsx b/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GithubSection.tsx index 9ce834daa0b44..f13f091d7c0d7 100644 --- a/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GithubSection.tsx +++ b/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GithubSection.tsx @@ -1,7 +1,4 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import { useMemo } from 'react' - -import { useGitHubAuthorizationQuery } from '@/data/integrations/github-authorization-query' import { useParams } from 'common' import { ScaffoldContainer, @@ -10,13 +7,14 @@ import { ScaffoldSectionDetail, } from 'components/layouts/Scaffold' import NoPermission from 'components/ui/NoPermission' -import { UpgradeToPro } from 'components/ui/UpgradeToPro' import { useGitHubConnectionsQuery } from 'data/integrations/github-connections-query' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' -import { BASE_PATH, IS_PLATFORM } from 'lib/constants' +import { BASE_PATH } from 'lib/constants' +import { useMemo } from 'react' import { GenericSkeletonLoader } from 'ui-patterns' -import GitHubIntegrationConnectionForm from './GitHubIntegrationConnectionForm' + +import { GitHubIntegrationConnectionForm } from './GitHubIntegrationConnectionForm' const IntegrationImageHandler = ({ title }: { title: 'vercel' | 'github' }) => { return ( @@ -35,11 +33,6 @@ export const GitHubSection = () => { const { can: canReadGitHubConnection, isLoading: isLoadingPermissions } = useAsyncCheckPermissions(PermissionAction.READ, 'integrations.github_connections') - const isProPlanAndUp = organization?.plan?.id !== 'free' - const promptProPlanUpgrade = IS_PLATFORM && !isProPlanAndUp - - const { data: gitHubAuthorization } = useGitHubAuthorizationQuery() - const { data: connections } = useGitHubConnectionsQuery( { organizationId: organization?.id }, { enabled: !!projectRef && !!organization?.id } @@ -66,35 +59,13 @@ export const GitHubSection = () => { ) : (
-
-
How does the GitHub integration work?
-

- Connecting to GitHub allows you to sync preview branches with a chosen GitHub - branch, keep your production branch in sync, and automatically create preview - branches for every pull request. -

- - {promptProPlanUpgrade && ( -
- -
- )} - - {/* [Joshen] Show connection form if GH has already been authorized OR no GH authorization but on a paid plan */} - {/* So this shouldn't render if there's no GH authorization and on a free plan */} - {(!!gitHubAuthorization || !promptProPlanUpgrade) && ( - - )} -
+
How does the GitHub integration work?
+

+ Connecting to GitHub allows you to sync preview branches with a chosen GitHub + branch, keep your production branch in sync, and automatically create preview + branches for every pull request. +

+
)} diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils.tsx index bc8dbe73db08f..0459fd3c838b6 100644 --- a/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils.tsx +++ b/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils.tsx @@ -1,10 +1,10 @@ import { toast } from 'sonner' - -import { StorageObject } from 'data/storage/bucket-objects-list-mutation' import { copyToClipboard } from 'ui' + import { inverseValidObjectKeyRegex, validObjectKeyRegex } from '../CreateBucketModal.utils' import { STORAGE_ROW_STATUS, STORAGE_ROW_TYPES } from '../Storage.constants' import { StorageItem, StorageItemMetadata } from '../Storage.types' +import type { StorageObject } from '@/data/storage/bucket-objects-list-mutation' import type { StorageExplorerState } from '@/state/storage-explorer' type UploadProgress = { @@ -164,7 +164,8 @@ export const formatFolderItems = (items: StorageObject[] = [], prefix?: string): .map((item) => { const type = item.id ? STORAGE_ROW_TYPES.FILE : STORAGE_ROW_TYPES.FOLDER - const durationSinceCreated = Number(new Date()) - Number(new Date(item.created_at)) + const durationSinceCreated = + Number(new Date()) - Number(item.created_at ? new Date(item.created_at) : new Date()) const isCorrupted = type === STORAGE_ROW_TYPES.FILE && !item.metadata && diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.tsx index ea89ce6b8ae1d..563580f547545 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.tsx @@ -50,7 +50,7 @@ import { } from './ColumnEditor.utils' import ColumnForeignKey from './ColumnForeignKey' import ColumnType from './ColumnType' -import HeaderTitle from './HeaderTitle' +import { HeaderTitle } from './HeaderTitle' export interface ColumnEditorProps { column?: Readonly diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/HeaderTitle.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/HeaderTitle.tsx index 7a2604fe661f6..f7631bd521450 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/HeaderTitle.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/HeaderTitle.tsx @@ -1,25 +1,23 @@ -import type { PostgresTable, PostgresColumn } from '@supabase/postgres-meta' +import type { PostgresColumn, PostgresTable } from '@supabase/postgres-meta' interface Props { table: PostgresTable column: PostgresColumn } -// Need to fix for new column later -const HeaderTitle: React.FC = ({ table, column }) => { +export const HeaderTitle = ({ table, column }: Props) => { if (!column) { return ( <> Add new column to - {table.name} + {table.name} ) } return ( <> - Update column {column.name} from {column.table} + Update column {column.name} from{' '} + {column.table} ) } - -export default HeaderTitle diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/HeaderTitle.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/HeaderTitle.tsx index 24cd1c31fec9f..c92b0b56c5db8 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/HeaderTitle.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/HeaderTitle.tsx @@ -8,7 +8,7 @@ export const HeaderTitle = ({ isNewRecord, tableName }: HeaderTitleProps) => { return ( {header} - {tableName && {tableName}} + {tableName && {tableName}} ) } diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx index 13a81de1bd747..aaf6bc56c329f 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx @@ -2,11 +2,7 @@ import * as Sentry from '@sentry/nextjs' import type { PostgresColumn, PostgresTable } from '@supabase/postgres-meta' import { useQueryClient } from '@tanstack/react-query' import { useParams } from 'common' -import { - queueCellEditWithOptimisticUpdate, - queueRowAddWithOptimisticUpdate, -} from 'components/grid/utils/queueOperationUtils' -import { useIsQueueOperationsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' +import { useTableRowOperations } from 'components/grid/hooks/useTableRowOperations' import { type GeneratedPolicy } from 'components/interfaces/Auth/Policies/Policies.utils' import { DiscardChangesConfirmationDialog } from 'components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog' import { databasePoliciesKeys } from 'data/database-policies/keys' @@ -23,8 +19,6 @@ import { privilegeKeys } from 'data/privileges/keys' import { tableEditorKeys } from 'data/table-editor/keys' import { isTableLike, type Entity } from 'data/table-editor/table-editor-types' import { tableRowKeys } from 'data/table-rows/keys' -import { useTableRowCreateMutation } from 'data/table-rows/table-row-create-mutation' -import { useTableRowUpdateMutation } from 'data/table-rows/table-row-update-mutation' import { tableKeys } from 'data/tables/keys' import { RetrieveTableResult } from 'data/tables/table-retrieve-query' import { getTables } from 'data/tables/tables-query' @@ -36,7 +30,6 @@ import { useTrack } from 'lib/telemetry/track' import { isEmpty, isUndefined, noop } from 'lodash' import { useState } from 'react' import { toast } from 'sonner' -import { useGetImpersonatedRoleState } from 'state/role-impersonation-state' import { useTableEditorStateSnapshot, type TableEditorState } from 'state/table-editor' import { createTabId, useTabsStateSnapshot } from 'state/tabs' import type { Dictionary } from 'types' @@ -202,10 +195,8 @@ export const SidePanelEditor = ({ const queryClient = useQueryClient() const { data: project } = useSelectedProjectQuery() const { data: org } = useSelectedOrganizationQuery() - const getImpersonatedRoleState = useGetImpersonatedRoleState() - const isApiGrantTogglesEnabled = useDataApiGrantTogglesEnabled() - const isQueueOperationsEnabled = useIsQueueOperationsEnabled() + const { updateRow, addRow, isEditPending } = useTableRowOperations() const [isEdited, setIsEdited] = useState(false) @@ -241,16 +232,6 @@ export const SidePanelEditor = ({ }) .map((column) => column.name) - const { mutateAsync: createTableRows } = useTableRowCreateMutation({ - onSuccess() { - toast.success('Successfully created row') - }, - }) - const { mutateAsync: updateTableRow, isPending: isUpdatingRow } = useTableRowUpdateMutation({ - onSuccess() { - toast.success('Successfully updated row') - }, - }) const { mutateAsync: createPublication } = useDatabasePublicationCreateMutation() const { mutateAsync: updatePublication } = useDatabasePublicationUpdateMutation({ onError: () => {}, @@ -273,32 +254,13 @@ export const SidePanelEditor = ({ let saveRowError: Error | undefined if (isNewRecord) { - // Queue the ADD_ROW operation if queue operations feature is enabled - if (isQueueOperationsEnabled && selectedTable.primary_keys.length > 0) { - queueRowAddWithOptimisticUpdate({ - queueOperation: snap.queueOperation, + try { + await addRow({ tableId: selectedTable.id, table: selectedTable as unknown as Entity, rowData: payload, enumArrayColumns, }) - - // Close panel immediately without error - onComplete() - setIsEdited(false) - if (!configuration.createMore) snap.closeSidePanel() - return - } - - try { - await createTableRows({ - projectRef: project.ref, - connectionString: project.connectionString, - table: selectedTable, - payload, - enumArrayColumns, - roleImpersonationState: getImpersonatedRoleState(), - }) } catch (error: any) { saveRowError = error } @@ -306,56 +268,24 @@ export const SidePanelEditor = ({ const hasChanges = !isEmpty(payload) if (hasChanges) { if (selectedTable.primary_keys.length > 0) { - // Queue the operation if queue operations feature is enabled - if (isQueueOperationsEnabled) { - const changedColumn = Object.keys(payload)[0] - if (!changedColumn) { - saveRowError = new Error('No changed column') - toast.error('No changed column') - onComplete(saveRowError) - return - } - - const row = getRowFromSidePanel(snap.sidePanel) + const row = getRowFromSidePanel(snap.sidePanel) - if (!row) { - saveRowError = new Error('No row found') - toast.error('No row found') - onComplete(saveRowError) - return - } - - const oldValue = row[changedColumn] + if (!row) { + saveRowError = new Error('No row found') + toast.error('No row found') + onComplete(saveRowError) + return + } - queueCellEditWithOptimisticUpdate({ - queueOperation: snap.queueOperation, + try { + await updateRow({ tableId: selectedTable.id, - // Cast to Entity - the queue save mutation only uses id, name, schema table: selectedTable as unknown as Entity, row, rowIdentifiers: configuration.identifiers, - columnName: changedColumn, - oldValue: oldValue, - newValue: payload[changedColumn], - enumArrayColumns, - }) - - // Close panel immediately without error - onComplete() - setIsEdited(false) - snap.closeSidePanel() - return - } - - try { - await updateTableRow({ - projectRef: project.ref, - connectionString: project.connectionString, - table: selectedTable, - configuration, payload, enumArrayColumns, - roleImpersonationState: getImpersonatedRoleState(), + onSuccess: () => toast.success('Successfully updated row'), }) } catch (error: any) { saveRowError = error @@ -1053,7 +983,7 @@ export const SidePanelEditor = ({ ? snap.sidePanel.foreignKey.foreignKey : undefined } - isSaving={isUpdatingRow} + isSaving={isEditPending} closePanel={onClosePanel} onSelect={onSaveForeignRow} /> diff --git a/apps/studio/components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout.tsx b/apps/studio/components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout.tsx index ae3565f011043..c65b7130fbfe9 100644 --- a/apps/studio/components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout.tsx +++ b/apps/studio/components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout.tsx @@ -37,6 +37,7 @@ import { PopoverTrigger_Shadcn_, Separator, } from 'ui' +import { TimestampInfo } from 'ui-patterns' import { Input } from 'ui-patterns/DataInputs/Input' import { PageHeader, @@ -293,23 +294,35 @@ const EdgeFunctionDetailsLayout = ({ - + {createdRelative && (

Created

-

{createdRelative}

+ {!!selectedFunction && ( + + )}
)} {updatedRelative && (

Last deployed

-

{updatedRelative}

+ {!!selectedFunction && ( + + )}
)} {selectedFunction?.version !== undefined && (

Deployments

-

{selectedFunction.version}

+

{selectedFunction.version}

)}
diff --git a/apps/studio/components/ui/UpgradeToPro.tsx b/apps/studio/components/ui/UpgradeToPro.tsx index e7059d417d9f4..c7e8b5ebf28f0 100644 --- a/apps/studio/components/ui/UpgradeToPro.tsx +++ b/apps/studio/components/ui/UpgradeToPro.tsx @@ -1,7 +1,8 @@ import { ReactNode } from 'react' - import { cn } from 'ui' import { Admonition } from 'ui-patterns' + +import { DocsButton } from './DocsButton' import { UpgradePlanButton } from './UpgradePlanButton' interface UpgradeToProProps { @@ -21,6 +22,7 @@ interface UpgradeToProProps { layout?: 'vertical' | 'horizontal' variant?: 'default' | 'primary' className?: string + docsUrl?: string } export const UpgradeToPro = ({ @@ -37,6 +39,7 @@ export const UpgradeToPro = ({ layout = 'horizontal', variant = 'primary', className, + docsUrl, }: UpgradeToProProps) => { return ( - {buttonText} - + <> + + {buttonText} + + {!!docsUrl && } + } /> ) diff --git a/apps/studio/lib/api/filterHelpers.test.ts b/apps/studio/lib/api/filterHelpers.test.ts index b54d120e1cf62..0272c3692fb4e 100644 --- a/apps/studio/lib/api/filterHelpers.test.ts +++ b/apps/studio/lib/api/filterHelpers.test.ts @@ -1,7 +1,6 @@ import { describe, expect, test } from 'vitest' import { - enforceAndLogicalOperator, FilterGroupType, isFilterGroup, serializeOperators, @@ -207,59 +206,3 @@ describe('validateFilterGroup', () => { expect(validateFilterGroup(group, properties)).toBe(true) }) }) - -describe('enforceAndLogicalOperator', () => { - test('converts OR to AND at root level', () => { - const group: FilterGroupType = { - logicalOperator: 'OR', - conditions: [{ propertyName: 'name', operator: '=', value: 'test' }], - } - const result = enforceAndLogicalOperator(group) - expect(result.logicalOperator).toBe('AND') - }) - - test('preserves conditions when converting', () => { - const condition = { propertyName: 'name', operator: '=', value: 'test' } - const group: FilterGroupType = { - logicalOperator: 'OR', - conditions: [condition], - } - const result = enforceAndLogicalOperator(group) - expect(result.conditions).toEqual([condition]) - }) - - test('recursively converts nested groups to AND', () => { - const group: FilterGroupType = { - logicalOperator: 'OR', - conditions: [ - { propertyName: 'name', operator: '=', value: 'test' }, - { - logicalOperator: 'OR', - conditions: [ - { propertyName: 'age', operator: '>', value: 18 }, - { - logicalOperator: 'OR', - conditions: [{ propertyName: 'active', operator: '=', value: true }], - }, - ], - }, - ], - } - const result = enforceAndLogicalOperator(group) - - expect(result.logicalOperator).toBe('AND') - expect((result.conditions[1] as FilterGroupType).logicalOperator).toBe('AND') - expect( - ((result.conditions[1] as FilterGroupType).conditions[1] as FilterGroupType).logicalOperator - ).toBe('AND') - }) - - test('handles empty conditions array', () => { - const group: FilterGroupType = { - logicalOperator: 'OR', - conditions: [], - } - const result = enforceAndLogicalOperator(group) - expect(result).toEqual({ logicalOperator: 'AND', conditions: [] }) - }) -}) diff --git a/apps/studio/lib/api/filterHelpers.ts b/apps/studio/lib/api/filterHelpers.ts index 668bd505a387b..72e6ed8876399 100644 --- a/apps/studio/lib/api/filterHelpers.ts +++ b/apps/studio/lib/api/filterHelpers.ts @@ -46,6 +46,11 @@ export const filterGroupSchema: z.ZodType = z.lazy(() => }) ) +export const filterGroupSchemaForAI = z.object({ + logicalOperator: z.literal('AND'), + conditions: z.array(filterConditionSchema), +}) + export const requestSchema = z.object({ prompt: z.string().min(1, 'Prompt is required'), filterProperties: z @@ -80,15 +85,6 @@ export function validateFilterGroup( }) } -export function enforceAndLogicalOperator(group: FilterGroupType): FilterGroupType { - return { - logicalOperator: 'AND', - conditions: group.conditions.map((condition) => - isFilterGroup(condition) ? enforceAndLogicalOperator(condition) : condition - ), - } -} - export function serializeOptions( options?: z.infer['options'] ): string[] | undefined { diff --git a/apps/studio/pages/api/ai/sql/filter-v1.ts b/apps/studio/pages/api/ai/sql/filter-v1.ts index fd0ecb294b3fe..8a9f30cc019ea 100644 --- a/apps/studio/pages/api/ai/sql/filter-v1.ts +++ b/apps/studio/pages/api/ai/sql/filter-v1.ts @@ -3,8 +3,7 @@ import { source } from 'common-tags' import { getModel } from 'lib/ai/model' import apiWrapper from 'lib/api/apiWrapper' import { - enforceAndLogicalOperator, - filterGroupSchema, + filterGroupSchemaForAI, requestSchema, serializeOperators, serializeOptions, @@ -65,7 +64,7 @@ export async function handlePost(req: NextApiRequest, res: NextApiResponse) { const result = await generateText({ model, providerOptions, - output: Output.object({ schema: filterGroupSchema }), + output: Output.object({ schema: filterGroupSchemaForAI }), prompt: source` You are an expert Postgres filter builder. Convert the user's request into structured filters. @@ -74,7 +73,6 @@ export async function handlePost(req: NextApiRequest, res: NextApiResponse) { Rules: - Use only the provided property names and operators for each property. - - Prefer logical operator "AND" unless the user explicitly asks for "OR". - When unsure, default to simple equality comparisons with reasonable values. - Values should respect property types: booleans must be true/false, dates should be ISO date strings (YYYY-MM-DD), and numbers must be numbers. - If options are provided for a property, choose from those values when appropriate. @@ -92,7 +90,7 @@ export async function handlePost(req: NextApiRequest, res: NextApiResponse) { }) } - return res.json(enforceAndLogicalOperator(generatedFilters)) + return res.json(generatedFilters) } catch (error) { if (error instanceof Error) { console.error(`AI filter generation failed: ${error.message}`) diff --git a/apps/studio/tests/components/Billing/UpdateBillingAddressModal.test.tsx b/apps/studio/tests/components/Billing/UpdateBillingAddressModal.test.tsx index 0099dd5b28886..743e9829ff303 100644 --- a/apps/studio/tests/components/Billing/UpdateBillingAddressModal.test.tsx +++ b/apps/studio/tests/components/Billing/UpdateBillingAddressModal.test.tsx @@ -175,17 +175,4 @@ describe('UpdateBillingAddressModal', () => { render() expect(screen.queryByText('Save address')).not.toBeInTheDocument() }) - - it('dismisses on close button click', async () => { - render() - expect(await screen.findByText('Billing address required')).toBeInTheDocument() - - // Click the X (close) button in DialogContent - const closeButton = screen.getByRole('button', { name: /close/i }) - await userEvent.click(closeButton) - - await waitFor(() => { - expect(screen.queryByText('Billing address required')).not.toBeInTheDocument() - }) - }) }) diff --git a/apps/www/_go/events/ai-engineer-europe-2026/contest.tsx b/apps/www/_go/events/ai-engineer-europe-2026/contest.tsx index 228c77c131bea..12c8d7708a200 100644 --- a/apps/www/_go/events/ai-engineer-europe-2026/contest.tsx +++ b/apps/www/_go/events/ai-engineer-europe-2026/contest.tsx @@ -63,7 +63,7 @@ const page: GoPageInput = { target="_blank" rel="noopener noreferrer" > - Download Slides + Download slides diff --git a/apps/www/_go/events/dash-2026/contest.tsx b/apps/www/_go/events/dash-2026/contest.tsx new file mode 100644 index 0000000000000..fe6dc4a9057fb --- /dev/null +++ b/apps/www/_go/events/dash-2026/contest.tsx @@ -0,0 +1,103 @@ +import type { GoPageInput } from 'marketing' +import Image from 'next/image' +import Link from 'next/link' +import { Button } from 'ui' + +import authors from '@/lib/authors.json' + +const speaker = authors.find((a) => a.author_id === 'sugu_sougoumarane') + +const page: GoPageInput = { + template: 'lead-gen', + slug: 'dash-2026/contest', + metadata: { + title: 'Win a MacBook Neo | Supabase at DASH 2026', + description: + 'Create a Supabase account and load data for a chance to win a MacBook Neo. DASH 2026.', + }, + hero: { + title: 'Win a MacBook Neo', + subtitle: 'Supabase at DASH 2026', + description: + "Great meeting you at DASH. Try Supabase if you haven't already -- it's Postgres with all the tools you need to build AI-native applications. We're running a sweepstakes with a MacBook Neo as the prize.", + image: { + src: '/images/landing-pages/sxsw-2026/macbook-neo.png', + alt: 'MacBook Neo in four colors', + width: 500, + height: 333, + }, + ctas: [ + { + label: 'Get started', + href: '#how-to-enter', + variant: 'primary', + }, + ], + }, + sections: [ + { + type: 'single-column', + title: 'Conference talk', + description: 'DASH 2026', + children: ( +
+ {speaker?.author_image_url && ( + + )} +
+

+ {speaker?.author} + {speaker?.position && `, ${speaker.position}`} +

+

Supabase

+
+ +
+ ), + }, + { + type: 'single-column', + id: 'how-to-enter', + title: 'How to enter', + children: ( +
+
    +
  1. Visit the Supabase booth at DASH 2026 and get scanned
  2. +
  3. + Create a Supabase account with the same email address where you got our post-event + note +
  4. +
  5. Load data into a Supabase database
  6. +
  7. Complete these steps by the deadline in your post-event note
  8. +
+ +

+ No purchase necessary. Void where prohibited.{' '} + + Official rules + + . +

+
+ ), + }, + ], +} + +export default page diff --git a/apps/www/_go/events/dash-2026/exec-dinner-thank-you.tsx b/apps/www/_go/events/dash-2026/exec-dinner-thank-you.tsx new file mode 100644 index 0000000000000..e4f63c6081c98 --- /dev/null +++ b/apps/www/_go/events/dash-2026/exec-dinner-thank-you.tsx @@ -0,0 +1,34 @@ +import type { GoPageInput } from 'marketing' +import Link from 'next/link' +import { Button } from 'ui' + +const page: GoPageInput = { + template: 'thank-you', + slug: 'dash-2026/exec-dinner/thank-you', + metadata: { + title: "You're confirmed | Supabase Executive Dinner", + description: + 'Your RSVP for the Supabase executive dinner at DASH 2026 has been confirmed. Venue and date to be announced.', + }, + hero: { + title: "You're confirmed", + description: + "We'll send details including venue, date, and directions closer to the event. We look forward to seeing you.", + }, + sections: [ + { + type: 'single-column', + title: 'In the meantime', + description: 'Learn more about what we are building at Supabase.', + children: ( +
+ +
+ ), + }, + ], +} + +export default page diff --git a/apps/www/_go/events/dash-2026/exec-dinner.tsx b/apps/www/_go/events/dash-2026/exec-dinner.tsx new file mode 100644 index 0000000000000..e030b0632f3fb --- /dev/null +++ b/apps/www/_go/events/dash-2026/exec-dinner.tsx @@ -0,0 +1,140 @@ +import type { GoPageInput } from 'marketing' +import Image from 'next/image' + +import authors from '@/lib/authors.json' + +const sugu = authors.find((a) => a.author_id === 'sugu_sougoumarane') + +const page: GoPageInput = { + template: 'lead-gen', + slug: 'dash-2026/exec-dinner', + metadata: { + title: 'Executive Dinner | Supabase at DASH 2026', + description: + 'Join Supabase leaders for an intimate dinner. Location to be announced. Cocktails at 6:30 PM, dinner at 7:00 PM.', + }, + hero: { + title: 'The future of scalable databases', + subtitle: 'An intimate executive dinner hosted by Supabase', + description: + 'Join Supabase product and engineering leaders for a dinner conversation about where Postgres is headed -- from scaling beyond single-node limits to managing globally distributed workloads. Expect sharp perspectives, good food, and the opportunity to connect with other engineering leaders.', + ctas: [ + { + label: 'Reserve your seat', + href: '#rsvp', + variant: 'primary', + }, + ], + }, + sections: [ + { + type: 'single-column', + title: 'Details', + children: ( +
+

Location

+

To be announced

+

Schedule

+

6:30 PM — Cocktails and introductions

+

7:00 PM — Dinner and discussion

+
+ ), + }, + { + type: 'single-column', + title: 'Your hosts', + children: ( +
+
+
+ {sugu?.author_image_url && ( + + )} +
+

{sugu?.author}

+

+ {sugu?.position && `${sugu.position}, `}Supabase +

+
+
+
+
+ TBA +
+
+

To be announced

+

Supabase

+
+
+
+
+ ), + }, + { + type: 'form', + id: 'rsvp', + title: 'Reserve your seat', + description: "Space is limited. Let us know you're coming.", + fields: [ + { + type: 'text', + name: 'first_name', + label: 'First Name', + placeholder: 'First Name', + required: true, + half: true, + }, + { + type: 'text', + name: 'last_name', + label: 'Last Name', + placeholder: 'Last Name', + required: true, + half: true, + }, + { + type: 'email', + name: 'email_address', + label: 'Email', + placeholder: 'Work email', + required: true, + }, + { + type: 'text', + name: 'company_name', + label: 'Company', + placeholder: 'Company name', + required: true, + }, + ], + submitLabel: 'Confirm RSVP', + successRedirect: '/go/dash-2026/exec-dinner/thank-you', + disclaimer: + 'By submitting this form, I confirm that I have read and understood the [Privacy Policy](https://supabase.com/privacy).', + crm: { + hubspot: { + formGuid: 'e8c8bb70-4edc-46d7-b752-df18001bb40d', + fieldMap: { + first_name: 'firstname', + last_name: 'lastname', + email_address: 'email', + company_name: 'company', + }, + consent: + 'By submitting this form, I confirm that I have read and understood the Privacy Policy.', + }, + }, + }, + ], +} + +export default page diff --git a/apps/www/_go/events/mcp-dev-nyc-2026/contest.tsx b/apps/www/_go/events/mcp-dev-nyc-2026/contest.tsx index 62dcb0d066a63..b103e1e3c42bd 100644 --- a/apps/www/_go/events/mcp-dev-nyc-2026/contest.tsx +++ b/apps/www/_go/events/mcp-dev-nyc-2026/contest.tsx @@ -63,7 +63,7 @@ const page: GoPageInput = { target="_blank" rel="noopener noreferrer" > - Download Slides + Download slides diff --git a/apps/www/_go/events/pgconf-dev-2026/contest.tsx b/apps/www/_go/events/pgconf-dev-2026/contest.tsx index dff7bb04e2c18..194910dddc404 100644 --- a/apps/www/_go/events/pgconf-dev-2026/contest.tsx +++ b/apps/www/_go/events/pgconf-dev-2026/contest.tsx @@ -63,7 +63,7 @@ const page: GoPageInput = { target="_blank" rel="noopener noreferrer" > - Download Slides + Download slides diff --git a/apps/www/_go/events/postgresconf-sjc-2026/contest.tsx b/apps/www/_go/events/postgresconf-sjc-2026/contest.tsx index 02a0a75787624..3c9a9ee01218c 100644 --- a/apps/www/_go/events/postgresconf-sjc-2026/contest.tsx +++ b/apps/www/_go/events/postgresconf-sjc-2026/contest.tsx @@ -90,7 +90,7 @@ const page: GoPageInput = { target="_blank" rel="noopener noreferrer" > - Download Slides + Download slides diff --git a/apps/www/_go/events/stripe-sessions-2026/exec-dinner.tsx b/apps/www/_go/events/stripe-sessions-2026/exec-dinner.tsx index a0a2a22a0c06a..e68167c6ca966 100644 --- a/apps/www/_go/events/stripe-sessions-2026/exec-dinner.tsx +++ b/apps/www/_go/events/stripe-sessions-2026/exec-dinner.tsx @@ -1,4 +1,11 @@ import type { GoPageInput } from 'marketing' +import Image from 'next/image' + +import authors from '@/lib/authors.json' + +const sugu = authors.find((a) => a.author_id === 'sugu_sougoumarane') +const deepthi = authors.find((a) => a.author_id === 'deepthi_sigireddi') +const joe = authors.find((a) => a.author_id === 'joe_sciarrino') const page: GoPageInput = { template: 'lead-gen', @@ -41,21 +48,56 @@ const page: GoPageInput = { children: (
-
-

Nate Asp

-

VP, Supabase

+
+ {sugu?.author_image_url && ( + + )} +
+

{sugu?.author}

+

+ {sugu?.position && `${sugu.position}, `}Supabase +

+
-
-

Deepthi Sigireddi

-

- Head of Databases, Supabase -

+
+ {deepthi?.author_image_url && ( + + )} +
+

{deepthi?.author}

+

+ {deepthi?.position && `${deepthi.position}, `}Supabase +

+
-
-

Sugu Sougoumarane

-

- Head of Multigres, Supabase -

+
+ {joe?.author_image_url && ( + + )} +
+

{joe?.author}

+

+ {joe?.position} +

+
diff --git a/apps/www/_go/index.tsx b/apps/www/_go/index.tsx index 3ba227d751247..d7047b8efd48f 100644 --- a/apps/www/_go/index.tsx +++ b/apps/www/_go/index.tsx @@ -1,28 +1,28 @@ import type { GoPageInput } from 'marketing' -import byocEarlyAccess from './pre-release/byoc-early-access' -import figmaWebinarMay2026 from './webinar/figma-webinar-may2026' -import figmaWebinarMay2026ThankYou from './webinar/figma-webinar-may2026-thankyou' -import boltWebinar from './webinar/bolt-webinar' -import boltWebinarThankYou from './webinar/bolt-webinar-thank-you' -import amoe from './legal/amoe' -import amoeThankYou from './legal/amoe-thankyou' -import contestRules from './legal/contest-rules' -import stripeExecDinner from './events/stripe-sessions-2026/exec-dinner' -import stripeExecDinnerThankYou from './events/stripe-sessions-2026/exec-dinner-thank-you' -import stripeSessionsContest from './events/stripe-sessions-2026/contest' -import sxswContest from './events/sxsw-2026/contest' import accentureContest from './events/accenture-reinvention-2026/contest' -import postgresconfContest from './events/postgresconf-sjc-2026/contest' -import postgresconfContestThankYou from './events/postgresconf-sjc-2026/contest-thank-you' +import aiEngineerEuropeContest from './events/ai-engineer-europe-2026/contest' +import aiEngineerEuropeContestThankYou from './events/ai-engineer-europe-2026/contest-thank-you' import mcpDevSummitContest from './events/mcp-dev-nyc-2026/contest' import mcpDevSummitContestThankYou from './events/mcp-dev-nyc-2026/contest-thank-you' import pgconfDevContest from './events/pgconf-dev-2026/contest' import pgconfDevContestThankYou from './events/pgconf-dev-2026/contest-thank-you' -import aiEngineerEuropeContest from './events/ai-engineer-europe-2026/contest' -import aiEngineerEuropeContestThankYou from './events/ai-engineer-europe-2026/contest-thank-you' +import postgresconfContest from './events/postgresconf-sjc-2026/contest' +import postgresconfContestThankYou from './events/postgresconf-sjc-2026/contest-thank-you' import startupGrindContest from './events/startup-grind-2026/contest' +import stripeSessionsContest from './events/stripe-sessions-2026/contest' +import stripeExecDinner from './events/stripe-sessions-2026/exec-dinner' +import stripeExecDinnerThankYou from './events/stripe-sessions-2026/exec-dinner-thank-you' +import sxswContest from './events/sxsw-2026/contest' import exampleLeadGen from './lead-gen/example-lead-gen' +import amoe from './legal/amoe' +import amoeThankYou from './legal/amoe-thankyou' +import contestRules from './legal/contest-rules' +import byocEarlyAccess from './pre-release/byoc-early-access' +import boltWebinar from './webinar/bolt-webinar' +import boltWebinarThankYou from './webinar/bolt-webinar-thank-you' +import figmaWebinarMay2026 from './webinar/figma-webinar-may2026' +import figmaWebinarMay2026ThankYou from './webinar/figma-webinar-may2026-thankyou' const pages: GoPageInput[] = [ exampleLeadGen, diff --git a/apps/www/lib/authors.json b/apps/www/lib/authors.json index a67ff731876cc..c38350986eff0 100644 --- a/apps/www/lib/authors.json +++ b/apps/www/lib/authors.json @@ -850,6 +850,14 @@ "author_url": "https://github.com/deepthi", "author_image_url": "https://github.com/deepthi.png" }, + { + "author_id": "joe_sciarrino", + "author": "Joe Sciarrino", + "username": "jhydra12", + "position": "Head of Product, Supabase Warehouse", + "author_url": "https://github.com/jhydra12", + "author_image_url": "https://github.com/jhydra12.png" + }, { "author_id": "manan_gupta", "author": "Manan Gupta", diff --git a/e2e/studio/features/queue-table-operations.spec.ts b/e2e/studio/features/queue-table-operations.spec.ts index ea835fb77c575..5aaab455bc30e 100644 --- a/e2e/studio/features/queue-table-operations.spec.ts +++ b/e2e/studio/features/queue-table-operations.spec.ts @@ -1,6 +1,6 @@ import { expect, Page } from '@playwright/test' -import { createTable, dropTable } from '../utils/db/index.js' +import { createTable, dropTable, query } from '../utils/db/index.js' import { test, withSetupCleanup } from '../utils/test.js' import { toUrl } from '../utils/to-url.js' import { waitForTableToLoad } from '../utils/wait-for-response.js' @@ -680,4 +680,79 @@ test.describe('Queue Table Operations', () => { await page.getByRole('button', { name: `View ${tableName2}`, exact: true }).click() await expect(page.getByRole('gridcell', { name: 'pending in table 2' })).toBeVisible() }) + + test('editing multiple columns via side panel queues all changes', async ({ page, ref }) => { + const tableName = `${tableNamePrefix}_multi_col` + + await using _ = await withSetupCleanup( + async () => { + await query( + `CREATE TABLE IF NOT EXISTS ${tableName} ( + id bigint generated by default as identity primary key, + created_at timestamp with time zone null default now(), + first_name text, + last_name text + )` + ) + await query( + `INSERT INTO ${tableName} (first_name, last_name) VALUES ($1, $2)`, + ['Alice', 'Smith'] + ) + }, + async () => { + await dropTable(tableName) + } + ) + + await page.goto(toUrl(`/project/${ref}/editor?schema=public`)) + await enableQueueOperations(page) + await page.reload() + await waitForTableToLoad(page, ref) + + await page.getByRole('button', { name: `View ${tableName}`, exact: true }).click() + await page.waitForURL(/\/editor\/\d+\?schema=public$/) + + await expect(page.getByRole('gridcell', { name: 'Alice' })).toBeVisible() + await expect(page.getByRole('gridcell', { name: 'Smith' })).toBeVisible() + + // Right-click to open context menu and edit the row + const cell = page.getByRole('gridcell', { name: 'Alice' }) + await cell.click({ button: 'right' }) + await page.getByRole('menuitem', { name: 'Edit row' }).click() + + // Update both columns in the side panel + const firstNameInput = page.getByTestId('first_name-input') + await expect(firstNameInput).toBeVisible() + await firstNameInput.clear() + await firstNameInput.fill('Bob') + + const lastNameInput = page.getByTestId('last_name-input') + await lastNameInput.clear() + await lastNameInput.fill('Jones') + + await page.getByTestId('action-bar-save-row').click() + + // Should queue 2 cell edits (one per changed column) + await expect(page.getByText('2 pending changes')).toBeVisible() + + // Both values should be optimistically updated in the grid + await expect(page.getByRole('gridcell', { name: 'Bob' })).toBeVisible() + await expect(page.getByRole('gridcell', { name: 'Jones' })).toBeVisible() + + // Review the queued operations + await page.getByRole('button', { name: /Review/ }).click() + + const sidePanel = page.getByRole('dialog') + await expect(sidePanel.getByText('2 cell edits')).toBeVisible() + + // Save all changes + await sidePanel.getByRole('button', { name: /^Save/ }).click() + await expect(page.getByText('Changes saved successfully')).toBeVisible() + + // Both columns should reflect the saved values + await expect(page.getByRole('gridcell', { name: 'Bob' })).toBeVisible() + await expect(page.getByRole('gridcell', { name: 'Jones' })).toBeVisible() + await expect(page.getByRole('gridcell', { name: 'Alice' })).not.toBeVisible() + await expect(page.getByRole('gridcell', { name: 'Smith' })).not.toBeVisible() + }) }) diff --git a/e2e/studio/features/table-editor.spec.ts b/e2e/studio/features/table-editor.spec.ts index fb8aaf26a8da6..b6f7de847ff6f 100644 --- a/e2e/studio/features/table-editor.spec.ts +++ b/e2e/studio/features/table-editor.spec.ts @@ -1234,6 +1234,248 @@ testRunner('table editor', () => { await expect(page.getByRole('gridcell', { name: 'drag drop value 1' })).toBeVisible() }) + test('row insert via side panel saves immediately', async ({ page, ref }) => { + const tableName = 'pw_table_row_insert' + const columnName = 'name' + + await using _ = await withSetupCleanup( + async () => { + await createTable(tableName, columnName) + }, + async () => { + await dropTable(tableName) + } + ) + + await page.goto(toUrl(`/project/${ref}/editor?schema=public`)) + await waitForTableToLoad(page, ref) + + await page.getByRole('button', { name: `View ${tableName}`, exact: true }).click() + await page.waitForURL(/\/editor\/\d+\?schema=public$/) + + // Open side panel to insert a new row + await page.getByTestId('table-editor-insert-new-row').click() + await page.getByRole('menuitem', { name: 'Insert row Insert a new row' }).click() + await page.getByTestId(`${columnName}-input`).fill('immediate insert') + + // Wait for the POST mutation to complete when saving + const insertPromise = waitForApiResponse(page, 'pg-meta', ref, 'query?key=', { + method: 'POST', + }) + await page.getByTestId('action-bar-save-row').click() + await insertPromise + + // Should show success toast + await expect( + page.getByText('Successfully created row'), + 'Success toast should appear after immediate row creation' + ).toBeVisible({ timeout: 10000 }) + + // Row should be visible in the grid + await expect( + page.getByRole('gridcell', { name: 'immediate insert' }), + 'Newly inserted row should be visible in the grid' + ).toBeVisible() + + // Should NOT show pending changes (queue is off) + await expect( + page.getByText('pending change'), + 'No pending changes should appear when queue is disabled' + ).not.toBeVisible() + }) + + test('row edit via side panel saves immediately', async ({ page, ref }) => { + const tableName = 'pw_table_row_edit' + const columnName = 'name' + + await using _ = await withSetupCleanup( + async () => { + await createTable(tableName, columnName, [{ name: 'original value' }]) + }, + async () => { + await dropTable(tableName) + } + ) + + await page.goto(toUrl(`/project/${ref}/editor?schema=public`)) + await waitForTableToLoad(page, ref) + + await page.getByRole('button', { name: `View ${tableName}`, exact: true }).click() + await page.waitForURL(/\/editor\/\d+\?schema=public$/) + + await expect(page.getByRole('gridcell', { name: 'original value' })).toBeVisible() + + // Right-click to open context menu and edit the row + const cell = page.getByRole('gridcell', { name: 'original value' }) + await cell.click({ button: 'right' }) + await page.getByRole('menuitem', { name: 'Edit row' }).click() + + // Update the value in the side panel + const input = page.getByTestId(`${columnName}-input`) + await expect(input).toBeVisible() + await input.clear() + await input.fill('updated value') + + // Wait for the POST mutation to complete when saving + const updatePromise = waitForApiResponse(page, 'pg-meta', ref, 'query?key=', { + method: 'POST', + }) + await page.getByTestId('action-bar-save-row').click() + await updatePromise + + // Updated value should be visible in the grid after immediate save + await expect( + page.getByRole('gridcell', { name: 'updated value' }), + 'Updated value should be visible in the grid' + ).toBeVisible() + + // Original value should be gone + await expect( + page.getByRole('gridcell', { name: 'original value' }), + 'Original value should no longer be visible' + ).not.toBeVisible() + + // Should NOT show pending changes (queue is off) + await expect( + page.getByText('pending change'), + 'No pending changes should appear when queue is disabled' + ).not.toBeVisible() + }) + + test('editing multiple columns via side panel saves all changes', async ({ page, ref }) => { + const tableName = 'pw_table_multi_col_edit' + + await using _ = await withSetupCleanup( + async () => { + await query( + `CREATE TABLE IF NOT EXISTS ${tableName} ( + id bigint generated by default as identity primary key, + created_at timestamp with time zone null default now(), + first_name text, + last_name text + )` + ) + await query( + `INSERT INTO ${tableName} (first_name, last_name) VALUES ($1, $2)`, + ['Alice', 'Smith'] + ) + }, + async () => { + await dropTable(tableName) + } + ) + + await page.goto(toUrl(`/project/${ref}/editor?schema=public`)) + await waitForTableToLoad(page, ref) + + await page.getByRole('button', { name: `View ${tableName}`, exact: true }).click() + await page.waitForURL(/\/editor\/\d+\?schema=public$/) + + await expect(page.getByRole('gridcell', { name: 'Alice' })).toBeVisible() + await expect(page.getByRole('gridcell', { name: 'Smith' })).toBeVisible() + + // Right-click to open context menu and edit the row + const cell = page.getByRole('gridcell', { name: 'Alice' }) + await cell.click({ button: 'right' }) + await page.getByRole('menuitem', { name: 'Edit row' }).click() + + // Update both columns in the side panel + const firstNameInput = page.getByTestId('first_name-input') + await expect(firstNameInput).toBeVisible() + await firstNameInput.clear() + await firstNameInput.fill('Bob') + + const lastNameInput = page.getByTestId('last_name-input') + await lastNameInput.clear() + await lastNameInput.fill('Jones') + + // Wait for the POST mutation to complete when saving + const updatePromise = waitForApiResponse(page, 'pg-meta', ref, 'query?key=', { + method: 'POST', + }) + await page.getByTestId('action-bar-save-row').click() + await updatePromise + + // Both columns should reflect the updated values + await expect( + page.getByRole('gridcell', { name: 'Bob' }), + 'First name should be updated to Bob' + ).toBeVisible() + await expect( + page.getByRole('gridcell', { name: 'Jones' }), + 'Last name should be updated to Jones' + ).toBeVisible() + + // Original values should be gone + await expect( + page.getByRole('gridcell', { name: 'Alice' }), + 'Original first name should no longer be visible' + ).not.toBeVisible() + await expect( + page.getByRole('gridcell', { name: 'Smith' }), + 'Original last name should no longer be visible' + ).not.toBeVisible() + }) + + test('row delete via context menu shows confirmation dialog', async ({ page, ref }) => { + const tableName = 'pw_table_row_delete' + const columnName = 'name' + + await using _ = await withSetupCleanup( + async () => { + await createTable(tableName, columnName, [{ name: 'row to delete' }]) + }, + async () => { + await dropTable(tableName) + } + ) + + await page.goto(toUrl(`/project/${ref}/editor?schema=public`)) + await waitForTableToLoad(page, ref) + + await page.getByRole('button', { name: `View ${tableName}`, exact: true }).click() + await page.waitForURL(/\/editor\/\d+\?schema=public$/) + + await expect(page.getByRole('gridcell', { name: 'row to delete' })).toBeVisible() + + // Right-click to open context menu and delete the row + const cell = page.getByRole('gridcell', { name: 'row to delete' }) + await cell.click({ button: 'right' }) + await page.getByRole('menuitem', { name: 'Delete row' }).click() + + // In non-queue mode, a confirmation dialog should appear + const confirmDialog = page.getByRole('dialog', { name: 'Confirm to delete the selected row' }) + await expect( + confirmDialog, + 'Confirmation dialog should appear for non-queue row deletion' + ).toBeVisible({ timeout: 10000 }) + + // Confirm the deletion + const deletePromise = waitForApiResponse(page, 'pg-meta', ref, 'query?key=', { + method: 'POST', + }) + await confirmDialog.getByRole('button', { name: 'Delete' }).click() + await deletePromise + + // Row should be gone + await expect( + page.getByRole('gridcell', { name: 'row to delete' }), + 'Deleted row should no longer be visible' + ).not.toBeVisible() + + // Should show 0 records + await expect( + page.getByText('0 records'), + 'Table should show 0 records after deletion' + ).toBeVisible() + + // Should NOT show pending changes (queue is off) + await expect( + page.getByText('pending change'), + 'No pending changes should appear when queue is disabled' + ).not.toBeVisible() + }) + test('create a table in a single transaction', async ({ page, ref }) => { const tableName = 'pw_table_create_transaction' diff --git a/packages/api-types/types/platform.d.ts b/packages/api-types/types/platform.d.ts index 8d5b481152b3e..5d3a7fe59e5eb 100644 --- a/packages/api-types/types/platform.d.ts +++ b/packages/api-types/types/platform.d.ts @@ -2432,183 +2432,8 @@ export interface paths { delete: operations['LogDrainController_deleteLogDrain'] options?: never head?: never - patch?: never - trace?: never - } - '/platform/projects/{ref}/analytics/warehouse/access-tokens': { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - /** Lists project's warehouse access tokens from logflare */ - get: operations['AccessTokenController_listAccessTokens'] - put?: never - /** Create a warehouse access token */ - post: operations['AccessTokenController_createAccessToken'] - delete?: never - options?: never - head?: never - patch?: never - trace?: never - } - '/platform/projects/{ref}/analytics/warehouse/access-tokens/{token}': { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - get?: never - put?: never - post?: never - /** Delete a warehouse access token */ - delete: operations['AccessTokenController_deleteAccessToken'] - options?: never - head?: never - patch?: never - trace?: never - } - '/platform/projects/{ref}/analytics/warehouse/collections': { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - /** Lists project's telemetry collections from logflare */ - get: operations['CollectionController_listCollections'] - put?: never - /** Create a telemetry collection */ - post: operations['CollectionController_createCollection'] - delete?: never - options?: never - head?: never - patch?: never - trace?: never - } - '/platform/projects/{ref}/analytics/warehouse/collections/{token}': { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - /** Get a telemetry collection */ - get: operations['CollectionController_getCollectionSchema'] - put?: never - post?: never - /** Delete a telemetry collection */ - delete: operations['CollectionController_deleteCollection'] - options?: never - head?: never - /** Update a telemetry collection */ - patch: operations['CollectionController_updateCollection'] - trace?: never - } - '/platform/projects/{ref}/analytics/warehouse/collections/{token}/schema': { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - /** Get a telemetry collection schema */ - get: operations['CollectionController_getCollection'] - put?: never - post?: never - delete?: never - options?: never - head?: never - patch?: never - trace?: never - } - '/platform/projects/{ref}/analytics/warehouse/endpoints': { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - /** Lists project's warehouse endpoints from logflare */ - get: operations['EndpointController_listEndpoints'] - put?: never - /** Create a warehouse endpoint */ - post: operations['EndpointController_createEndpoint'] - delete?: never - options?: never - head?: never - patch?: never - trace?: never - } - '/platform/projects/{ref}/analytics/warehouse/endpoints/{token}': { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - get?: never - /** Update a warehouse endpoint */ - put: operations['EndpointController_updateEndpoint'] - post?: never - /** Delete a warehouse endpoint */ - delete: operations['EndpointController_deleteEndpoint'] - options?: never - head?: never - patch?: never - trace?: never - } - '/platform/projects/{ref}/analytics/warehouse/query': { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - /** Lists project's warehouse queries from logflare */ - get: operations['WarehouseQueryController_runQuery'] - put?: never - post?: never - delete?: never - options?: never - head?: never - patch?: never - trace?: never - } - '/platform/projects/{ref}/analytics/warehouse/query/parse': { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - /** Parses a warehouse query */ - get: operations['WarehouseQueryController_parseQuery'] - put?: never - post?: never - delete?: never - options?: never - head?: never - patch?: never - trace?: never - } - '/platform/projects/{ref}/analytics/warehouse/tenant': { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - /** Gets project's warehouse tenant from logflare */ - get: operations['TenantController_getTenant'] - put?: never - post?: never - delete?: never - options?: never - head?: never - patch?: never + /** Patch a log drain */ + patch: operations['LogDrainController_patchLogDrain'] trace?: never } '/platform/projects/{ref}/api-keys/temporary': { @@ -4763,7 +4588,7 @@ export interface components { slug: string } /** @enum {string} */ - status: 'pending' | 'complete' | 'expired' + status: 'pending' | 'complete' | 'expired' | 'error' } AddAwsAccountToPrivateLinkBody: { account_name?: string @@ -4991,7 +4816,7 @@ export interface components { CreateBackendParamsOpenapi: { config: | { - hostname: string + hostname?: string password?: string | null port?: number | null schema?: string @@ -5005,19 +4830,19 @@ export interface components { } /** @enum {string} */ http?: 'http1' | 'http2' - url: string + url?: string } | { - dataset_id: string - project_id: string + dataset_id?: string + project_id?: string } | { - api_key: string - region: string + api_key?: string + region?: string } | { password?: string | null - url: string + url?: string username?: string | null } | { @@ -5025,17 +4850,28 @@ export interface components { [key: string]: string } password?: string | null - url: string + url?: string username?: string | null } | { - dsn: string + dsn?: string } | { - api_token: string - dataset_name: string + api_token?: string + dataset_name?: string domain?: string } + | { + ca_cert?: string + cipher_key?: string + client_cert?: string + client_key?: string + host?: string + port?: number + structured_data?: string + /** @default false */ + tls?: boolean + } description?: string name: string /** @enum {string} */ @@ -5052,6 +4888,7 @@ export interface components { | 'axiom' | 'last9' | 'otlp' + | 'syslog' } CreateBucketIndexBody: { /** @enum {string} */ @@ -5069,10 +4906,6 @@ export interface components { session_id: string token_name?: string } - CreateCollectionBody: { - name: string - retention_days: number - } CreateContentFolderBody: { name: string /** Format: uuid */ @@ -5421,6 +5254,8 @@ export interface components { | 'infra_read_replicas_write' | 'project_snippets_read' | 'project_snippets_write' + | 'realtime_config_read' + | 'realtime_config_write' | 'storage_read' | 'storage_write' | 'storage_config_read' @@ -5882,6 +5717,8 @@ export interface components { | 'infra_read_replicas_write' | 'project_snippets_read' | 'project_snippets_write' + | 'realtime_config_read' + | 'realtime_config_write' | 'storage_read' | 'storage_write' | 'storage_config_read' @@ -7289,17 +7126,10 @@ export interface components { /** Format: uri */ receipt_pdf: string } - LFAccessToken: { - description: string | null - id: number - inserted_at: string - scopes: string - token: string - } LFBackend: { config: | { - hostname: string + hostname?: string password?: string | null port?: number | null schema?: string @@ -7313,19 +7143,19 @@ export interface components { } /** @enum {string} */ http?: 'http1' | 'http2' - url: string + url?: string } | { - dataset_id: string - project_id: string + dataset_id?: string + project_id?: string } | { - api_key: string - region: string + api_key?: string + region?: string } | { password?: string | null - url: string + url?: string username?: string | null } | { @@ -7333,17 +7163,28 @@ export interface components { [key: string]: string } password?: string | null - url: string + url?: string username?: string | null } | { - dsn: string + dsn?: string } | { - api_token: string - dataset_name: string + api_token?: string + dataset_name?: string domain?: string } + | { + ca_cert?: string + cipher_key?: string + client_cert?: string + client_key?: string + host?: string + port?: number + structured_data?: string + /** @default false */ + tls?: boolean + } description?: string id: number metadata: { @@ -7367,48 +7208,9 @@ export interface components { | 'axiom' | 'last9' | 'otlp' + | 'syslog' user_id: number } - LFEndpoint: { - cache_duration_seconds: number - description: string - enable_auth: number - id: number - /** @enum {string} */ - language: 'bq_sql' | 'pg_sql' - max_limit: number - name: string - proactive_requerying_seconds: number - query: string - sandboxable: boolean | null - token: string - } - LFSource: { - bigquery_table_ttl: number - custom_event_message_keys: string | null - favourite: boolean - id: number - lock_schema: boolean - name: string - public_token: string | null - retention_days: number - slack_hook_url: string | null - token: string - webhook_notification_url: string | null - } - LFUser: { - bigquery_dataset_id: string | null - bigquery_dataset_location: string | null - bigquery_project_id: string | null - company: string | null - email: string | null - email_me_product: string | null - metadata?: { - project_ref: string - } - phone: string | null - token: string - } LinkClazarBuyerBody: { buyer_id: string } @@ -9738,17 +9540,15 @@ export interface components { } StorageListResponseV2: { folders: { - created_at: string - key: string + key?: string name: string - updated_at: string }[] hasNext: boolean nextCursor?: string objects: { created_at: string id: string - key: string + key?: string last_accessed_at: string metadata: { cacheControl?: string | null @@ -9758,14 +9558,14 @@ export interface components { lastModified?: string | null mimetype?: string | null size?: number | null - } + } | null name: string updated_at: string }[] } StorageObject: { - bucket_id: string - buckets: { + bucket_id?: string + buckets?: { allowed_mime_types?: string[] created_at: string file_size_limit?: number @@ -9777,15 +9577,15 @@ export interface components { type?: 'STANDARD' | 'ANALYTICS' updated_at: string } - created_at: string - id: string - last_accessed_at: string + created_at: string | null + id: string | null + last_accessed_at: string | null metadata: { [key: string]: unknown - } + } | null name: string - owner: string - updated_at: string + owner?: string + updated_at: string | null } StorageVectorBucketListIndexesResponse: { indexes: { @@ -10085,7 +9885,7 @@ export interface components { UpdateBackendParamsOpenapi: { config?: | { - hostname: string + hostname?: string password?: string | null port?: number | null schema?: string @@ -10099,19 +9899,19 @@ export interface components { } /** @enum {string} */ http?: 'http1' | 'http2' - url: string + url?: string } | { - dataset_id: string - project_id: string + dataset_id?: string + project_id?: string } | { - api_key: string - region: string + api_key?: string + region?: string } | { password?: string | null - url: string + url?: string username?: string | null } | { @@ -10119,21 +9919,32 @@ export interface components { [key: string]: string } password?: string | null - url: string + url?: string username?: string | null } | { - dsn: string + dsn?: string } | { - api_token: string - dataset_name: string + api_token?: string + dataset_name?: string domain?: string } + | { + ca_cert?: string + cipher_key?: string + client_cert?: string + client_key?: string + host?: string + port?: number + structured_data?: string + /** @default false */ + tls?: boolean + } description?: string name?: string /** @enum {string} */ - type: + type?: | 'postgres' | 'bigquery' | 'clickhouse' @@ -10146,10 +9957,7 @@ export interface components { | 'axiom' | 'last9' | 'otlp' - } - UpdateCollectionBody: { - name: string - retention_days: number + | 'syslog' } UpdateContentFolderBody: { name: string @@ -10602,6 +10410,8 @@ export interface components { | 'infra_read_replicas_write' | 'project_snippets_read' | 'project_snippets_write' + | 'realtime_config_read' + | 'realtime_config_write' | 'storage_read' | 'storage_write' | 'storage_config_read' @@ -19267,764 +19077,30 @@ export interface operations { } } } - AccessTokenController_listAccessTokens: { + LogDrainController_patchLogDrain: { parameters: { query?: never header?: never path: { /** @description Project ref */ ref: string + /** @description Log drains identifier */ + token: string } cookie?: never } - requestBody?: never - responses: { - 200: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['LFAccessToken'][] - } - } - /** @description Unauthorized */ - 401: { - headers: { - [name: string]: unknown - } - content?: never - } - /** @description Forbidden action */ - 403: { - headers: { - [name: string]: unknown - } - content?: never - } - /** @description Rate limit exceeded */ - 429: { - headers: { - [name: string]: unknown - } - content?: never - } - /** @description Failed to fetch warehouse access tokens */ - 500: { - headers: { - [name: string]: unknown - } - content?: never + requestBody: { + content: { + 'application/json': components['schemas']['UpdateBackendParamsOpenapi'] } } - } - AccessTokenController_createAccessToken: { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - requestBody?: never responses: { - 201: { + 200: { headers: { [name: string]: unknown } content: { - 'application/json': components['schemas']['LFAccessToken'] - } - } - /** @description Unauthorized */ - 401: { - headers: { - [name: string]: unknown - } - content?: never - } - /** @description Forbidden action */ - 403: { - headers: { - [name: string]: unknown - } - content?: never - } - /** @description Rate limit exceeded */ - 429: { - headers: { - [name: string]: unknown - } - content?: never - } - /** @description Failed to create warehouse access token */ - 500: { - headers: { - [name: string]: unknown - } - content?: never - } - } - } - AccessTokenController_deleteAccessToken: { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - requestBody?: never - responses: { - 200: { - headers: { - [name: string]: unknown - } - content?: never - } - /** @description Unauthorized */ - 401: { - headers: { - [name: string]: unknown - } - content?: never - } - /** @description Forbidden action */ - 403: { - headers: { - [name: string]: unknown - } - content?: never - } - /** @description Rate limit exceeded */ - 429: { - headers: { - [name: string]: unknown - } - content?: never - } - /** @description Failed to delete warehouse access token */ - 500: { - headers: { - [name: string]: unknown - } - content?: never - } - } - } - CollectionController_listCollections: { - parameters: { - query?: never - header?: never - path: { - /** @description Project ref */ - ref: string - } - cookie?: never - } - requestBody?: never - responses: { - 200: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['LFSource'][] - } - } - /** @description Unauthorized */ - 401: { - headers: { - [name: string]: unknown - } - content?: never - } - /** @description Forbidden action */ - 403: { - headers: { - [name: string]: unknown - } - content?: never - } - /** @description Rate limit exceeded */ - 429: { - headers: { - [name: string]: unknown - } - content?: never - } - /** @description Failed to fetch telemetry collections */ - 500: { - headers: { - [name: string]: unknown - } - content?: never - } - } - } - CollectionController_createCollection: { - parameters: { - query?: never - header?: never - path: { - /** @description Project ref */ - ref: string - } - cookie?: never - } - requestBody: { - content: { - 'application/json': components['schemas']['CreateCollectionBody'] - } - } - responses: { - 201: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['LFSource'] - } - } - /** @description Unauthorized */ - 401: { - headers: { - [name: string]: unknown - } - content?: never - } - /** @description Forbidden action */ - 403: { - headers: { - [name: string]: unknown - } - content?: never - } - /** @description Rate limit exceeded */ - 429: { - headers: { - [name: string]: unknown - } - content?: never - } - /** @description Failed to create telemetry collection */ - 500: { - headers: { - [name: string]: unknown - } - content?: never - } - } - } - CollectionController_getCollectionSchema: { - parameters: { - query?: never - header?: never - path: { - /** @description Project ref */ - ref: string - token: string - } - cookie?: never - } - requestBody?: never - responses: { - 200: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['LFSource'] - } - } - /** @description Unauthorized */ - 401: { - headers: { - [name: string]: unknown - } - content?: never - } - /** @description Forbidden action */ - 403: { - headers: { - [name: string]: unknown - } - content?: never - } - /** @description Rate limit exceeded */ - 429: { - headers: { - [name: string]: unknown - } - content?: never - } - /** @description Failed to fetch telemetry collection */ - 500: { - headers: { - [name: string]: unknown - } - content?: never - } - } - } - CollectionController_deleteCollection: { - parameters: { - query?: never - header?: never - path: { - /** @description Project ref */ - ref: string - token: string - } - cookie?: never - } - requestBody?: never - responses: { - 200: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['LFSource'] - } - } - /** @description Unauthorized */ - 401: { - headers: { - [name: string]: unknown - } - content?: never - } - /** @description Forbidden action */ - 403: { - headers: { - [name: string]: unknown - } - content?: never - } - /** @description Rate limit exceeded */ - 429: { - headers: { - [name: string]: unknown - } - content?: never - } - /** @description Failed to delete telemetry collection */ - 500: { - headers: { - [name: string]: unknown - } - content?: never - } - } - } - CollectionController_updateCollection: { - parameters: { - query?: never - header?: never - path: { - /** @description Project ref */ - ref: string - token: string - } - cookie?: never - } - requestBody: { - content: { - 'application/json': components['schemas']['UpdateCollectionBody'] - } - } - responses: { - 200: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['LFSource'] - } - } - /** @description Unauthorized */ - 401: { - headers: { - [name: string]: unknown - } - content?: never - } - /** @description Forbidden action */ - 403: { - headers: { - [name: string]: unknown - } - content?: never - } - /** @description Rate limit exceeded */ - 429: { - headers: { - [name: string]: unknown - } - content?: never - } - /** @description Failed to update telemetry collection */ - 500: { - headers: { - [name: string]: unknown - } - content?: never - } - } - } - CollectionController_getCollection: { - parameters: { - query?: never - header?: never - path: { - /** @description Project ref */ - ref: string - token: string - variant: 'dot' | 'json_schema' - } - cookie?: never - } - requestBody?: never - responses: { - 200: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['LFSource'] - } - } - /** @description Unauthorized */ - 401: { - headers: { - [name: string]: unknown - } - content?: never - } - /** @description Forbidden action */ - 403: { - headers: { - [name: string]: unknown - } - content?: never - } - /** @description Rate limit exceeded */ - 429: { - headers: { - [name: string]: unknown - } - content?: never - } - /** @description Failed to fetch telemetry collection schema */ - 500: { - headers: { - [name: string]: unknown - } - content?: never - } - } - } - EndpointController_listEndpoints: { - parameters: { - query?: never - header?: never - path: { - /** @description Project ref */ - ref: string - } - cookie?: never - } - requestBody?: never - responses: { - 200: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['LFEndpoint'][] - } - } - /** @description Unauthorized */ - 401: { - headers: { - [name: string]: unknown - } - content?: never - } - /** @description Forbidden action */ - 403: { - headers: { - [name: string]: unknown - } - content?: never - } - /** @description Rate limit exceeded */ - 429: { - headers: { - [name: string]: unknown - } - content?: never - } - /** @description Failed to fetch warehouse endpoints */ - 500: { - headers: { - [name: string]: unknown - } - content?: never - } - } - } - EndpointController_createEndpoint: { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - requestBody?: never - responses: { - 201: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['LFEndpoint'] - } - } - /** @description Unauthorized */ - 401: { - headers: { - [name: string]: unknown - } - content?: never - } - /** @description Forbidden action */ - 403: { - headers: { - [name: string]: unknown - } - content?: never - } - /** @description Rate limit exceeded */ - 429: { - headers: { - [name: string]: unknown - } - content?: never - } - /** @description Failed to create warehouse endpoint */ - 500: { - headers: { - [name: string]: unknown - } - content?: never - } - } - } - EndpointController_updateEndpoint: { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - requestBody?: never - responses: { - 200: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['LFEndpoint'] - } - } - /** @description Unauthorized */ - 401: { - headers: { - [name: string]: unknown - } - content?: never - } - /** @description Forbidden action */ - 403: { - headers: { - [name: string]: unknown - } - content?: never - } - /** @description Rate limit exceeded */ - 429: { - headers: { - [name: string]: unknown - } - content?: never - } - /** @description Failed to update warehouse endpoint */ - 500: { - headers: { - [name: string]: unknown - } - content?: never - } - } - } - EndpointController_deleteEndpoint: { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - requestBody?: never - responses: { - 200: { - headers: { - [name: string]: unknown - } - content?: never - } - /** @description Unauthorized */ - 401: { - headers: { - [name: string]: unknown - } - content?: never - } - /** @description Forbidden action */ - 403: { - headers: { - [name: string]: unknown - } - content?: never - } - /** @description Rate limit exceeded */ - 429: { - headers: { - [name: string]: unknown - } - content?: never - } - /** @description Failed to delete warehouse endpoint */ - 500: { - headers: { - [name: string]: unknown - } - content?: never - } - } - } - WarehouseQueryController_runQuery: { - parameters: { - query?: never - header?: never - path: { - /** @description Project ref */ - ref: string - } - cookie?: never - } - requestBody?: never - responses: { - 200: { - headers: { - [name: string]: unknown - } - content?: never - } - /** @description Unauthorized */ - 401: { - headers: { - [name: string]: unknown - } - content?: never - } - /** @description Forbidden action */ - 403: { - headers: { - [name: string]: unknown - } - content?: never - } - /** @description Rate limit exceeded */ - 429: { - headers: { - [name: string]: unknown - } - content?: never - } - /** @description Failed to fetch warehouse queries */ - 500: { - headers: { - [name: string]: unknown - } - content?: never - } - } - } - WarehouseQueryController_parseQuery: { - parameters: { - query?: never - header?: never - path: { - /** @description Project ref */ - ref: string - } - cookie?: never - } - requestBody?: never - responses: { - 200: { - headers: { - [name: string]: unknown - } - content?: never - } - /** @description Unauthorized */ - 401: { - headers: { - [name: string]: unknown - } - content?: never - } - /** @description Forbidden action */ - 403: { - headers: { - [name: string]: unknown - } - content?: never - } - /** @description Rate limit exceeded */ - 429: { - headers: { - [name: string]: unknown - } - content?: never - } - /** @description Failed to parse warehouse query */ - 500: { - headers: { - [name: string]: unknown - } - content?: never - } - } - } - TenantController_getTenant: { - parameters: { - query?: never - header?: never - path: { - /** @description Project ref */ - ref: string - } - cookie?: never - } - requestBody?: never - responses: { - 200: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['LFUser'] + 'application/json': components['schemas']['LFBackend'] } } /** @description Unauthorized */ @@ -20048,7 +19124,7 @@ export interface operations { } content?: never } - /** @description Failed to fetch or provision warehouse tenant */ + /** @description Failed to patch log drain */ 500: { headers: { [name: string]: unknown diff --git a/packages/common/telemetry-constants.ts b/packages/common/telemetry-constants.ts index 4ccbee941c326..27a9600b42419 100644 --- a/packages/common/telemetry-constants.ts +++ b/packages/common/telemetry-constants.ts @@ -2603,6 +2603,7 @@ export interface LogDrainSaveButtonClickedEvent { | 'axiom' | 'last9' | 'otlp' + | 'syslog' } groups: TelemetryGroups } @@ -2633,6 +2634,7 @@ export interface LogDrainConfirmButtonSubmittedEvent { | 'axiom' | 'last9' | 'otlp' + | 'syslog' } groups: TelemetryGroups } diff --git a/supa-mdx-lint/Rule001HeadingCase.toml b/supa-mdx-lint/Rule001HeadingCase.toml index bacce926e1852..531d6003b703a 100644 --- a/supa-mdx-lint/Rule001HeadingCase.toml +++ b/supa-mdx-lint/Rule001HeadingCase.toml @@ -37,6 +37,7 @@ may_uppercase = [ "Clerk", "Cloudflare", "Cloudflare Workers?", + "Claude Code", "Code Exchange", "Colab", "Compute",