Merge remote-tracking branch 'origin/master' into alaister/tanstack-start

Resolve conflicts:
- apps/studio/package.json: drop @supabase/postgres-meta (replaced by
  @supabase/pg-meta on master) while keeping tanstack/tailwind v4 dev deps
- pnpm-lock.yaml: take master's and reconcile via pnpm install

Bump tanstack deps to latest available (post supply-chain unpublish):
- @tanstack/react-devtools ^0.10.2 -> ^0.10.3
- @tanstack/react-hotkeys ^0.9.1 -> ^0.10.0
- @tanstack/react-query / -devtools ^5.0.0 -> ^5.100.10 (studio/docs/ui-library)
This commit is contained in:
Alaister Young committed 2026-05-15 13:39:15 +08:00
commit c7de8411cf
426 files changed
+15792 -7311

No files matched your search

+5 -5
View File
@@ -192,13 +192,13 @@ export const Index: Record<string, any> = {
subcategory: "undefined",
chunks: []
},
"alert-collapsible-demo": {
name: "alert-collapsible-demo",
"collapsible-alert-demo": {
name: "collapsible-alert-demo",
type: "components:example",
registryDependencies: ["alert","collapsible","button"],
component: React.lazy(() => import("@/registry/default/example/alert-collapsible-demo")),
registryDependencies: ["collapsible-alert"],
component: React.lazy(() => import("@/registry/default/example/collapsible-alert-demo")),
source: "",
files: ["registry/default/example/alert-collapsible-demo.tsx"],
files: ["registry/default/example/collapsible-alert-demo.tsx"],
category: "undefined",
subcategory: "undefined",
chunks: []
@@ -56,11 +56,11 @@ const ColorPalette = () => {
<div key={slug} className={`grid items-center gap-1 ${GRID_COLS}`}>
<div className="pr-2 text-sm font-medium text-foreground">{name}</div>
{SCALE_STEPS.map((step) => {
const reference = `var(--colors-${slug}${step})`
const reference = `var(--color-${slug}-${step * 100})`
const isCopied = copied === reference
return (
<button
key={step}
key={step * 100}
type="button"
onClick={() => handleCopy(reference)}
className="group relative flex aspect-square w-full items-center justify-center rounded-sm border border-overlay/40 transition hover:scale-[1.05] focus:outline-none focus-visible:ring-2 focus-visible:ring-foreground"
@@ -68,7 +68,7 @@ const ColorPalette = () => {
title={reference}
>
<span className="rounded-xs bg-surface-100/90 px-1 font-mono text-[10px] text-foreground-light opacity-0 transition group-hover:opacity-100">
{isCopied ? 'Copied!' : step}
{isCopied ? 'Copied!' : step * 100}
</span>
</button>
)
+5
View File
@@ -117,6 +117,11 @@ export const docsConfig: DocsConfig = {
href: '/docs/fragments/admonition',
items: [],
},
{
title: 'Collapsible Alert',
href: '/docs/fragments/collapsible-alert',
items: [],
},
{
title: 'Assistant Chat',
href: '/docs/fragments/assistant-chat',
@@ -75,4 +75,6 @@ These can also be accessed with `foreground`. Like `text-foreground-light`.
Every Radix scale exposed via `--colors-{name}{1..12}`. Click a swatch to copy the CSS variable reference. The colors are taken
from `@radix-ui/colors` v0.1.9 except the `Brand` and `Scale` colors.
The `Brand` colors issue of not being uniform across the whole scale is known, they need to be refactored. The `brand-200` variable in the dark themes is also bad.
<ColorPalette />
@@ -1,29 +1,24 @@
---
title: Alert
description: Displays a callout for user attention.
description: Displays a low-level callout primitive for user attention.
component: true
source:
radix: true
shadcn: true
---
Alert forms the core primitives for [Admonition](../fragments/admonition). Use the Admonition component unless you must deviate from our consistent patterns. Examples where Alert might make sense include:
Alert is the low-level shadcn primitive that forms the foundation for [Admonition](../fragments/admonition). Use Admonition for product callouts unless you need custom composition that the Admonition API does not support.
- Bespoke icon requirement
- Highly custom internal contents
Examples where Alert may be appropriate include bespoke icons, custom internal layouts, or wrapper components that need direct access to `alertVariants`.
Use [Collapsible Alert](../fragments/collapsible-alert) when a callout also needs expandable detail. Use [Collapsible](./collapsible) only for generic disclosure behaviour that is not itself an alert.
<ComponentPreview name="alert-demo" peekCode wide />
## Installation
```bash
npx shadcn-ui@latest add alert
```
## Usage
```tsx
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Alert, AlertDescription, AlertTitle } from 'ui/src/components/shadcn/ui/alert'
```
```tsx
@@ -35,3 +30,11 @@ import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
</AlertDescription>
</Alert>
```
### Variants
Use `alertVariants` when building shared wrappers around the primitive.
```tsx
import { alertVariants } from 'ui/src/components/shadcn/ui/alert'
```
@@ -1,37 +1,51 @@
---
title: Alert Collapsible
description: An alert-styled block with a collapsible content section, toggled by an icon button.
title: Collapsible
description: An expandable content region using the shadcn Collapsible primitive.
component: true
---
<ComponentPreview name="alert-collapsible-demo" peekCode wide />
Collapsible is a low-level primitive for revealing and hiding related content. Use it when a component needs custom trigger or content composition.
Use Collapsible for generic disclosure behaviour: rows, sections, menus, tool details, and custom layouts. Use [Collapsible Alert](../fragments/collapsible-alert) instead when the disclosure is an alert-styled callout that should keep standard alert padding, variants, trigger styling, and toggle treatment.
<ComponentPreview name="collapsible-demo" peekCode wide />
## Usage
```tsx
import { AlertCollapsible } from 'ui'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from 'ui/src/components/shadcn/ui/collapsible'
```
```tsx
<AlertCollapsible trigger="Need help?">
<p>Content revealed when expanded.</p>
</AlertCollapsible>
<Collapsible>
<CollapsibleTrigger>Toggle</CollapsibleTrigger>
<CollapsibleContent>
<p>Content revealed when expanded.</p>
</CollapsibleContent>
</Collapsible>
```
### Default open
```tsx
<AlertCollapsible trigger="Details" defaultOpen>
<p>This section is expanded by default.</p>
</AlertCollapsible>
<Collapsible defaultOpen>
<CollapsibleTrigger>Details</CollapsibleTrigger>
<CollapsibleContent>
<p>This section is expanded by default.</p>
</CollapsibleContent>
</Collapsible>
```
### Variants
### Animation
Supports the same `variant` prop as `Alert`: `default`, `destructive`, and `warning`.
Add animation classes to `CollapsibleContent` when animated open and close transitions are needed.
```tsx
<AlertCollapsible trigger="Warning" variant="warning">
<p>Something needs attention.</p>
</AlertCollapsible>
<CollapsibleContent className="data-closed:animate-collapsible-up data-open:animate-collapsible-down">
<p>Content revealed when expanded.</p>
</CollapsibleContent>
```
@@ -4,7 +4,7 @@ description: Displays a callout for user attention.
fragment: true
---
Admonition provides focus for situations that require a callout.
Admonition provides the standard product callout pattern for situations that require user attention.
<ComponentPreview
name="admonition-demo"
@@ -13,7 +13,9 @@ Admonition provides focus for situations that require a callout.
wide
/>
Use Admonition instead of [Alert](../components/alert) unless you specifically need the primitives. If in doubt, stick with Admonition.
Use Admonition instead of the low-level [Alert](../components/alert) primitive unless you specifically need custom composition. If in doubt, stick with Admonition.
Use [Collapsible Alert](./collapsible-alert) when the callout needs to remain visible while optional detail can be expanded. Use [Collapsible](../components/collapsible) for generic disclosure behaviour that is not itself a callout.
Use `title` for the heading slot when the callout needs its own heading. `title` is optional: short callouts may use `description` without a title when nearby content already provides enough context.
@@ -0,0 +1,43 @@
---
title: Collapsible Alert
description: An alert-styled block with a collapsible content section, toggled by an icon button.
fragment: true
---
Collapsible Alert combines an Alert-style container with a collapsible content section. Use it when the callout itself needs to stay visible while optional detail can be expanded.
Use Collapsible Alert when the disclosure is itself a callout. It owns the alert styling, padding, trigger treatment, and toggle button. Use [Collapsible](../components/collapsible) instead when you only need generic reveal and hide behaviour and the surrounding layout is not an alert.
For non-expandable product callouts, use [Admonition](./admonition). Use the low-level [Alert](../components/alert) primitive only when a wrapper or bespoke composition needs direct control over the alert structure.
<ComponentPreview name="collapsible-alert-demo" peekCode wide />
## Usage
```tsx
import { CollapsibleAlert } from 'ui-patterns/collapsible-alert'
```
```tsx
<CollapsibleAlert trigger="Need help?">
<p>Content revealed when expanded.</p>
</CollapsibleAlert>
```
### Default open
```tsx
<CollapsibleAlert trigger="Details" defaultOpen>
<p>This section is expanded by default.</p>
</CollapsibleAlert>
```
### Variants
Supports the same `variant` prop as Alert: `default`, `destructive`, and `warning`.
```tsx
<CollapsibleAlert trigger="Warning" variant="warning">
<p>Something needs attention.</p>
</CollapsibleAlert>
```
@@ -1,11 +0,0 @@
import { AlertCollapsible } from 'ui'
export default function AlertCollapsibleDemo() {
return (
<AlertCollapsible trigger="Need help?">
<p className="text-sm text-foreground-light">
Try a different browser or disable extensions that block network requests.
</p>
</AlertCollapsible>
)
}
@@ -1,14 +1,12 @@
import { Terminal } from 'lucide-react'
import { Alert_Shadcn_, AlertDescription_Shadcn_, AlertTitle_Shadcn_ } from 'ui'
import { Alert, AlertDescription, AlertTitle } from 'ui/src/components/shadcn/ui/alert'
export default function AlertDemo() {
return (
<Alert_Shadcn_>
<Alert>
<Terminal size={16} />
<AlertTitle_Shadcn_>Heads up!</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
You can also add components to your app using the CLI.
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
<AlertTitle>Heads up!</AlertTitle>
<AlertDescription>You can also add components to your app using the CLI.</AlertDescription>
</Alert>
)
}
@@ -0,0 +1,11 @@
import { CollapsibleAlert } from 'ui-patterns/collapsible-alert'
export default function CollapsibleAlertDemo() {
return (
<CollapsibleAlert trigger="Need help?">
<p className="text-sm text-foreground-light">
Try a different browser or disable extensions that block network requests.
</p>
</CollapsibleAlert>
)
}
@@ -1,33 +1,33 @@
'use client'
import { ChevronsUpDown, Plus, X } from 'lucide-react'
import { ChevronsUpDown } from 'lucide-react'
import * as React from 'react'
import { Button } from 'ui'
import {
Button,
Collapsible_Shadcn_,
CollapsibleContent_Shadcn_,
CollapsibleTrigger_Shadcn_,
} from 'ui'
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from 'ui/src/components/shadcn/ui/collapsible'
export default function CollapsibleDemo() {
const [isOpen, setIsOpen] = React.useState(false)
return (
<Collapsible_Shadcn_ open={isOpen} onOpenChange={setIsOpen} className="w-[350px] space-y-2">
<Collapsible open={isOpen} onOpenChange={setIsOpen} className="w-[350px] space-y-2">
<div className="flex items-center justify-between space-x-4 px-4">
<h4 className="text-sm font-semibold">@peduarte starred 3 repositories</h4>
<CollapsibleTrigger_Shadcn_ asChild>
<CollapsibleTrigger asChild>
<Button type="outline" size="medium" className="w-9 p-0">
<ChevronsUpDown className="h-4 w-4" />
<span className="sr-only">Toggle</span>
</Button>
</CollapsibleTrigger_Shadcn_>
</CollapsibleTrigger>
</div>
<div className="rounded-md border px-4 py-3 font-mono text-sm">@radix-ui/primitives</div>
<CollapsibleContent_Shadcn_ className="space-y-2">
<CollapsibleContent className="space-y-2">
<div className="rounded-md border px-4 py-3 font-mono text-sm">@radix-ui/colors</div>
<div className="rounded-md border px-4 py-3 font-mono text-sm">@stitches/react</div>
</CollapsibleContent_Shadcn_>
</Collapsible_Shadcn_>
</CollapsibleContent>
</Collapsible>
)
}
+3 -3
View File
@@ -62,10 +62,10 @@ export const examples: Registry = [
files: ['example/alert-demo.tsx'],
},
{
name: 'alert-collapsible-demo',
name: 'collapsible-alert-demo',
type: 'components:example',
registryDependencies: ['alert', 'collapsible', 'button'],
files: ['example/alert-collapsible-demo.tsx'],
registryDependencies: ['collapsible-alert'],
files: ['example/collapsible-alert-demo.tsx'],
},
{
name: 'alert-dialog-demo',
@@ -0,0 +1,26 @@
import { GuideTemplate } from '~/features/docs/GuidesMdx.template'
import {
genGuideMeta,
genGuidesStaticParams,
getGuidesMarkdown,
} from '~/features/docs/GuidesMdx.utils'
import { getEmptyArray } from '~/features/helpers.fn'
import { IS_DEV } from '~/lib/constants'
type Params = { slug?: string[] }
const AiToolsGuidePage = async (props: { params: Promise<Params> }) => {
const params = await props.params
const slug = ['ai-tools', ...(params.slug ?? [])]
const data = await getGuidesMarkdown(slug)
return <GuideTemplate {...data!} />
}
const generateStaticParams = !IS_DEV ? genGuidesStaticParams('ai-tools') : getEmptyArray
const generateMetadata = genGuideMeta((params: { slug?: string[] }) =>
getGuidesMarkdown(['ai-tools', ...(params.slug ?? [])])
)
export default AiToolsGuidePage
export { generateMetadata, generateStaticParams }
@@ -1,14 +1,13 @@
import { GuideTemplate, newEditLink } from '~/features/docs/GuidesMdx.template'
import { source } from 'common-tags'
import { notFound } from 'next/navigation'
import {
generateAiPromptMetadata,
generateAiPromptsStaticParams,
generateCursorPromptDeepLink,
getAiPrompt,
wrapInMarkdownCodeBlock,
} from './AiPrompts.utils'
} from '~/app/guides/getting-started/ai-prompts/[slug]/AiPrompts.utils'
import { GuideTemplate, newEditLink } from '~/features/docs/GuidesMdx.template'
import { source } from 'common-tags'
import { notFound } from 'next/navigation'
export const dynamicParams = false
@@ -51,7 +50,7 @@ export default async function AiPromptsPage(props: { params: Promise<{ slug: str
meta={{ title: `AI Prompt: ${heading}` }}
content={content}
editLink={newEditLink(`supabase/supabase/blob/master/examples/prompts/${slug}.md`)}
pathname={`/guides/getting-started/ai-prompts/${slug}`}
pathname={`/guides/ai-tools/ai-prompts/${slug}`}
/>
)
}
+5
View File
@@ -0,0 +1,5 @@
import Layout from '~/layouts/guides'
export default async function AiToolsLayout({ children }: { children: React.ReactNode }) {
return <Layout>{children}</Layout>
}
@@ -0,0 +1,15 @@
import SectionTroubleshootingPage, {
generateSectionTroubleshootingMetadata,
} from '~/features/docs/TroubleshootingSection.page'
export default async function AiToolsTroubleshootingPage() {
return (
<SectionTroubleshootingPage
topic="ai-tools"
sectionName="AI Tools"
description="Search or browse troubleshooting guides for common AI tools issues, including configuration, usage, and integration problems."
/>
)
}
export const metadata = generateSectionTroubleshootingMetadata('AI', 'AI Tools')
@@ -1,5 +1,4 @@
import Link from 'next/link'
import { GlassPanel } from 'ui-patterns'
import { getAiPrompts } from './AiPrompts.utils'
@@ -12,7 +11,7 @@ export async function AiPromptsIndex() {
{prompts.map((prompt) => (
<Link
key={prompt.filename}
href={`/guides/getting-started/ai-prompts/${prompt.filename}`}
href={`/guides/ai-tools/ai-prompts/${prompt.filename}`}
passHref
>
<GlassPanel
+14 -8
View File
@@ -180,10 +180,16 @@ const clientLibraries = [
const additionalResources = [
{
title: 'Management API',
description: 'Manage your Supabase projects and organizations.',
icon: 'reference-api',
href: '/reference/api/introduction',
title: 'AI tools',
description: 'Develop with Supabase AI-first using plugins, MCP, and skills.',
icon: 'ai-tools',
href: '/guides/ai',
},
{
title: 'Platform guides',
description: 'Learn more about the tools and services powering Supabase.',
icon: 'platform',
href: '/guides/platform',
},
{
title: 'Supabase CLI',
@@ -192,10 +198,10 @@ const additionalResources = [
href: '/reference/cli/introduction',
},
{
title: 'Platform Guides',
description: 'Learn more about the tools and services powering Supabase.',
icon: 'platform',
href: '/guides/platform',
title: 'Management API',
description: 'Manage your Supabase projects and organizations.',
icon: 'reference-api',
href: '/reference/api/introduction',
},
{
title: 'Integrations',
+1 -1
View File
@@ -27,7 +27,7 @@ function AiPrompt({ className }: { className?: string }) {
'transition-colors',
className
)}
href="/guides/getting-started/ai-prompts"
href="/guides/ai-tools/ai-prompts"
>
<Sparkles size={14} />
Start with Supabase AI prompts
+25
View File
@@ -0,0 +1,25 @@
import { renderMermaidSVG } from 'beautiful-mermaid'
interface MermaidProps {
chart: string
}
export function Mermaid({ chart }: MermaidProps) {
const svg = renderMermaidSVG(chart, {
bg: 'hsl(var(--background-default))',
fg: 'hsl(var(--foreground-default))',
accent: 'hsl(var(--brand-default))',
muted: 'hsl(var(--foreground-light))',
line: 'hsl(var(--border-strong))',
border: 'hsl(var(--border-strong))',
surface: 'hsl(var(--background-surface-200))',
transparent: true,
})
return (
<div
className="my-6 overflow-x-auto rounded-md border bg-surface-100 p-4 [&>svg]:mx-auto [&>svg]:h-auto [&>svg]:max-w-full"
dangerouslySetInnerHTML={{ __html: svg }}
/>
)
}
@@ -58,6 +58,8 @@ const {
'sdk:swift',
])
const aiToolsEnabled = true
const jsOnly =
!sdkCsharpEnabled && !sdkDartEnabled && !sdkKotlinEnabled && !sdkPythonEnabled && !sdkSwiftEnabled
@@ -148,6 +150,13 @@ export const GLOBAL_MENU_ITEMS: GlobalMenuItems = [
label: 'Build',
menuItems: [
[
{
label: 'AI Tools',
icon: 'ai-tools',
href: '/guides/ai-tools' as `/${string}`,
level: 'ai_tools',
enabled: aiToolsEnabled,
},
{
label: 'Local Development & CLI',
icon: 'dev-cli',
@@ -327,8 +336,9 @@ export const gettingstarted: NavMenuConstant = {
title: 'Start with Supabase',
url: '/guides/getting-started',
items: [
{ name: 'Features', url: '/guides/getting-started/features' },
{ name: 'Build with AI tools', url: '/guides/ai-tools' },
{ name: 'API Keys', url: '/guides/getting-started/api-keys' },
{ name: 'Local Development', url: '/guides/cli/getting-started' },
{ name: 'Architecture', url: '/guides/getting-started/architecture' },
{
name: 'Framework Quickstarts',
@@ -496,32 +506,6 @@ export const gettingstarted: NavMenuConstant = {
},
],
},
{
name: 'AI Tools',
url: undefined,
items: [
{
name: 'Supabase Plugin for AI Coding Agents',
url: '/guides/getting-started/plugins' as `/${string}`,
},
{
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}`,
},
{
name: 'Deploy MCP servers',
url: '/guides/getting-started/byo-mcp' as `/${string}`,
},
],
},
],
}
@@ -2307,6 +2291,47 @@ export const ai: NavMenuConstant = {
],
}
export const ai_tools: NavMenuConstant = {
icon: 'ai-tools',
title: 'AI Tools',
url: '/guides/ai-tools',
enabled: aiToolsEnabled,
items: [
{ name: 'Overview', url: '/guides/ai-tools' },
{
name: 'Agent Plugin',
url: '/guides/ai-tools/plugins' as `/${string}`,
},
{
name: 'MCP server',
url: '/guides/ai-tools/mcp' as `/${string}`,
},
{
name: 'Agent Skills',
url: '/guides/ai-tools/ai-skills' as `/${string}`,
},
{
name: 'Prompts',
url: '/guides/ai-tools/ai-prompts' as `/${string}`,
},
{
name: 'Build AI features',
url: undefined,
items: [
{
name: 'Deploy MCP servers',
url: '/guides/ai-tools/byo-mcp' as `/${string}`,
},
{
name: 'Vectors / Embeddings ',
url: '/guides/ai' as `/${string}`,
},
],
},
],
}
export const local_development: NavMenuConstant = {
icon: 'dev-cli',
title: 'Local Dev / CLI',
@@ -3076,6 +3101,10 @@ export const integrations: NavMenuConstant = {
name: 'Supabase for Platforms',
url: '/guides/integrations/supabase-for-platforms',
},
{
name: 'Supabase partner integration guide',
url: '/guides/integrations/partner-integration-guide',
},
],
},
{ name: 'Integrations', url: undefined, items: [] },
@@ -24,6 +24,7 @@ enum MenuId {
Security = 'security',
SelfHosting = 'self_hosting',
Integrations = 'integrations',
AiTools = 'ai_tools',
LocalDevelopment = 'local_development',
Contributing = 'contributing',
RefJavaScriptV1 = 'reference_javascript_v1',
@@ -134,6 +135,10 @@ const menus: Menu[] = [
id: MenuId.LocalDevelopment,
type: 'guide',
},
{
id: MenuId.AiTools,
type: 'guide',
},
{
id: MenuId.Contributing,
type: 'guide',
@@ -110,7 +110,7 @@ export const getMenuId = (pathname: string | null) => {
pathname = (pathname ??= '').replace(/^\/guides\//, '')
switch (true) {
case pathname.startsWith('ai'):
case pathname.startsWith('ai') && !pathname.startsWith('ai-tools'):
return MenuId.Ai
case pathname.startsWith('api'):
return MenuId.Api
@@ -132,6 +132,8 @@ export const getMenuId = (pathname: string | null) => {
return MenuId.Integrations
case pathname.startsWith('local-development'):
return MenuId.LocalDevelopment
case pathname.startsWith('ai-tools'):
return MenuId.AiTools
case pathname.startsWith('telemetry'):
return MenuId.Telemetry
case pathname.startsWith('platform'):
@@ -3,7 +3,9 @@ import { ChevronDown } from 'lucide-react'
import { useState } from 'react'
import {
Button,
Collapsible,
Collapsible_Shadcn_,
CollapsibleContent_Shadcn_,
CollapsibleTrigger_Shadcn_,
Label_Shadcn_,
Select_Shadcn_,
SelectContent_Shadcn_,
@@ -151,8 +153,8 @@ export default function RealtimeLimitsEstimater({}) {
</div>
)}
<Collapsible open={expandPreview} onOpenChange={setExpandPreview}>
<Collapsible.Trigger asChild>
<Collapsible_Shadcn_ open={expandPreview} onOpenChange={setExpandPreview}>
<CollapsibleTrigger_Shadcn_ asChild>
<div className="py-1 flex items-center">
<p className="text-sm">View raw throughput table</p>
<Button
@@ -168,8 +170,8 @@ export default function RealtimeLimitsEstimater({}) {
onClick={() => setExpandPreview(!expandPreview)}
/>
</div>
</Collapsible.Trigger>
<Collapsible.Content>
</CollapsibleTrigger_Shadcn_>
<CollapsibleContent_Shadcn_>
<div>
{throughputTable
.map((l) => l.computeAddOn)
@@ -218,8 +220,8 @@ export default function RealtimeLimitsEstimater({}) {
</div>
))}
</div>
</Collapsible.Content>
</Collapsible>
</CollapsibleContent_Shadcn_>
</Collapsible_Shadcn_>
</div>
)
}
+19
View File
@@ -0,0 +1,19 @@
---
id: 'ai-tools'
title: 'AI Tools'
subtitle: 'Plugins, MCP, and skills for AI-first Supabase development.'
---
Supabase provides integrations and resources that your AI coding agents can use to build on Supabase:
- [Agent Plugin](/docs/guides/ai-tools/plugins): One-click setup that bundles the MCP server and agent skills into a single install for your AI coding agent.
- [MCP Server](/docs/guides/ai-tools/mcp): Connect your AI agent to your Supabase project using the Model Context Protocol (MCP). Gives your agent tools to read your schema, run queries, manage migrations, and more - directly from your IDE.
- [Agent Skills](/docs/guides/ai-tools/ai-skills): Curated instruction sets that give your AI agent procedural knowledge about working with Supabase.
- [Prompts](/docs/guides/ai-tools/ai-prompts): Ready-to-use prompts for common Supabase tasks. Copy them into your project and reference them from your AI tool for consistent, accurate output.
## Building AI into your app?
The tools above are for your development workflow. If you're building AI capabilities into your own product:
- [Deploy MCP servers](/docs/guides/ai-tools/byo-mcp) - Host your own MCP server on Supabase Edge Functions so your users can connect their AI agents to your product
- [Vectors / Embeddings](/docs/guides/ai) - Build semantic search, RAG pipelines, and other AI-powered features using pgvector
@@ -1,9 +1,9 @@
---
id: 'ai-tools-mcp'
title: 'Model context protocol (MCP)'
title: 'Supabase MCP Server'
subtitle: 'Connect your AI tools to Supabase using MCP'
description: 'Connect your AI tools to Supabase using MCP'
sidebar_label: 'Model context protocol (MCP)'
sidebar_label: 'MCP Server'
---
The [Model Context Protocol](https://modelcontextprotocol.io/introduction) (MCP) is a standard for connecting Large Language Models (LLMs) to platforms like Supabase. Once connected, your AI assistants can interact with and query your Supabase projects on your behalf.
@@ -39,9 +39,9 @@ supabase gen types typescript --project-id=your-project-ref --schema=storage,pub
Find the complete code on [GitHub](https://github.com/supabase/supabase/tree/master/examples/edge-functions/supabase/functions/huggingface-image-captioning).
```ts
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { HfInference } from 'https://esm.sh/@huggingface/inference@2.3.2'
import { createClient } from 'npm:@supabase/supabase-js@2'
import { Database } from './types.ts'
console.log('Hello from `huggingface-image-captioning` function!')
@@ -57,7 +57,7 @@ interface WebhookPayload {
old_record: null | SoRecord
}
serve(async (req) => {
Deno.serve(async (req) => {
const payload: WebhookPayload = await req.json()
const soRecord = payload.record
const SUPABASE_SECRET_KEYS = JSON.parse(Deno.env.get('SUPABASE_SECRET_KEYS')!)
+1 -2
View File
@@ -94,12 +94,11 @@ HUGGING_FACE_ACCESS_TOKEN=<your-token-here>
Let's modify the Edge Function to import Hugging Face's inference client and perform a `text-to-image` request:
```ts
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { HfInference } from 'https://esm.sh/@huggingface/inference@2.3.2'
const hf = new HfInference(Deno.env.get('HUGGING_FACE_ACCESS_TOKEN'))
serve(async (req) => {
Deno.serve(async (req) => {
const { prompt } = await req.json()
const image = await hf.textToImage(
@@ -182,9 +182,7 @@ Custom Access Token Hooks are triggered for **all** token issuance. Use `client_
A common use case is customizing the `audience` claim for different OAuth clients. This allows third-party services to validate that tokens were issued specifically for them:
```typescript
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
serve(async (req) => {
Deno.serve(async (req) => {
const { user, claims, client_id } = await req.json()
// Customize audience based on OAuth client
@@ -230,10 +228,9 @@ The `audience` claim is especially important for:
You can also add custom claims and metadata based on the OAuth client:
```typescript
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
serve(async (req) => {
Deno.serve(async (req) => {
const { user, claims, client_id } = await req.json()
const supabase = createClient(Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SECRET_KEY')!)
@@ -723,7 +723,6 @@ curl -X PATCH "https://api.supabase.com/v1/projects/$PROJECT_REF/config/auth" \
dependencies {
implementation("io.github.jan-tennert.supabase:gotrue-kt:VERSION")
implementation("io.github.jan-tennert.supabase:compose-auth:VERSION")
implementation("io.github.jan-tennert.supabase:compose-auth-ui:VERSION") // Optional, for UI components
}
```
@@ -34,6 +34,7 @@ GET YOUR CERT FROM YOUR PROJECT DASHBOARD
Create a `DenoPostgresDriver.ts` file to manage the connection to Postgres via [deno-postgres](https://deno-postgres.com/):
```ts DenoPostgresDriver.ts
import { Pool, PoolClient } from 'https://deno.land/x/postgres@v0.17.0/mod.ts'
import {
CompiledQuery,
DatabaseConnection,
@@ -44,7 +45,6 @@ import {
} from 'https://esm.sh/kysely@0.23.4'
import { freeze, isFunction } from 'https://esm.sh/kysely@0.23.4/dist/esm/util/object-utils.js'
import { extendStackTrace } from 'https://esm.sh/kysely@0.23.4/dist/esm/util/stack-trace-utils.js'
import { Pool, PoolClient } from 'https://deno.land/x/postgres@v0.17.0/mod.ts'
export interface PostgresDialectConfig {
pool: Pool | (() => Promise<Pool>)
@@ -190,15 +190,15 @@ class PostgresConnection implements DatabaseConnection {
Create an `index.ts` file to execute a query on incoming requests:
```ts index.ts
import { serve } from 'https://deno.land/std@0.175.0/http/server.ts'
import { Pool } from 'https://deno.land/x/postgres@v0.17.0/mod.ts'
import {
Kysely,
Generated,
Kysely,
PostgresAdapter,
PostgresIntrospector,
PostgresQueryCompiler,
} from 'https://esm.sh/kysely@0.23.4'
import { PostgresDriver } from './DenoPostgresDriver.ts'
console.log(`Function "kysely-postgres" up and running!`)
@@ -245,7 +245,7 @@ const db = new Kysely<Database>({
},
})
serve(async (_req) => {
Deno.serve(async (_req) => {
try {
// Run a query
const animals = await db.selectFrom('animals').select(['id', 'animal', 'created_at']).execute()
+9 -9
View File
@@ -11,17 +11,17 @@ hideToc: true
<div className="grid grid-cols-12 gap-6 not-prose">
{[
{
title: 'Features',
{
title: 'Build with AI tools',
description: 'Develop with Supabase AI-first using plugins, MCP, and skills.',
hasLightIcon: true,
href: '/guides/getting-started/features',
description: 'A non-exhaustive list of features that Supabase provides for every project.'
},
{
title: 'Architecture',
href: '/guides/ai',
},
{
title: 'API Keys',
hasLightIcon: true,
href: '/guides/getting-started/architecture',
description: "An overview of Supabase's architecture and product principles.",
href: '/guides/getting-started/api-keys',
description: 'Learn about the different API keys in Supabase and how to use them.',
},
{
title: 'Local Development',
@@ -141,7 +141,7 @@ These variables are safe to expose in your Expo app since Supabase has
const supabaseUrl = YOUR_REACT_NATIVE_SUPABASE_URL
const supabasePublishableKey = YOUR_REACT_NATIVE_SUPABASE_PUBLISHABLE_KEY
const supabase = createClient(supabaseUrl, supabasePublishableKey, {
export const supabase = createClient(supabaseUrl, supabasePublishableKey, {
auth: {
storage: new LargeSecureStore(),
autoRefreshToken: true,
@@ -0,0 +1,278 @@
---
id: 'partner-integration-guide'
title: 'Supabase Partner Integration Guide'
subtitle: 'Integrate Supabase with your platform or product.'
description: 'This guide explains how a Supabase partner can integrate with the Supabase platform via OAuth.'
---
This guide assumes you have already followed [the Build a Supabase Integration guide](/docs/guides/integrations/build-a-supabase-oauth-integration) and have a working OAuth client.
When a Supabase user clicks the **Install Integration** button in the Dashboard, they are redirected to your system to begin the OAuth flow. You can implement this redirect in one of two ways:
- **[Simple redirect](#method-1-simple-redirect)**: Easier to build, but your system cannot verify that the incoming user was sent by Supabase.
- **[Signed redirect](#method-2-signed-redirect)**: More work to build, but cryptographically verifies that the redirect originated from Supabase. **Recommended for production integrations.**
Pick the method that fits your security requirements, then follow the matching section below.
## Method 1: Simple redirect
In this method, you implement a single `GET` endpoint. Supabase redirects the user to this endpoint when they click **Install Integration**, and your endpoint kicks off the OAuth flow.
<Mermaid
chart={`sequenceDiagram
autonumber
participant User
participant Browser
participant Partner as Partner (OAuth Client)
participant Supabase as Supabase (OAuth Authorization Server/Protected Resource)
Note over Browser: Supabase Dashboard Open
User->>Browser: Clicks "Install Integration"
Browser->>Partner: "Connect to Partner" Click Handler Page
Partner->>Browser: Redirect to Supabase Authorization Page
Browser->>Supabase: Request Authorization
Supabase->>Browser: Redirect to Consent Screen
Browser->>User: Display Consent Screen
User->>Browser: Gives Consent
Browser->>Supabase: Submit User Consent
Supabase->>Browser: Redirect to Partner With Authorization Code
Browser->>Partner: Submit Authorization Code
Partner->>Supabase: Exchange Authorization Code for Token
Supabase->>Partner: Return Token
Partner->>Supabase: Request Management API Resources
Supabase->>Partner: Return Resources
Partner->>Browser: Ok 200, OAuth Flow Complete
Browser->>User: Display "Integration Installed" Message`}
/>
### Step 1: Implement the redirect endpoint
Expose a `GET` endpoint at any URL you control:
```
GET https://<your-host>/<optional-path>?project_id=<supabase-project_ref>&organization_slug=<supabase-org-slug>
```
Supabase will append the following query parameters to the URL when redirecting:
| Param | Description |
| ------------------- | ---------------------------------------------------------------------------------------------- |
| `project_id` | Supabase project ref of the project where the user clicked the **Install Integration** button. |
| `organization_slug` | Supabase organization slug where the user clicked the **Install Integration** button. |
Save these parameters in your system. You can use them to fetch project or organization details, or to pre-select a project or organization in your UI once the OAuth flow is complete.
Your endpoint may ask the user to sign up, sign in, or perform other setup tasks on your website. Once those are complete, immediately [redirect to the Supabase authorization URL](/docs/guides/integrations/build-a-supabase-oauth-integration#redirecting-to-the-authorize-url) without further user interaction. This starts the OAuth flow.
### Step 2: Share your endpoint with Supabase
Send Supabase the URL of your endpoint so we can configure the **Install Integration** button to redirect there.
## Method 2: Signed redirect
In this method, Supabase signs a JWT before redirecting the user. You verify the signature, generate a one-time redirect record, and return its URL to Supabase. Supabase then redirects the user to that URL. This guarantees that any redirect your system handles originated from Supabase.
You will implement two endpoints:
1. A `POST` endpoint that validates the signed JWT and returns a unique redirect URL.
2. A `GET` endpoint that handles the user once they are redirected to that URL.
<Mermaid
chart={`sequenceDiagram
autonumber
participant User
participant Browser
participant Partner as Partner (OAuth Client)
participant Supabase as Supabase (OAuth Authorization Server/Protected Resource)
Note over Browser: Supabase Dashboard Open
User->>Browser: Clicks "Install Integration"
Browser->>Supabase: Request Redirect URL Generation
Supabase->>Partner: Send Signed JWT
Partner->>Partner: Validate JWT
Partner->>Partner: Generate Unique Redirect URL
Partner->>Supabase: Return Redirect URL
Supabase->>Browser: Redirect to the Redirect URL
Browser->>Partner: Visit Redirect URL
Partner->>Partner: Validate Redirect Record
Partner->>Browser: Optional User Actions
Browser->>User: User Performs Optional Actions
User->>Browser: User Optional Actions Complete
Browser->>Partner: User Optional Actions Complete
Partner->>Browser: Redirect to Supabase Authorization Page
Browser->>Supabase: Request Authorization
Supabase->>Browser: Redirect to Consent Screen
Browser->>User: Display Consent Screen
User->>Browser: Gives Consent
Browser->>Supabase: Submit User Consent
Supabase->>Browser: Redirect to Partner With Authorization Code
Browser->>Partner: Submit Authorization Code
Partner->>Supabase: Exchange Authorization Code for Token
Supabase->>Partner: Return Token
Partner->>Supabase: Request Management API Resources
Supabase->>Partner: Return Resources
Partner->>Browser: Ok 200, OAuth Flow Complete
Browser->>User: Display "Integration Installed" Message`}
/>
### Step 1: Exchange public keys with Supabase
Supabase generates two key-pairs, one for staging, one for production, and shares the public keys with you. Save both public keys and their key IDs in your system. The keys are PEM-encoded EC P-256.
Example:
```
===============Staging==================
Key ID: pik_3038669348a3ea75dbaf0655
Public Key:
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEnj3NmwrLPPH/3isvpS601ndQP9Mk
zqppdLDV9YfmoF4wavTyb9UTVE5pJ0fukpo5aOoNb4fBZgESsedIUoEn8Q==
-----END PUBLIC KEY-----
==============Production================
Key ID: pik_89e80ddbca9df41b97e28986
Public Key:
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEWCGhwtFWn4jpWZNeyZpTlaAdq/tD
/yBaN0gFPpS8LTFiCPFgnWKbVe3RfExXh7bEhcrEUdWycmYvwrNklWWHRA==
-----END PUBLIC KEY-----
```
Look up the correct public key by the `kid` field in the JWT header.
Storing keys by ID enables zero-downtime key rotation. When Supabase rotates keys, we share new key-pairs and start signing JWTs with the new key ID. Your system picks the correct key based on `kid` without any code changes.
<Admonition type="note">
In future, we plan to publish keys at a `.../.well-known/jwks.json` URL and fully automate key rotation. For now this is done manually.
</Admonition>
### Step 2: Implement the redirect record endpoint
This endpoint receives the signed JWT from Supabase and returns a one-time redirect URL.
Host the endpoint at any URL and path you control. Do not require authentication on this endpoint, but apply rate limiting to prevent abuse.
```
POST https://<your-api-host>/<your-api-path>
Content-Type: application/json
```
#### Request body
```json
{
"token": "<signed-jwt>"
}
```
#### JWT fields
The token is a JWT signed with the ES256 private key.
**Protected header (JOSE header)**
| Field | Required | Description |
| ----- | -------- | ---------------------------------------------------------------------------------------------------------- |
| `alg` | Yes | Always `ES256` — the only signing algorithm currently supported. |
| `kid` | Yes | The key ID identifying the key pair. Use this to pick the correct public key when verifying the signature. |
**Payload (claims)**
| Claim | Required | Description |
| ------------------- | -------- | ----------------------------------------------------------------------------------------------------------------- |
| `iss` | Yes | Always `supabase`. |
| `aud` | Yes | A unique string identifying the audience of this JWT. Usually a URL. |
| `iat` | Yes | Issued-at timestamp (seconds since epoch). |
| `exp` | Yes | Expiry timestamp — at most 5 minutes after `iat`. |
| `organization_slug` | No | The Supabase organization the user is connecting from. Use to pre-select the org during the OAuth consent screen. |
| `project_id` | No | The Supabase project ref the user wants to connect. Use to pre-select a Supabase project in your UI. |
**Signature**
The header and claims are signed with the EC P-256 private key.
#### Validation
When you receive a request:
1. Read the `kid` field from the JWT header and look up the matching public key.
2. Verify the JWT signature with that public key.
3. Verify that `alg` is `ES256`.
4. Verify that `iss` is `supabase`.
5. Verify that `aud` matches the value you agreed with Supabase.
6. Verify that the current time is between `iat` and `exp`.
If any check fails, return `401 Unauthorized`.
If validation succeeds, generate a UUID to identify this redirect record (also called an integration record), save it in your system with an expiry (typically 1 hour), and return it in the response. The expiry prevents records from accumulating and limits the window in which a leaked record can be used.
#### Response body
```json
{
"integrationId": "<a unique uuid>",
"redirectUrl": "https://<your-api-host>/<your-api-path>/<integration-id>",
"expiresAt": "<timestamp>"
}
```
| Field | Description |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `integrationId` | A UUID uniquely identifying the redirect record. |
| `redirectUrl` | The URL the user will be redirected to. Must contain the `integrationId` somewhere in its path so you can retrieve the record on redirect. |
| `expiresAt` | The time when the redirect record expires (typically 1 hour from creation). The user must begin the flow before this time. |
### Step 3: Implement the redirect handler endpoint
This is the `GET` endpoint at the `redirectUrl` you returned in the previous step. The redirect record's UUID must be in its path.
When a user arrives at this endpoint:
1. Extract the redirect record UUID from the URL path.
2. Look it up in your system. If it doesn't exist or has expired, return `401 Unauthorized`.
3. Optionally, walk the user through any setup required on your side — for example, signing up, signing in, or configuring your system so the integration will work.
4. Redirect the user to the [Supabase authorization URL](/docs/guides/integrations/build-a-supabase-oauth-integration#redirecting-to-the-authorize-url) to start the OAuth flow.
<Admonition type="tip">
If you need the user to perform setup steps on your site, design the experience as a wizard that ends by redirecting to the Supabase authorization URL. This minimizes the chance of users getting distracted, navigating elsewhere on your site, and abandoning the OAuth flow.
</Admonition>
### Step 4: Share your details with Supabase
Send Supabase the following so we can configure the **Install Integration** button:
- The URL of your redirect record endpoint ([Step 2](#step-2-implement-the-redirect-record-endpoint)).
- The URL pattern of your redirect handler endpoint ([Step 3](#step-3-implement-the-redirect-handler-endpoint)).
- The `aud` claim value you want Supabase to send in the signed JWT.
Ask Supabase for the public keys and key IDs for the staging and production environments.
@@ -207,6 +207,28 @@ Contains your application's Site URL. This can be configured in your project's [
<p>Visit <a href="{{ .SiteURL }}">here</a> to log in.</p>
```
### `RedirectTo`
Contains the redirect URL passed as the `redirectTo` option in the auth method call.
**Usage**
```html
<a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email&next={{ .RedirectTo }}">
Confirm your email
</a>
```
### `Data`
Contains metadata from `auth.users.user_metadata`. Use this to personalize the email message.
**Usage**
```html
<p>Hello {{ .Data.first_name }}, please confirm your signup.</p>
```
### `Email`
Contains the user's email address.
@@ -111,6 +111,21 @@ You can access them only by:
### Signing URLs
<Admonition type="note">
Storage signed URLs are signed with a dedicated internal key that is separate from your project's Auth JWT signing key. Each project has its own signing key.
Because signed URLs use this separate key, they are not affected by:
- Rotating or revoking Auth JWT legacy secret or signing key
- Disabling legacy keys in the dashboard
- Switching from HS256 to asymmetric (ES256) signing key
Signed URLs remain valid until their expiry time regardless of any Auth key changes.
If you need to revoke signed URLs, [contact Supabase support](/dashboard/support/new?projectRef=).
</Admonition>
You can sign a time-limited URL that you can share to your users by invoking the `createSignedUrl` method on the SDK.
```js
@@ -13,7 +13,7 @@ The self-hosted version is pretty similar to the hosted one. It might not always
### Feature availability
To know what features are available in the self-hosted version, refer to the comprehensive list here:
[Supabase Features](/docs/guides/getting-started/features#generally-available)
[Supabase Features](/features)
### Self-hosting documentation
@@ -19,3 +19,5 @@ If you are observing a `RateLimitError: "Rate limit exceeded for trace ..."` err
- **Consolidate Logic:** Refactor the logic into a shared library or move it directly into the calling function to eliminate the need for cross-function network calls.
You can monitor these errors by visiting the [logs explorer](/dashboard/project/_/logs/explorer) to identify which executions are exceeding their trace budget.
For more details on how recursive invocations and per-trace rate limiting work, see the recursive functions guide: [Recursive Functions — What gets rate limited](/docs/guides/functions/recursive-functions#what-gets-rate-limited).
@@ -7,4 +7,4 @@ keywords = [ "password", "reset" ]
database_id = "6e4a515c-5e70-4b92-808c-f076d47d4e35"
---
You can reset your database password from the [Database Settings page](/dashboard/project/_/database/settings) on the project dashboard.
You can reset your database password from the [**Database > Settings**](/dashboard/project/_/database/settings) section of the Dashboard.
@@ -172,7 +172,7 @@ export const config = {
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { createClient } from '@/utils/supabase/server';
import { createClient } from '@/lib/supabase/server';
export async function login(formData: FormData) {
const supabase = createClient();
@@ -245,7 +245,7 @@ export default function LoginPage() {
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs';
// with
import { createClient } from '@/utils/supabase/client';
import { createClient } from '@/lib/supabase/client';
export default async function Page() {
// replace this line
@@ -266,7 +266,7 @@ import { cookies } from 'next/headers';
import { createServerComponentClient } from '@supabase/auth-helpers-nextjs';
// with
import { createClient } from '@/utils/supabase/server';
import { createClient } from '@/lib/supabase/server';
export default async function Page() {
// replace
@@ -290,7 +290,7 @@ import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs';
import { cookies } from 'next/headers';
// with
import { createClient } from '@/utils/supabase/server';
import { createClient } from '@/lib/supabase/server';
export async function POST(request: Request) {
// replace
@@ -26,12 +26,8 @@ If you haven’t migrated to asymmetric JWT signing keys:
{/* supa-mdx-lint-disable Rule004ExcludeWords */}
1. Go to [**Project Settings** → **JWT Keys**](/dashboard/project/_/settings/jwt) in the Supabase Dashboard
2. Navigate to the **Legacy JWT Secret** tab
3. Click on **Change Legacy Secret**
- Click on **Generate a random secret** to let Supabase decide the JWT secret.
- Click on **Create my own secret** to enter a custom JWT secret
4. You will see a Confirmation dialog. Read it through and confirm to proceed.
We recommend that you migrate to asymmetric JWT signing keys and publishable/secret API keys as it is no longer possible to rotate the legacy anon, service and JWT secrets.
You can view this [**Get Started guide**](/docs/guides/auth/signing-keys#getting-started) for steps to migrate to asymmetric JWT signing keys.
If you have migrated to new symmetric JWT signing keys:
@@ -9,6 +9,7 @@ import { ComputeDiskLimitsTable } from '~/components/ComputeDiskLimitsTable'
import { Extensions } from '~/components/Extensions'
import Image, { type ImageProps } from '~/components/Image'
import { JwtGeneratorSimple } from '~/components/JwtGenerator'
import { Mermaid } from '~/components/Mermaid'
import { MetricsStackCards } from '~/components/MetricsStackCards'
import { NavData } from '~/components/NavData'
import { Price } from '~/components/Price'
@@ -70,6 +71,7 @@ const components = {
JwtGeneratorSimple,
Link,
McpConfigPanel,
Mermaid,
MetricsStackCards,
NamedCodeBlock,
NavData,
@@ -1,7 +1,7 @@
import { type NavMenuConstant } from '~/components/Navigation/Navigation.types'
import {
GLOBAL_MENU_ITEMS,
ai,
ai_tools,
api,
auth,
cron,
@@ -9,6 +9,7 @@ import {
deployment,
functions,
gettingstarted,
GLOBAL_MENU_ITEMS,
graphql,
integrations,
local_development,
@@ -33,6 +34,7 @@ const SECTION_NAV_MAPS: Record<string, NavMenuConstant> = {
'getting-started': gettingstarted,
graphql,
integrations,
'ai-tools': ai_tools,
'local-development': local_development,
platform,
queues,
@@ -60,6 +60,7 @@ export const TroubleshootingSchema = z
topics: z.array(
z.enum([
'ai',
'ai-tools',
'auth',
'branching',
'cli',
+2 -1
View File
@@ -56,10 +56,11 @@
"@octokit/plugin-retry": "^8.1.0",
"@sentry/nextjs": "catalog:",
"@supabase/supabase-js": "catalog:",
"@tanstack/react-query": "^5.13.4",
"@tanstack/react-query": "^5.100.10",
"acorn": "^8.11.3",
"acorn-typescript": "^1.4.13",
"ai-commands": "workspace:*",
"beautiful-mermaid": "^1.1.3",
"common": "workspace:*",
"common-tags": "^1.8.2",
"config": "workspace:*",
+8
View File
@@ -38,6 +38,8 @@ Bobbie Soedirgo
Brad Gyger
Brendan Stephens
Brent Newson
Brian Cullen
Brent Schroeter
Carel de Waal
Cameron Blackwood
Cameron Michie
@@ -53,6 +55,7 @@ Chris Gwilliams
Chris Martin
Chris Stockton
Chris Ward
Christian Funkhouser
Clayton Kast
Colin Goodheart-Smithe
Colin Murray
@@ -123,6 +126,7 @@ Jean-Paul Argudo
Jeff Smick
Jenny Kibiri
Jeremias Menichelli
Jess Fink
Jess Shears
Jim Brodeur
Jim Chanco Jr
@@ -142,6 +146,7 @@ Jordan McQueen
Jordi Enric
José Luis Ledesma
Joshen Lim
Julian Domke
Julien Goux
Kai M
Kalleby Santos
@@ -174,6 +179,7 @@ Manuel Mendez
Manan Gupta
Marcus Weiner
Margarita Sandomirskaia
Marija Milicevic
Mark Burggraf
Matthew Hambright
Matt Hudson
@@ -206,11 +212,13 @@ Paweł Gulbinowicz
Pedro Rodrigues
Peter Lyn
Peter Soderberg
Pierre Ducroquet
Qiao Han
Rafael Chacón
Raminder Singh
Raúl Barroso
Riccardo Busetti
Richard Kasprzak
Rob Shields
Rodrigo Esteves
Rodrigo Mansueli
@@ -1,4 +1,4 @@
import type { PostgresTable } from '@supabase/postgres-meta'
import type { PGTable } from '@supabase/pg-meta'
import { ArrowRight } from 'lucide-react'
import type { PropsWithChildren } from 'react'
import type { RenderCellProps } from 'react-data-grid'
@@ -38,7 +38,7 @@ export const ForeignKeyFormatter = (props: Props) => {
r.source_column_name === column.name
)
const { data: targetTable, isPending: isLoadingTargetTable } = useTableQuery<PostgresTable>(
const { data: targetTable, isPending: isLoadingTargetTable } = useTableQuery<PGTable>(
{
projectRef: project?.ref,
connectionString: project?.connectionString,
@@ -1,4 +1,4 @@
import { PostgresTable } from '@supabase/postgres-meta'
import type { PGTable } from '@supabase/pg-meta'
import { keepPreviousData } from '@tanstack/react-query'
import { useParams } from 'common'
import { Key } from 'lucide-react'
@@ -29,7 +29,7 @@ import { useTableRowsQuery } from '@/data/table-rows/table-rows-query'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
interface ReferenceRecordPeekProps {
table: PostgresTable
table: PGTable
column: string
value: string | number | Record<string, unknown>
}
@@ -1,6 +1,6 @@
import { DndContext, DragOverlay, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'
import { horizontalListSortingStrategy, SortableContext } from '@dnd-kit/sortable'
import type { PostgresColumn } from '@supabase/postgres-meta'
import type { PGColumn } from '@supabase/pg-meta'
import { forwardRef, memo, Ref, useCallback, useMemo, useRef, useState } from 'react'
import DataGrid, {
CalculatedColumn,
@@ -137,7 +137,7 @@ export const Grid = memo(
tableEditorSnap.onEditForeignKeyColumnValue({
foreignKey,
row,
column: column as unknown as PostgresColumn,
column: column as unknown as PGColumn,
})
}
}
@@ -1,5 +1,4 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useQueryClient } from '@tanstack/react-query'
import { InputVariants } from '@ui/components/shadcn/ui/input'
import { useParams } from 'common'
import { Eye, EyeOff } from 'lucide-react'
@@ -7,10 +6,9 @@ import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { Button, cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
import { useRevealedSecret } from './useRevealedSecret'
import CopyButton from '@/components/ui/CopyButton'
import { useAPIKeyIdQuery } from '@/data/api-keys/api-key-id-query'
import { APIKeysData } from '@/data/api-keys/api-keys-query'
import { apiKeysKeys } from '@/data/api-keys/keys'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
export function ApiKeyPill({
@@ -18,94 +16,70 @@ export function ApiKeyPill({
}: {
apiKey: Extract<APIKeysData[number], { type: 'secret' | 'publishable' }>
}) {
const queryClient = useQueryClient()
const { ref: projectRef } = useParams()
// State that controls whether to show the full API key
const [show, setShowState] = useState(false)
const [show, setShow] = useState(false)
const isSecret = apiKey.type === 'secret'
// Permission check for revealing/copying secret API keys
const { can: canManageSecretKeys, isLoading: isLoadingPermission } = useAsyncCheckPermissions(
PermissionAction.READ,
'service_api_keys'
)
// This query only runs when show=true (enabled: show)
// It fetches the fully revealed API key when needed
const {
data,
error,
isPending: isLoading,
refetch: refetchApiKey,
} = useAPIKeyIdQuery(
{
projectRef,
id: apiKey.id as string,
reveal: true, // Request the unmasked key
},
{
enabled: show, // Only run query when show is true
staleTime: 0, // Always consider data stale
gcTime: 0, // Don't cache the key data
}
)
data: revealedKey,
isLoading,
reveal,
clear,
} = useRevealedSecret({
projectRef,
id: apiKey.id as string,
})
// Auto-hide timer for the API key (security feature)
useEffect(() => {
if (show && data?.api_key) {
// Auto-hide the key after 10 seconds
if (show && revealedKey) {
const timer = setTimeout(() => {
setShowState(false)
// Clear the cached key from memory
queryClient.removeQueries({
queryKey: apiKeysKeys.single(projectRef, apiKey.id as string),
exact: true,
})
}, 10000) // Hide after 10 seconds
setShow(false)
clear()
}, 10000)
return () => clearTimeout(timer)
}
}, [show, data?.api_key, projectRef, queryClient, apiKey.id])
}, [show, revealedKey, clear])
async function onSubmitToggle() {
// Don't reveal key if not allowed or loading
async function onToggleShow() {
if (isSecret && !canManageSecretKeys) return
if (isLoadingPermission) return
// Toggle the show state
setShowState(!show)
if (show) {
setShow(false)
clear()
} else {
setShow(true)
try {
await reveal()
} catch {
toast.error('Failed to reveal secret API key')
setShow(false)
}
}
}
async function onCopy() {
// If key is already revealed, use that value
if (data?.api_key) return data?.api_key ?? ''
if (!isSecret) return apiKey.api_key
if (revealedKey) return revealedKey
try {
// Fetch full key and immediately clear from cache after copying
const result = await refetchApiKey()
queryClient.removeQueries({
queryKey: apiKeysKeys.single(projectRef, apiKey.id as string),
exact: true,
})
if (result.isSuccess) return result.data.api_key ?? ''
if (error) {
toast.error('Failed to copy secret API key')
return ''
}
} catch (error) {
console.error('Failed to fetch API key:', error)
return ''
const key = await reveal()
clear()
return key ?? ''
} catch {
toast.error('Failed to copy secret API key')
return apiKey.api_key
}
// Fallback to the masked version if fetch fails
return apiKey.api_key
}
// States for disabling buttons/showing tooltips
const isRestricted = isSecret && !canManageSecretKeys
return (
@@ -123,7 +97,7 @@ export function ApiKeyPill({
{isSecret ? (
<>
<span>{apiKey?.api_key.slice(0, 15)}</span>
<span>{show && data?.api_key ? data?.api_key.slice(15) : '••••••••••••••••'}</span>
<span>{show && revealedKey ? revealedKey.slice(15) : '••••••••••••••••'}</span>
</>
) : (
<span title={apiKey.api_key} className="truncate">
@@ -141,7 +115,7 @@ export function ApiKeyPill({
className="rounded-full px-2 pointer-events-auto"
loading={show && isLoading}
icon={show ? <EyeOff strokeWidth={2} /> : <Eye strokeWidth={2} />}
onClick={onSubmitToggle}
onClick={onToggleShow}
disabled={isRestricted}
/>
</TooltipTrigger>
@@ -0,0 +1,43 @@
import { useCallback, useRef, useState } from 'react'
import { getAPIKeysById } from '@/data/api-keys/api-key-id-query'
interface UseRevealedSecretOptions {
projectRef?: string
id?: string
}
export function useRevealedSecret({ projectRef, id }: UseRevealedSecretOptions) {
const [data, setData] = useState<string | undefined | null>()
const [isLoading, setIsLoading] = useState(false)
const requestIdRef = useRef(0)
const reveal = useCallback(async () => {
if (!projectRef || !id) return
const requestId = ++requestIdRef.current
setIsLoading(true)
try {
const result = await getAPIKeysById({ projectRef, id, reveal: true })
if (requestId !== requestIdRef.current) return
setData(result.api_key)
return result.api_key
} catch (error) {
if (requestId !== requestIdRef.current) return
console.error('Failed to reveal secret key:', error)
throw error
} finally {
if (requestId === requestIdRef.current) {
setIsLoading(false)
}
}
}, [projectRef, id])
const clear = useCallback(() => {
requestIdRef.current++
setData(undefined)
}, [])
return { data, isLoading, reveal, clear }
}
@@ -3,6 +3,7 @@ import { PropsWithChildren } from 'react'
import { OrganizationResourceBanner } from '../Organization/HeaderBanner'
import { ClockSkewBanner } from '@/components/layouts/AppLayout/ClockSkewBanner'
import { FlyDeprecationBanner } from '@/components/layouts/AppLayout/FlyDeprecationBanner'
import { NoticeBanner, NoticeBanner2 } from '@/components/layouts/AppLayout/NoticeBanner'
import { StatusPageBanner } from '@/components/layouts/AppLayout/StatusPageBanner'
@@ -17,6 +18,7 @@ export const AppBannerWrapper = ({ children }: PropsWithChildren<{}>) => {
<StatusPageBanner />
{showNoticeBanner && <NoticeBanner />}
{showNoticeBanner2 && <NoticeBanner2 />}
<FlyDeprecationBanner />
<OrganizationResourceBanner />
{/* Disabled until reintroduced or removed altogether. */}
{/* <TaxIdBanner /> */}
@@ -28,7 +28,7 @@ const FeaturePreviewContext = createContext<FeaturePreviewContextType>({
export const useFeaturePreviewContext = () => useContext(FeaturePreviewContext)
export const FeaturePreviewContextProvider = ({ children }: PropsWithChildren<{}>) => {
export const FeaturePreviewContextProvider = ({ children }: PropsWithChildren) => {
const { hasLoaded } = useContext(FeatureFlagContext)
const featurePreviews = useFeaturePreviews()
@@ -93,8 +93,8 @@ export const useUnifiedLogsPreview = () => {
useIsEnterpriseOrSupabaseOrg()
const isLoading = !flagsHaveLoaded || isOrgLoading
const isEligible = unifiedLogsEnabled && isEnterpriseOrSupabaseOrg
const isEnabled = unifiedLogsEnabled && flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_UNIFIED_LOGS]
const isEligible = unifiedLogsEnabled || isEnterpriseOrSupabaseOrg
const isEnabled = isEligible && flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_UNIFIED_LOGS]
const enable = () => onUpdateFlag(LOCAL_STORAGE_KEYS.UI_PREVIEW_UNIFIED_LOGS, true)
const disable = () => onUpdateFlag(LOCAL_STORAGE_KEYS.UI_PREVIEW_UNIFIED_LOGS, false)
@@ -37,7 +37,7 @@ export const useFeaturePreviews = (): FeaturePreview[] => {
key: LOCAL_STORAGE_KEYS.UI_PREVIEW_UNIFIED_LOGS,
name: 'New Logs interface',
discussionsUrl: 'https://github.com/orgs/supabase/discussions/37234',
enabled: isUnifiedLogsPreviewAvailable && isEnterpriseOrSupabaseOrg,
enabled: isUnifiedLogsPreviewAvailable || isEnterpriseOrSupabaseOrg,
isNew: false,
isPlatformOnly: true,
isDefaultOptIn: false,
@@ -1,3 +1,5 @@
import type { DisplayableSqlFragment, SafeSqlFragment } from '@supabase/pg-meta'
export interface PolicyFormField {
id?: number
name: string
@@ -19,8 +21,8 @@ export interface PostgresPolicyCreatePayload {
name: string
table: string
schema?: string
definition?: string
check?: string
definition?: SafeSqlFragment
check?: SafeSqlFragment
action?: 'PERMISSIVE' | 'RESTRICTIVE'
command?: 'SELECT' | 'INSERT' | 'UPDATE' | 'DELETE' | 'ALL'
roles?: string[]
@@ -29,7 +31,30 @@ export interface PostgresPolicyCreatePayload {
export interface PostgresPolicyUpdatePayload {
id: number
name?: string
definition?: string
check?: string
definition?: SafeSqlFragment
check?: SafeSqlFragment
roles?: string[]
}
/**
* Pre-acceptance shape of {@link PostgresPolicyCreatePayload}. `definition`/`check` are
* `DisplayableSqlFragment` because they originate from form input and have not yet been
* promoted via `acceptUntrustedSql`. Promotion must happen at the user gesture
* (the Save click in `PolicyEditorModal`), not inside a utility.
*/
export interface DraftPostgresPolicyCreatePayload extends Omit<
PostgresPolicyCreatePayload,
'definition' | 'check'
> {
definition?: DisplayableSqlFragment
check?: DisplayableSqlFragment
}
/** Pre-acceptance shape of {@link PostgresPolicyUpdatePayload}. See {@link DraftPostgresPolicyCreatePayload}. */
export interface DraftPostgresPolicyUpdatePayload extends Omit<
PostgresPolicyUpdatePayload,
'definition' | 'check'
> {
definition?: DisplayableSqlFragment
check?: DisplayableSqlFragment
}
@@ -1,3 +1,4 @@
import { safeSql } from '@supabase/pg-meta'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
@@ -249,7 +250,7 @@ describe('Policies.utils - Policy Generation', () => {
command: 'SELECT',
table: 'posts',
schema: 'public',
definition: 'true',
definition: safeSql`true`,
action: 'PERMISSIVE',
roles: ['public'],
},
@@ -342,7 +343,7 @@ describe('Policies.utils - Policy Generation', () => {
command: 'SELECT',
table: 'posts',
schema: 'public',
definition: 'true',
definition: safeSql`true`,
action: 'PERMISSIVE',
roles: ['public'],
},
@@ -1,12 +1,19 @@
import { ident } from '@supabase/pg-meta/src/pg-format'
import type { PostgresPolicy } from '@supabase/postgres-meta'
import {
acceptUntrustedSql,
ident,
safeSql,
untrustedSql,
type DisplayableSqlFragment,
type SafeSqlFragment,
} from '@supabase/pg-meta'
import type { PGPolicy } from '@supabase/pg-meta'
import { has, isEmpty, isEqual } from 'lodash'
import {
DraftPostgresPolicyCreatePayload,
DraftPostgresPolicyUpdatePayload,
PolicyFormField,
PolicyForReview,
PostgresPolicyCreatePayload,
PostgresPolicyUpdatePayload,
} from './Policies.types'
import { generateSqlPolicy } from '@/data/ai/sql-policy-mutation'
import type { CreatePolicyBody } from '@/data/database-policies/database-policy-create-mutation'
@@ -19,7 +26,7 @@ import type { ForeignKeyConstraint } from '@/data/database/foreign-key-constrain
export const createSQLPolicy = (
policyFormFields: PolicyFormField,
originalPolicyFormFields?: PostgresPolicy
originalPolicyFormFields?: PGPolicy
) => {
const { definition, check } = policyFormFields
const formattedPolicyFormFields = {
@@ -113,45 +120,48 @@ const createSQLStatementForUpdatePolicy = (
return { description, statement }
}
// These constructors return DRAFT payloads — `definition`/`check` are still
// `DisplayableSqlFragment`. Promotion to `SafeSqlFragment` must happen at the user gesture
// (the Save click in `PolicyEditorModal`), not here, since this module has no guarantee that
// it was reached via a deliberate user action.
export const createPayloadForCreatePolicy = (
policyFormFields: PolicyFormField
): PostgresPolicyCreatePayload => {
const { command, definition, check, roles } = policyFormFields
): DraftPostgresPolicyCreatePayload => {
const { name, schema, table, command, definition, check, roles } = policyFormFields
return {
...policyFormFields,
name,
schema,
table,
action: 'PERMISSIVE',
command: command || undefined,
definition: definition || undefined,
check: check || undefined,
definition: !definition ? undefined : untrustedSql(definition),
check: !check ? undefined : untrustedSql(check),
roles: roles.length > 0 ? roles : undefined,
}
}
export const createPayloadForUpdatePolicy = (
policyFormFields: PolicyFormField,
originalPolicyFormFields: PostgresPolicy
): PostgresPolicyUpdatePayload => {
originalPolicyFormFields: PGPolicy
): DraftPostgresPolicyUpdatePayload => {
const { definition, check } = policyFormFields
const formattedPolicyFormFields = {
...policyFormFields,
definition: definition ? definition.replace(/\s+/g, ' ').trim() : definition,
check: check ? check.replace(/\s+/g, ' ').trim() : check,
}
const formattedDefinition = definition ? definition.replace(/\s+/g, ' ').trim() : definition
const formattedCheck = check ? check.replace(/\s+/g, ' ').trim() : check
const payload: PostgresPolicyUpdatePayload = { id: originalPolicyFormFields.id }
const payload: DraftPostgresPolicyUpdatePayload = { id: originalPolicyFormFields.id }
if (!isEqual(formattedPolicyFormFields.name, originalPolicyFormFields.name)) {
payload.name = formattedPolicyFormFields.name
if (!isEqual(policyFormFields.name, originalPolicyFormFields.name)) {
payload.name = policyFormFields.name
}
if (!isEqual(formattedPolicyFormFields.definition, originalPolicyFormFields.definition)) {
payload.definition = formattedPolicyFormFields.definition || undefined
if (!isEqual(formattedDefinition, originalPolicyFormFields.definition)) {
payload.definition = !formattedDefinition ? undefined : untrustedSql(formattedDefinition)
}
if (!isEqual(formattedPolicyFormFields.check, originalPolicyFormFields.check)) {
payload.check = formattedPolicyFormFields.check || undefined
if (!isEqual(formattedCheck, originalPolicyFormFields.check)) {
payload.check = !formattedCheck ? undefined : untrustedSql(formattedCheck)
}
if (!isEqual(formattedPolicyFormFields.roles, originalPolicyFormFields.roles)) {
if (formattedPolicyFormFields.roles.length === 0) payload.roles = ['public']
else payload.roles = formattedPolicyFormFields.roles || undefined
if (!isEqual(policyFormFields.roles, originalPolicyFormFields.roles)) {
if (policyFormFields.roles.length === 0) payload.roles = ['public']
else payload.roles = policyFormFields.roles || undefined
}
return payload
@@ -160,17 +170,43 @@ export const createPayloadForUpdatePolicy = (
// --- Policy Generation ---
/**
* Generated policy extends CreatePolicyBody with additional fields for display.
* - sql: Full CREATE POLICY SQL statement for preview
* - Required fields that are optional in CreatePolicyBody
* A policy generated for display/staging in the table editor.
* `definition`/`check` are `DisplayableSqlFragment` because generators have different provenance:
* programmatic generation produces `SafeSqlFragment` (composed via `safeSql`), AI generation
* produces `UntrustedSqlFragment` (third-party output). Consumers must promote via
* `acceptUntrustedSql` at a user gesture before executing.
*/
export type GeneratedPolicy = Required<
Pick<CreatePolicyBody, 'name' | 'table' | 'schema' | 'action' | 'roles'>
> &
Pick<CreatePolicyBody, 'command' | 'definition' | 'check'> & {
Pick<CreatePolicyBody, 'command'> & {
definition?: DisplayableSqlFragment
check?: DisplayableSqlFragment
sql: string
}
/**
* A {@link GeneratedPolicy} whose `definition`/`check` have already been promoted to
* `SafeSqlFragment`. Producing one of these is the contract that says: the user gesture
* required to execute this SQL has already happened.
*/
export type AcceptedGeneratedPolicy = Omit<GeneratedPolicy, 'definition' | 'check'> & {
definition?: SafeSqlFragment
check?: SafeSqlFragment
}
/**
* Promotes a {@link GeneratedPolicy} to an {@link AcceptedGeneratedPolicy}.
* ONLY call from an event handler tied to a deliberate user action (e.g. the Save click
* on the table editor). Never call from useEffect, render, or any path that runs without
* a user gesture.
*/
export const acceptGeneratedPolicy = (policy: GeneratedPolicy): AcceptedGeneratedPolicy => ({
...policy,
definition: policy.definition === undefined ? undefined : acceptUntrustedSql(policy.definition),
check: policy.check === undefined ? undefined : acceptUntrustedSql(policy.check),
})
type Relationship = {
source_schema: string
source_table_name: string
@@ -270,23 +306,22 @@ const findPathToAuthUsers = (
}
/** Generates SQL expression for RLS policy based on FK path to auth.users */
const buildPolicyExpression = (path: Relationship[]): string => {
if (path.length === 0) return ''
const buildPolicyExpression = (path: Relationship[]): SafeSqlFragment => {
if (path.length === 0) return safeSql``
// Direct FK to auth.users
if (path.length === 1) {
return `(select auth.uid()) = ${ident(path[0].source_column_name)}`
return safeSql`(select auth.uid()) = ${ident(path[0].source_column_name)}`
}
// Indirect path - build EXISTS with JOINs
const [first, ...rest] = path
const firstTarget = `${ident(first.target_table_schema)}.${ident(first.target_table_name)}`
const source = `${ident(first.source_schema)}.${ident(first.source_table_name)}`
const firstTarget = safeSql`${ident(first.target_table_schema)}.${ident(first.target_table_name)}`
const source = safeSql`${ident(first.source_schema)}.${ident(first.source_table_name)}`
const last = path[path.length - 1]
const joins = rest
.slice(0, -1)
.map((r) => {
const joins = rest.slice(0, -1).reduce<SafeSqlFragment>(
(acc, r) => {
const targetSchema = ident(r.target_table_schema)
const targetTable = ident(r.target_table_name)
const targetColumn = ident(r.target_column_name)
@@ -294,11 +329,13 @@ const buildPolicyExpression = (path: Relationship[]): string => {
const sourceSchema = ident(r.source_schema)
const sourceTable = ident(r.source_table_name)
const sourceColumn = ident(r.source_column_name)
return `join ${targetSchema}.${targetTable} on ${targetSchema}.${targetTable}.${targetColumn} = ${sourceSchema}.${sourceTable}.${sourceColumn}`
})
.join('\n ')
const join = safeSql`join ${targetSchema}.${targetTable} on ${targetSchema}.${targetTable}.${targetColumn} = ${sourceSchema}.${sourceTable}.${sourceColumn}`
return acc.length === 0 ? join : safeSql`${acc}\n ${join}`
},
safeSql``
)
return `exists (
return safeSql`exists (
select 1 from ${firstTarget}
${joins}
where ${firstTarget}.${ident(first.target_column_name)} = ${source}.${ident(first.source_column_name)}
@@ -383,15 +420,13 @@ export const generateAiPoliciesForTable = async ({
if (!connectionString) return []
try {
const aiPolicies = await generateSqlPolicy({
return await generateSqlPolicy({
tableName: table.name,
schema: table.schema,
columns: columns.map((col) => col.name.trim()),
projectRef,
connectionString: connectionString ?? '',
})
// AI response now includes all structured fields
return aiPolicies as GeneratedPolicy[]
} catch (error) {
console.log('AI policy generation failed:', error)
return []
@@ -1,4 +1,4 @@
import { PostgresPolicy } from '@supabase/postgres-meta'
import { acceptUntrustedSql, PGPolicy } from '@supabase/pg-meta'
import { isEmpty, noop } from 'lodash'
import { useCallback, useEffect, useState } from 'react'
import { toast } from 'sonner'
@@ -6,6 +6,8 @@ import { Modal } from 'ui'
import { POLICY_MODAL_VIEWS } from '../Policies.constants'
import {
DraftPostgresPolicyCreatePayload,
DraftPostgresPolicyUpdatePayload,
PolicyFormField,
PolicyForReview,
PostgresPolicyCreatePayload,
@@ -28,11 +30,29 @@ import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialo
import useLatest from '@/hooks/misc/useLatest'
import { useConfirmOnClose } from '@/hooks/ui/useConfirmOnClose'
// Call only from a user-gesture handler (the Save click). Promotes the draft payload's
// `definition`/`check` from `DisplayableSqlFragment` to executable `SafeSqlFragment`.
const acceptCreatePayload = (
draft: DraftPostgresPolicyCreatePayload
): PostgresPolicyCreatePayload => ({
...draft,
definition: draft.definition === undefined ? undefined : acceptUntrustedSql(draft.definition),
check: draft.check === undefined ? undefined : acceptUntrustedSql(draft.check),
})
const acceptUpdatePayload = (
draft: DraftPostgresPolicyUpdatePayload
): PostgresPolicyUpdatePayload => ({
...draft,
definition: draft.definition === undefined ? undefined : acceptUntrustedSql(draft.definition),
check: draft.check === undefined ? undefined : acceptUntrustedSql(draft.check),
})
interface PolicyEditorModalProps {
visible?: boolean
schema?: string
table?: string
selectedPolicyToEdit?: PostgresPolicy
selectedPolicyToEdit?: PGPolicy
showAssistantPreview?: boolean
onSelectCancel: () => void
onCreatePolicy: (payload: PostgresPolicyCreatePayload) => Promise<boolean>
@@ -163,10 +183,11 @@ export const PolicyEditorModal = ({
onReviewPolicy()
}
// The Save click is the explicit user gesture that promotes editor SQL to executable.
const onReviewSave = () => {
const payload = isNewPolicy
? createPayloadForCreatePolicy(policyFormFields)
: createPayloadForUpdatePolicy(policyFormFields, selectedPolicyToEdit)
? acceptCreatePayload(createPayloadForCreatePolicy(policyFormFields))
: acceptUpdatePayload(createPayloadForUpdatePolicy(policyFormFields, selectedPolicyToEdit))
onSavePolicy(payload)
setIsDirty(false)
}
@@ -1,9 +1,9 @@
import { PostgresPolicy } from '@supabase/postgres-meta'
import type { PGPolicy } from '@supabase/pg-meta'
import { Lock } from 'lucide-react'
interface LockedCreateQuerySection {
schema: string
selectedPolicy?: PostgresPolicy
selectedPolicy?: PGPolicy
isRenamingPolicy: boolean
formFields: { name: string; table: string; behavior: string; command: string; roles: string }
}
@@ -1,9 +1,9 @@
import type { PGPolicy } from '@supabase/pg-meta'
import { ident, keyword, safeSql, type SafeSqlFragment } from '@supabase/pg-meta/src/pg-format'
import type { PostgresPolicy } from '@supabase/postgres-meta'
import { isEqual } from 'lodash'
// [Joshen] Not used but keeping this for now in case we do an inline editor
export const generatePlaceholder = (policy?: PostgresPolicy) => {
export const generatePlaceholder = (policy?: PGPolicy) => {
if (policy === undefined) {
return `
-- Press tab to use this code\n
@@ -78,7 +78,7 @@ export const generateCreatePolicyQuery = ({
}
export const checkIfPolicyHasChanged = (
selectedPolicy: PostgresPolicy,
selectedPolicy: PGPolicy,
policyForm: {
name: string
roles: string[]
@@ -1,4 +1,4 @@
import type { PostgresPolicy } from '@supabase/postgres-meta'
import type { PGPolicy } from '@supabase/pg-meta'
import { ChevronDown, PanelLeftClose, PanelRightClose, X } from 'lucide-react'
import { useState } from 'react'
import {
@@ -19,7 +19,7 @@ export const PolicyEditorPanelHeader = ({
showTools,
setShowTools,
}: {
selectedPolicy?: PostgresPolicy
selectedPolicy?: PGPolicy
showTools: boolean
setShowTools: (v: boolean) => void
}) => {
@@ -1,4 +1,4 @@
import { PostgresPolicy } from '@supabase/postgres-meta'
import type { PGPolicy } from '@supabase/pg-meta'
import { Search } from 'lucide-react'
import { useState } from 'react'
import {
@@ -26,7 +26,7 @@ import { NoSearchResults } from '@/components/ui/NoSearchResults'
interface PolicyTemplatesProps {
schema: string
table: string
selectedPolicy?: PostgresPolicy
selectedPolicy?: PGPolicy
selectedTemplate?: string
onSelectTemplate: (template: any) => void
}
@@ -1,9 +1,9 @@
import { ident, joinSqlFragments, safeSql, type SafeSqlFragment } from '@supabase/pg-meta'
import { PostgresPolicy } from '@supabase/postgres-meta'
import type { PGPolicy } from '@supabase/pg-meta'
import type { TableApiAccessData } from '@/data/privileges/table-api-access-query'
export type Policy = Omit<PostgresPolicy, 'definition' | 'check'> & {
export type Policy = Omit<PGPolicy, 'definition' | 'check'> & {
definition: SafeSqlFragment | null
check: SafeSqlFragment | null
}
@@ -14,7 +14,7 @@ import {
SelectValue,
} from '@ui/components/shadcn/ui/select'
import { LOCAL_STORAGE_KEYS } from 'common'
import { Code } from 'lucide-react'
import { Code, ExternalLink } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import {
Button,
@@ -42,6 +42,7 @@ import type { Policy } from '@/components/interfaces/Auth/Policies/PolicyTableRo
import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
import { AiAssistantDropdown } from '@/components/ui/AiAssistantDropdown'
import { FeaturePreviewBadge } from '@/components/ui/FeaturePreviewBadge'
import { useTrack } from '@/lib/telemetry/track'
import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state'
import { useRoleImpersonationStateSnapshot } from '@/state/role-impersonation-state'
import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state'
@@ -51,6 +52,7 @@ interface RLSTesterSheetProps {
}
export const RLSTesterSheet = ({ handleSelectEditPolicy }: RLSTesterSheetProps) => {
const track = useTrack()
const aiSnap = useAiAssistantStateSnapshot()
const { openSidebar } = useSidebarManagerSnapshot()
const { setRole } = useRoleImpersonationStateSnapshot()
@@ -103,8 +105,10 @@ export const RLSTesterSheet = ({ handleSelectEditPolicy }: RLSTesterSheetProps)
if (format === 'lib') {
if (!inferredSQL) return
await testQuery({ value: acceptUntrustedSql(inferredSQL), ...executionCallbacks })
track('rls_tester_run_query_clicked', { type: 'inferred' })
} else {
await testQuery({ value, ...executionCallbacks })
track('rls_tester_run_query_clicked', { type: 'raw' })
}
}
@@ -272,11 +276,10 @@ export const RLSTesterSheet = ({ handleSelectEditPolicy }: RLSTesterSheetProps)
</div>
<SheetFooter className="sm:justify-between">
<Button asChild type="text">
<Button asChild type="default" icon={<ExternalLink />}>
<a
target="_blank"
rel="noopener noreferrer"
className="text-foreground-light hover:text-foreground"
href="https://github.com/orgs/supabase/discussions/45233"
>
Give feedback
@@ -125,7 +125,7 @@ const CreditCard = ({
</p>
</div>
{isSpt && spt && (
<div className="mt-2.5 flex items-center gap-2 border-t border-border-light pt-2.5 text-xs text-foreground-light">
<div className="mt-2.5 flex items-center gap-2 border-t pt-2.5 text-xs text-foreground-light">
<p className="m-0">
Managed via Stripe Projects · Token ending in{' '}
<code className="text-code-inline">{spt.last4}</code>
@@ -175,7 +175,7 @@ export const ConnectionPanel = ({
onCopyCallback={onCopyCallback}
/>
{notice && (
<div className="border px-4 py-1 w-full justify-start rounded-t-none !last:rounded-b group-data-open:rounded-b-none border-light">
<div className="border px-4 py-1 w-full justify-start rounded-t-none !last:rounded-b group-data-open:rounded-b-none">
{notice?.map((text: string) => (
<p key={text} className="text-xs text-foreground-lighter">
{text}
@@ -27,7 +27,7 @@ export const ConnectionParameters = ({ parameters }: ConnectionParametersProps)
<Collapsible_Shadcn_ open={isOpen} onOpenChange={setIsOpen} className="group -space-y-px">
<CollapsibleTrigger_Shadcn_
asChild
className="w-full justify-start rounded-t-none !last:rounded-b group-data-open:rounded-b-none data-[state=open]:border-light border-light px-3"
className="w-full justify-start rounded-t-none !last:rounded-b group-data-open:rounded-b-none px-3"
>
<Button
type="default"
@@ -490,7 +490,7 @@ export const DatabaseConnectionString = () => {
<Collapsible_Shadcn_ className="group">
<CollapsibleTrigger_Shadcn_
asChild
className="w-full justify-start !last:rounded-b group-data-open:rounded-b-none border-light px-3"
className="w-full justify-start !last:rounded-b group-data-open:rounded-b-none px-3"
>
<Button
type="default"
@@ -14,6 +14,7 @@ import {
TableRow,
} from 'ui'
import { getDatabaseTriggersHref } from './FunctionList.utils'
import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
import type { DatabaseFunction } from '@/data/database-functions/database-functions-query'
@@ -125,7 +126,7 @@ const FunctionList = ({
<TableCell className="table-cell">
{x.return_type === 'trigger' ? (
<Link
href={`/project/${projectRef}/database/triggers?search=${x.name}`}
href={getDatabaseTriggersHref(projectRef, x.name)}
className="truncate text-link"
title={x.return_type}
>
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'
import { getDatabaseTriggersHref } from './FunctionList.utils'
describe('FunctionList.utils: getDatabaseTriggersHref', () => {
it('builds the triggers href with a plain function name', () => {
expect(getDatabaseTriggersHref('abc', 'on_insert')).toBe(
'/project/abc/database/triggers?search=on_insert'
)
})
it('preserves special characters in the function name', () => {
const href = getDatabaseTriggersHref('abc', 'on_insert&schema=secret')
const parsed = new URL(href, 'http://example.com')
expect(parsed.searchParams.get('search')).toBe('on_insert&schema=secret')
expect(parsed.searchParams.get('schema')).toBeNull()
})
it('preserves spaces and plus signs in the function name', () => {
const href = getDatabaseTriggersHref('abc', 'a name+x')
const parsed = new URL(href, 'http://example.com')
expect(parsed.searchParams.get('search')).toBe('a name+x')
})
it('falls back to empty strings for undefined inputs', () => {
expect(getDatabaseTriggersHref(undefined, undefined)).toBe(
'/project//database/triggers?search='
)
})
})
@@ -0,0 +1,6 @@
export const getDatabaseTriggersHref = (
projectRef: string | null | undefined,
name: string | null | undefined
): string => {
return `/project/${projectRef ?? ''}/database/triggers?search=${encodeURIComponent(name ?? '')}`
}
@@ -1,7 +1,6 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { keyword } from '@supabase/pg-meta'
import { PGTriggerCreate } from '@supabase/pg-meta/src/pg-meta-triggers'
import type { PostgresTrigger } from '@supabase/postgres-meta'
import type { PGTrigger, PGTriggerCreate } from '@supabase/pg-meta'
import { useQueryClient } from '@tanstack/react-query'
import { useParams } from 'common'
import { parseAsBoolean, parseAsString, useQueryState } from 'nuqs'
@@ -37,7 +36,7 @@ export const isEdgeFunction = ({
const FORM_ID = 'edit-hook-panel-form'
const parseHeaders = (selectedHook?: PostgresTrigger): HTTPArgument[] => {
const parseHeaders = (selectedHook?: PGTrigger): HTTPArgument[] => {
if (typeof selectedHook === 'undefined') {
return [{ id: uuidv4(), name: 'Content-type', value: 'application/json' }]
}
@@ -57,7 +56,7 @@ const parseHeaders = (selectedHook?: PostgresTrigger): HTTPArgument[] => {
}))
}
const parseParameters = (selectedHook?: PostgresTrigger): HTTPArgument[] => {
const parseParameters = (selectedHook?: PGTrigger): HTTPArgument[] => {
if (typeof selectedHook === 'undefined') {
return [{ id: uuidv4(), name: '', value: '' }]
}
@@ -1,4 +1,4 @@
import type { PostgresTrigger } from '@supabase/postgres-meta'
import type { PGTrigger } from '@supabase/pg-meta'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useParams } from 'common'
import Image from 'next/legacy/image'
@@ -42,7 +42,7 @@ import { uuidv4 } from '@/lib/helpers'
export interface FormContentsProps {
form: UseFormReturn<WebhookFormValues>
selectedHook?: PostgresTrigger
selectedHook?: PGTrigger
}
export const FormContents = ({ form, selectedHook }: FormContentsProps) => {
@@ -1,4 +1,4 @@
import type { PostgresPublication, PostgresTable } from '@supabase/postgres-meta'
import type { PGPublication, PGTable } from '@supabase/pg-meta'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useState } from 'react'
import { toast } from 'sonner'
@@ -10,8 +10,8 @@ import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { useProtectedSchemas } from '@/hooks/useProtectedSchemas'
interface PublicationsTableItemProps {
table: PostgresTable
selectedPublication: PostgresPublication
table: PGTable
selectedPublication: PGPublication
}
export const PublicationsTableItem = ({
@@ -35,10 +35,7 @@ export const PublicationsTableItem = ({
const { mutate: updatePublications, isPending } = useDatabasePublicationUpdateMutation()
const toggleReplicationForTable = async (
table: PostgresTable,
publication: PostgresPublication
) => {
const toggleReplicationForTable = async (table: PGTable, publication: PGPublication) => {
if (project === undefined) return console.error('Project is required')
const originalChecked = checked
@@ -33,12 +33,18 @@ interface NewPublicationPanelProps {
export const NewPublicationPanel = ({ visible, sourceId, onClose }: NewPublicationPanelProps) => {
const { ref: projectRef } = useParams()
const { data: project } = useSelectedProjectQuery()
const { mutateAsync: createPublication, isPending: creatingPublication } =
useCreatePublicationMutation()
const { data: tables } = useReplicationTablesQuery({
projectRef,
sourceId,
})
const { data: tables } = useReplicationTablesQuery({ projectRef, sourceId })
const { mutate: createPublication, isPending: creatingPublication } =
useCreatePublicationMutation({
onSuccess: () => {
toast.success('Successfully created publication')
form.reset(defaultValues)
onClose()
},
})
const formId = 'publication-editor'
const FormSchema = z.object({
name: z.string().min(1, 'Name is required'),
@@ -59,23 +65,19 @@ export const NewPublicationPanel = ({ visible, sourceId, onClose }: NewPublicati
if (!projectRef) return console.error('Project ref is required')
if (!project) return console.error('Project is required')
if (!sourceId) return console.error('Source id is required')
try {
await createPublication({
projectRef,
sourceId,
name: data.name,
tables: data.tables.map((table) => {
const [schema, name] = table.split('.')
return { schema, name }
}),
connectionString: project.connectionString,
})
toast.success('Successfully created publication')
onClose()
} catch (error) {
toast.error('Failed to create publication')
}
form.reset(defaultValues)
const tables = data.tables.map((table) => {
const [schema, name] = table.split('.')
return { schema, name }
})
createPublication({
projectRef,
sourceId,
name: data.name,
tables,
connectionString: project.connectionString,
})
}
return (
@@ -56,6 +56,7 @@ import {
PipelineStatusRequestStatus,
usePipelineRequestStatus,
} from '@/state/replication-pipeline-request-status'
import { type ResponseError } from '@/types'
const formId = 'destination-editor'
@@ -368,8 +369,11 @@ export const DestinationForm = ({
const hasRequestError = results.some((r) => r.status === 'rejected')
if (hasRequestError) {
// If any request failed, show a generic error and stop
toast.error('Failed to validate configuration. Please try again.')
// If any request failed, surface the upstream message so users see why
const rejected = results.find((r): r is PromiseRejectedResult => r.status === 'rejected')
const reason =
rejected?.reason instanceof Error ? rejected.reason.message : 'Please try again.'
toast.error(`Failed to validate configuration: ${reason}`)
setHasRunValidation(false)
return false
}
@@ -481,7 +485,7 @@ export const DestinationForm = ({
}
} catch (error) {
const action = editMode ? 'apply and run' : 'create and start'
toast.error(`Failed to ${action} destination`)
toast.error(`Failed to ${action} destination: ${(error as ResponseError).message}`)
}
}
@@ -16,7 +16,6 @@ import {
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
import { DeleteDestination } from './DeleteDestination'
import { PIPELINE_ERROR_MESSAGES } from './Pipeline.utils'
import { PipelineStatus } from './PipelineStatus'
import { PipelineStatusName, STATUS_REFRESH_FREQUENCY_MS } from './Replication.constants'
import { RowMenu } from './RowMenu'
@@ -32,6 +31,7 @@ import {
PipelineStatusRequestStatus,
usePipelineRequestStatus,
} from '@/state/replication-pipeline-request-status'
import { type ResponseError } from '@/types'
interface DestinationRowProps {
destinationId: number
@@ -99,7 +99,7 @@ export const DestinationRow = ({ destinationId }: DestinationRowProps) => {
return console.error('Project ref is required')
}
if (!pipeline) {
return toast.error(PIPELINE_ERROR_MESSAGES.NO_PIPELINE_FOUND)
return toast.error('No pipeline found')
}
try {
@@ -114,7 +114,7 @@ export const DestinationRow = ({ destinationId }: DestinationRowProps) => {
setShowDeleteDestinationForm(false)
toast.success(`Deleted destination "${destinationName}"`)
} catch (error) {
toast.error(PIPELINE_ERROR_MESSAGES.DELETE_DESTINATION)
toast.error(`Failed to delete destination: ${(error as ResponseError).message}`)
} finally {
setIsDeleting(false)
}
@@ -129,7 +129,7 @@ export const DestinationRow = ({ destinationId }: DestinationRowProps) => {
return (
<>
{isPipelineError && (
<AlertError error={pipelineError} subject={PIPELINE_ERROR_MESSAGES.RETRIEVE_PIPELINE} />
<AlertError error={pipelineError} subject="Failed to retrieve pipeline information" />
)}
{isPipelineSuccess && (
<TableRow>
@@ -27,7 +27,6 @@ import { DestinationPanel } from './DestinationPanel/DestinationPanel'
import { DestinationType } from './DestinationPanel/DestinationPanel.types'
import { DestinationRow } from './DestinationRow'
import { DisableExternalReplicationDialog } from './DisableExternalReplicationDialog'
import { PIPELINE_ERROR_MESSAGES } from './Pipeline.utils'
import { ReadReplicaRow } from './ReadReplicas/ReadReplicaRow'
import {
useIsETLBigQueryPrivateAlpha,
@@ -272,7 +271,7 @@ export const Destinations = () => {
{hasErrorsFetchingData && (
<AlertError
error={destinationsError || databasesError}
subject={PIPELINE_ERROR_MESSAGES.RETRIEVE_DESTINATIONS}
subject="Failed to retrieve destinations"
/>
)}
@@ -2,18 +2,6 @@ import { PipelineStatusName } from './Replication.constants'
import { ReplicationPipelineStatusData } from '@/data/replication/pipeline-status-query'
import { PipelineStatusRequestStatus } from '@/state/replication-pipeline-request-status'
export const PIPELINE_ERROR_MESSAGES = {
RETRIEVE_PIPELINE: 'Failed to retrieve pipeline information',
RETRIEVE_PIPELINE_STATUS: 'Failed to retrieve pipeline status',
RETRIEVE_REPLICATION_STATUS: 'Failed to retrieve table replication status',
RETRIEVE_DESTINATIONS: 'Failed to retrieve destinations',
ENABLE_DESTINATION: 'Failed to enable destination',
DISABLE_DESTINATION: 'Failed to disable destination',
DELETE_DESTINATION: 'Failed to delete destination',
NO_PIPELINE_FOUND: 'No pipeline found',
COPY_TABLE_STATUS: 'Failed to copy table status',
} as const
export const getStatusName = (
status: ReplicationPipelineStatusData['status'] | undefined
): PipelineStatusName | undefined => {
@@ -39,7 +39,6 @@ import {
getPipelineDisplayState,
getStatusName,
PIPELINE_ACTIONABLE_STATES,
PIPELINE_ERROR_MESSAGES,
} from '../Pipeline.utils'
import { PipelineStatus } from '../PipelineStatus'
import { PipelineStatusName, STATUS_REFRESH_FREQUENCY_MS } from '../Replication.constants'
@@ -62,6 +61,7 @@ import {
PipelineStatusRequestStatus,
usePipelineRequestStatus,
} from '@/state/replication-pipeline-request-status'
import { type ResponseError } from '@/types'
/**
* Component for displaying replication pipeline status and table replication details.
@@ -232,8 +232,14 @@ export const ReplicationPipelineStatus = () => {
const onPrimaryAction = async () => {
if (!projectRef) return console.error('Project ref is required')
if (!pipeline) return toast.error(PIPELINE_ERROR_MESSAGES.NO_PIPELINE_FOUND)
if (!pipeline) return toast.error('No pipeline found')
const action =
statusName === PipelineStatusName.STOPPED
? 'start'
: statusName === PipelineStatusName.STARTED
? 'stop'
: 'restart'
try {
if (statusName === PipelineStatusName.STOPPED) {
setRequestStatus(pipeline.id, PipelineStatusRequestStatus.StartRequested, statusName)
@@ -247,7 +253,7 @@ export const ReplicationPipelineStatus = () => {
}
} catch (error) {
setRequestStatus(pipeline.id, PipelineStatusRequestStatus.None)
toast.error(PIPELINE_ERROR_MESSAGES.ENABLE_DESTINATION)
toast.error(`Failed to ${action} pipeline: ${(error as ResponseError).message}`)
}
}
@@ -315,7 +321,7 @@ export const ReplicationPipelineStatus = () => {
</div>
</div>
{isPipelineError && (
<AlertError error={pipelineError} subject={PIPELINE_ERROR_MESSAGES.RETRIEVE_PIPELINE} />
<AlertError error={pipelineError} subject="Failed to retrieve pipeline information" />
)}
{isStatusError && (
@@ -16,7 +16,6 @@ import {
getStatusName,
PIPELINE_DISABLE_ALLOWED_FROM,
PIPELINE_ENABLE_ALLOWED_FROM,
PIPELINE_ERROR_MESSAGES,
} from './Pipeline.utils'
import { PipelineStatusName } from './Replication.constants'
import AlertError from '@/components/ui/AlertError'
@@ -88,46 +87,46 @@ export const RowMenu = ({
const onEnablePipeline = async () => {
if (!projectRef) return console.error('Project ref is required')
if (!pipeline) return toast.error(PIPELINE_ERROR_MESSAGES.NO_PIPELINE_FOUND)
if (!pipeline) return toast.error('No pipeline found')
try {
// Only show 'enabling' when transitioning from allowed states
if (PIPELINE_ENABLE_ALLOWED_FROM.includes(statusName as any)) {
if (PIPELINE_ENABLE_ALLOWED_FROM.includes(statusName as PipelineStatusName)) {
setGlobalRequestStatus(pipeline.id, PipelineStatusRequestStatus.StartRequested, statusName)
}
await startPipeline({ projectRef, pipelineId: pipeline.id })
} catch (error) {
setGlobalRequestStatus(pipeline.id, PipelineStatusRequestStatus.None)
toast.error(PIPELINE_ERROR_MESSAGES.ENABLE_DESTINATION)
toast.error(`Failed to start pipeline: ${(error as ResponseError).message}`)
}
}
const onDisablePipeline = async () => {
if (!projectRef) return console.error('Project ref is required')
if (!pipeline) return toast.error(PIPELINE_ERROR_MESSAGES.NO_PIPELINE_FOUND)
if (!pipeline) return toast.error('No pipeline found')
try {
// Only show 'disabling' when transitioning from allowed states
if (PIPELINE_DISABLE_ALLOWED_FROM.includes(statusName as any)) {
if (PIPELINE_DISABLE_ALLOWED_FROM.includes(statusName as PipelineStatusName)) {
setGlobalRequestStatus(pipeline.id, PipelineStatusRequestStatus.StopRequested, statusName)
}
await stopPipeline({ projectRef, pipelineId: pipeline.id })
} catch (error) {
setGlobalRequestStatus(pipeline.id, PipelineStatusRequestStatus.None)
toast.error(PIPELINE_ERROR_MESSAGES.DISABLE_DESTINATION)
toast.error(`Failed to stop pipeline: ${(error as ResponseError).message}`)
}
}
const onRestartPipeline = async () => {
if (!projectRef) return console.error('Project ref is required')
if (!pipeline) return toast.error(PIPELINE_ERROR_MESSAGES.NO_PIPELINE_FOUND)
if (!pipeline) return toast.error('No pipeline found')
try {
setGlobalRequestStatus(pipeline.id, PipelineStatusRequestStatus.RestartRequested, statusName)
await restartPipeline({ projectRef, pipelineId: pipeline.id })
} catch (error) {
setGlobalRequestStatus(pipeline.id, PipelineStatusRequestStatus.None)
toast.error(PIPELINE_ERROR_MESSAGES.ENABLE_DESTINATION)
toast.error(`Failed to restart pipeline: ${(error as ResponseError).message}`)
}
}
@@ -135,9 +134,7 @@ export const RowMenu = ({
<div className="flex justify-end items-center space-x-2">
{isLoading && <ShimmeringLoader />}
{isError && (
<AlertError error={error} subject={PIPELINE_ERROR_MESSAGES.RETRIEVE_PIPELINE_STATUS} />
)}
{isError && <AlertError error={error} subject="Failed to retrieve pipeline status" />}
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -20,6 +20,7 @@ import {
PipelineStatusRequestStatus,
usePipelineRequestStatus,
} from '@/state/replication-pipeline-request-status'
import { type ResponseError } from '@/types'
interface UpdateVersionModalProps {
visible: boolean
@@ -65,9 +66,9 @@ export const UpdateVersionModal = ({
// Step 1: Update to the new version
try {
await updatePipelineVersion({ projectRef, pipelineId: pipeline.id, versionId })
} catch (e: any) {
} catch (e) {
// 404: default changed; version cache will refresh via mutation onError. Keep dialog open.
if (e?.code === 404) return
if ((e as ResponseError)?.code === 404) return
// Other errors are already toasted by the mutation; do not double-toast here.
return
}
@@ -80,10 +81,10 @@ export const UpdateVersionModal = ({
try {
await restartPipeline({ projectRef, pipelineId: pipeline.id })
toast.success('Pipeline successfully updated and is currently restarting')
} catch (e: any) {
} catch (e) {
// Clear optimistic state and surface a single concise error
setRequestStatus(pipeline.id, PipelineStatusRequestStatus.None)
toast.error('Failed to restart pipeline')
toast.error(`Failed to restart pipeline: ${(e as ResponseError).message}`)
}
} else {
toast.success('Pipeline successfully updated')
@@ -6,7 +6,9 @@ import { toast } from 'sonner'
import {
Button,
cn,
Collapsible,
Collapsible_Shadcn_,
CollapsibleContent_Shadcn_,
CollapsibleTrigger_Shadcn_,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
@@ -45,7 +47,6 @@ export const RoleRow = ({ role, disabled = false, onSelectDelete }: RoleRowProps
const { data: project } = useSelectedProjectQuery()
const [isExpanded, setIsExpanded] = useState(false)
const { mutate: updateDatabaseRole, isPending: isUpdating } = useDatabaseRoleUpdateMutation()
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: role,
@@ -93,7 +94,7 @@ export const RoleRow = ({ role, disabled = false, onSelectDelete }: RoleRowProps
const formId = `role-update-form-${role.id}`
return (
<Collapsible
<Collapsible_Shadcn_
open={isExpanded}
className={cn(
'bg-surface-100',
@@ -109,7 +110,7 @@ export const RoleRow = ({ role, disabled = false, onSelectDelete }: RoleRowProps
)}
>
<div className={cn('flex items-center relative', !disabled && 'pr-(--card-padding-x)')}>
<Collapsible.Trigger asChild>
<CollapsibleTrigger_Shadcn_ asChild>
<button
id={`collapsible-trigger-${role.id}`}
type="button"
@@ -150,7 +151,7 @@ export const RoleRow = ({ role, disabled = false, onSelectDelete }: RoleRowProps
</p>
</div>
</button>
</Collapsible.Trigger>
</CollapsibleTrigger_Shadcn_>
{!disabled && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -176,7 +177,7 @@ export const RoleRow = ({ role, disabled = false, onSelectDelete }: RoleRowProps
</DropdownMenu>
)}
</div>
<Collapsible.Content>
<CollapsibleContent_Shadcn_>
<Form {...form}>
<form
id={formId}
@@ -228,7 +229,7 @@ export const RoleRow = ({ role, disabled = false, onSelectDelete }: RoleRowProps
)}
</form>
</Form>
</Collapsible.Content>
</Collapsible>
</CollapsibleContent_Shadcn_>
</Collapsible_Shadcn_>
)
}
@@ -1,4 +1,4 @@
import type { PostgresSchema, PostgresTable } from '@supabase/postgres-meta'
import type { PGSchema } from '@supabase/pg-meta'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import {
Background,
@@ -64,6 +64,7 @@ import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { useIsProtectedSchema } from '@/hooks/useProtectedSchemas'
import { useStaticEffectEvent } from '@/hooks/useStaticEffectEvent'
import { tablesToSQL } from '@/lib/helpers'
import type { SafePostgresTable } from '@/lib/postgres-types'
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
import { useShortcut } from '@/state/shortcuts/useShortcut'
import { useTableEditorStateSnapshot } from '@/state/table-editor'
@@ -75,7 +76,7 @@ export const SchemaGraph = () => {
const { resolvedTheme } = useTheme()
const { data: project } = useSelectedProjectQuery()
const { selectedSchema, setSelectedSchema } = useQuerySchemaState()
const [selectedTable, setSelectedTable] = useState<PostgresTable | null>(null)
const [selectedTable, setSelectedTable] = useState<SafePostgresTable | null>(null)
const snap = useTableEditorStateSnapshot()
const { isDownloading, exportSchemaToImage } = useExportSchemaToImage()
@@ -241,7 +242,7 @@ export const SchemaGraph = () => {
const isFirstLoad = useRef(true)
useEffect(() => {
if (isSuccessTables && isSuccessSchemas && tables.length > 0) {
const schema = schemas.find((s) => s.name === selectedSchema) as PostgresSchema
const schema = schemas.find((s) => s.name === selectedSchema) as PGSchema
getGraphDataFromTables(ref as string, schema, tables).then(({ nodes, edges }) => {
reactFlowInstance.setNodes(nodes)
reactFlowInstance.setEdges(edges)
@@ -1,5 +1,5 @@
import dagre from '@dagrejs/dagre'
import type { PostgresSchema, PostgresTable } from '@supabase/postgres-meta'
import type { PGSchema, PGTable } from '@supabase/pg-meta'
import { Edge, Node, Position } from '@xyflow/react'
import { uniqBy } from 'lodash'
@@ -16,8 +16,8 @@ const RANK_SEP = 50
export async function getGraphDataFromTables(
ref?: string,
schema?: PostgresSchema,
tables?: PostgresTable[]
schema?: PGSchema,
tables?: PGTable[]
): Promise<{
nodes: Node<TableNodeData>[]
edges: Edge[]
@@ -166,7 +166,7 @@ export async function getGraphDataFromTables(
}
function findTablesHandleIds(
tables: PostgresTable[],
tables: PGTable[],
table_name: string,
column_name: string
): [string?, string?] {
@@ -1,4 +1,3 @@
import { PostgresColumn } from '@supabase/postgres-meta'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useParams } from 'common'
import { noop } from 'lodash'
@@ -48,6 +47,7 @@ import {
getUniqueIndexColumnNames,
} from './ColumnList.utils'
import { ConstraintToken } from './ConstraintToken'
import { displayColumnType } from '@/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.utils'
import AlertError from '@/components/ui/AlertError'
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
import { DropdownMenuItemTooltip } from '@/components/ui/DropdownMenuItemTooltip'
@@ -57,8 +57,9 @@ import { isTableLike } from '@/data/table-editor/table-editor-types'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { useIsProtectedSchema } from '@/hooks/useProtectedSchemas'
import type { SafePostgresColumn } from '@/lib/postgres-types'
const getColumnTypeAffordancePresentation = (column: PostgresColumn) => {
const getColumnTypeAffordancePresentation = (column: SafePostgresColumn) => {
const { kind, label } = getColumnTypeAffordance(column.format)
const iconClassName = 'text-foreground-muted'
@@ -98,8 +99,8 @@ const getColumnTypeAffordancePresentation = (column: PostgresColumn) => {
interface ColumnListProps {
onAddColumn: () => void
onEditColumn: (column: PostgresColumn) => void
onDeleteColumn: (column: PostgresColumn) => void
onEditColumn: (column: SafePostgresColumn) => void
onDeleteColumn: (column: SafePostgresColumn) => void
}
export const ColumnList = ({
@@ -322,7 +323,11 @@ export const ColumnList = ({
<span>{column.data_type}</span>
{column.format !== column.data_type && (
<span className="text-xs text-foreground-light">
{column.format}
{displayColumnType(
column.format,
column.format_schema,
column.data_type === 'ARRAY'
)}
</span>
)}
</div>
@@ -343,7 +348,13 @@ export const ColumnList = ({
</div>
</TableCell>
<TableCell>
<p className="text-foreground-lighter">{column.format}</p>
<p className="text-foreground-lighter">
{displayColumnType(
column.format,
column.format_schema,
column.data_type === 'ARRAY'
)}
</p>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1.5">{constraintTokens}</div>
@@ -1,4 +1,3 @@
import type { PostgresTable } from '@supabase/postgres-meta'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useParams } from 'common'
import { noop } from 'lodash'
@@ -55,14 +54,15 @@ import { useQuerySchemaState } from '@/hooks/misc/useSchemaQueryState'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { useIsProtectedSchema } from '@/hooks/useProtectedSchemas'
import { onSearchInputEscape } from '@/lib/keyboard'
import type { SafePostgresTable } from '@/lib/postgres-types'
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
import { useShortcut } from '@/state/shortcuts/useShortcut'
interface TableListProps {
onAddTable: () => void
onEditTable: (table: PostgresTable) => void
onDeleteTable: (table: PostgresTable) => void
onDuplicateTable: (table: PostgresTable) => void
onEditTable: (table: SafePostgresTable) => void
onDeleteTable: (table: SafePostgresTable) => void
onDuplicateTable: (table: SafePostgresTable) => void
}
export const TableList = ({
@@ -1,7 +1,7 @@
import { PostgresMaterializedView, PostgresTable, PostgresView } from '@supabase/postgres-meta'
import { PostgresForeignTable } from '@supabase/postgres-meta/dist/lib/types'
import { PGForeignTable, PGMaterializedView, PGView } from '@supabase/pg-meta'
import { ENTITY_TYPE } from '@/data/entity-types/entity-type-constants'
import type { SafePostgresTable } from '@/lib/postgres-types'
// [Joshen] We just need name, schema, description, rows, size, and the number of columns
// Just missing partitioned tables as missing pg-meta support
@@ -11,10 +11,10 @@ export const formatAllEntities = ({
materializedViews = [],
foreignTables = [],
}: {
tables?: PostgresTable[]
views?: PostgresView[]
materializedViews?: PostgresMaterializedView[]
foreignTables?: PostgresForeignTable[]
tables?: SafePostgresTable[]
views?: PGView[]
materializedViews?: PGMaterializedView[]
foreignTables?: PGForeignTable[]
}) => {
const formattedTables = tables.map((x) => {
return {
@@ -1,16 +1,16 @@
import type { PostgresTrigger } from '@supabase/postgres-meta'
import type { PGTrigger } from '@supabase/pg-meta'
import { TextConfirmModal } from '@/components/ui/TextConfirmModalWrapper'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
interface DeleteTriggerProps {
trigger?: PostgresTrigger
trigger?: PGTrigger
visible: boolean
setVisible: (value: string | null) => void
onDelete: (params: {
projectRef: string
connectionString?: string | null
trigger: PostgresTrigger
trigger: PGTrigger
}) => void
isLoading: boolean
}
@@ -17,6 +17,7 @@ import {
import type { EventTrigger } from './EventTriggerList.utils'
import { SUPABASE_ROLES } from '@/components/interfaces/Database/Roles/Roles.constants'
import { getDatabaseFunctionsHref } from '@/components/interfaces/Database/Triggers/TriggersList/TriggerList.utils'
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
interface EventTriggerListProps {
@@ -126,7 +127,11 @@ export const EventTriggerList = ({
<TableCell className="space-x-2">
{trigger.function_name ? (
<Link
href={`/project/${projectRef}/database/functions?search=${trigger.function_name}&schema=${trigger.function_schema}`}
href={getDatabaseFunctionsHref(
projectRef,
trigger.function_schema,
trigger.function_name
)}
className="text-link-table-cell block max-w-40 text-foreground-light"
>
{trigger.function_name}
@@ -3,7 +3,18 @@ import { DatabaseZap, Plus, Search } from 'lucide-react'
import { parseAsJson, parseAsString, useQueryState } from 'nuqs'
import { useMemo, useRef, useState } from 'react'
import { toast } from 'sonner'
import { Button, Card, Input, Table, TableBody, TableHead, TableHeader, TableRow } from 'ui'
import {
Button,
Card,
InputGroup,
InputGroupAddon,
InputGroupInput,
Table,
TableBody,
TableHead,
TableHeader,
TableRow,
} from 'ui'
import { EmptyStatePresentational } from 'ui-patterns'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
@@ -181,16 +192,19 @@ export const EventTriggersList = () => {
<div className="space-y-4">
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-2 flex-wrap">
<div className="flex flex-col lg:flex-row lg:items-center gap-2 flex-wrap">
<Input
inputRef={searchInputRef}
placeholder="Search for an event trigger"
size="tiny"
icon={<Search />}
value={filterString}
className="w-full lg:w-64"
onChange={(e) => setFilterString(e.target.value)}
onKeyDown={onSearchInputEscape(filterString, setFilterString)}
/>
<InputGroup className="w-full lg:w-52">
<InputGroupInput
ref={searchInputRef}
size="tiny"
placeholder="Search for a trigger"
value={filterString}
onChange={(e) => setFilterString(e.target.value)}
onKeyDown={onSearchInputEscape(filterString, setFilterString)}
/>
<InputGroupAddon>
<Search />
</InputGroupAddon>
</InputGroup>
<ReportsSelectFilter
label="Owner"
options={ownerOptions.map((owner) => ({
@@ -1,5 +1,5 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { PostgresTrigger } from '@supabase/postgres-meta'
import type { PGTrigger } from '@supabase/pg-meta'
import { Terminal } from 'lucide-react'
import { useEffect, useState } from 'react'
import { SubmitHandler, useForm } from 'react-hook-form'
@@ -76,7 +76,7 @@ const defaultValues: z.infer<typeof FormSchema> = {
}
interface TriggerSheetProps {
selectedTrigger?: PostgresTrigger
selectedTrigger?: PGTrigger
isDuplicatingTrigger?: boolean
open: boolean
onClose: () => void
@@ -16,7 +16,11 @@ import {
TableRow,
} from 'ui'
import { generateTriggerCreateSQL, type PostgresTrigger } from './TriggerList.utils'
import {
generateTriggerCreateSQL,
getDatabaseFunctionsHref,
type PostgresTrigger,
} from './TriggerList.utils'
import { selectFilterSchema } from '@/components/interfaces/Reports/v2/ReportsSelectFilter'
import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
@@ -131,7 +135,7 @@ export const TriggerList = ({ editTrigger, duplicateTrigger, deleteTrigger }: Tr
<TableCell className="space-x-2">
{x.function_name ? (
<Link
href={`/project/${projectRef}/database/functions?search=${x.function_name}&schema=${x.function_schema}`}
href={getDatabaseFunctionsHref(projectRef, x.function_schema, x.function_name)}
className="text-link-table-cell block max-w-40 text-foreground-light"
>
{x.function_name}
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest'
import { getDatabaseFunctionsHref } from './TriggerList.utils'
describe('TriggerList.utils: getDatabaseFunctionsHref', () => {
it('builds the functions href with plain values', () => {
expect(getDatabaseFunctionsHref('abc', 'public', 'do_thing')).toBe(
'/project/abc/database/functions?search=do_thing&schema=public'
)
})
it('preserves special characters in the function name', () => {
const href = getDatabaseFunctionsHref('abc', 'public', 'do_thing&secret=1')
const parsed = new URL(href, 'http://example.com')
expect(parsed.searchParams.get('search')).toBe('do_thing&secret=1')
expect(parsed.searchParams.get('schema')).toBe('public')
})
it('preserves special characters in the schema', () => {
const href = getDatabaseFunctionsHref('abc', 'my schema+x', 'do_thing')
const parsed = new URL(href, 'http://example.com')
expect(parsed.searchParams.get('schema')).toBe('my schema+x')
expect(parsed.searchParams.get('search')).toBe('do_thing')
})
it('encodes both name and schema together', () => {
const href = getDatabaseFunctionsHref('abc', 'a&b=c', 'd e+f')
const parsed = new URL(href, 'http://example.com')
expect(parsed.searchParams.get('schema')).toBe('a&b=c')
expect(parsed.searchParams.get('search')).toBe('d e+f')
})
it('falls back to empty strings for undefined inputs', () => {
expect(getDatabaseFunctionsHref(undefined, undefined, undefined)).toBe(
'/project//database/functions?search=&schema='
)
})
})
@@ -37,3 +37,13 @@ FOR EACH ${keyword(trigger.orientation)}
return sql
}
export const getDatabaseFunctionsHref = (
projectRef: string | null | undefined,
schema: string | null | undefined,
name: string | null | undefined
): string => {
return `/project/${projectRef ?? ''}/database/functions?search=${encodeURIComponent(
name ?? ''
)}&schema=${encodeURIComponent(schema ?? '')}`
}
Loaded 100 of 426 files, more files were not shown because too many files have changed in this diff. Show more