diff --git a/apps/studio/components/interfaces/Integrations/Wrappers/WrapperTableEditor.tsx b/apps/studio/components/interfaces/Integrations/Wrappers/WrapperTableEditor.tsx
index 56849026784a4..95ac87a2d1898 100644
--- a/apps/studio/components/interfaces/Integrations/Wrappers/WrapperTableEditor.tsx
+++ b/apps/studio/components/interfaces/Integrations/Wrappers/WrapperTableEditor.tsx
@@ -1,9 +1,8 @@
-import { Check, ChevronsUpDown, Database, Plus } from 'lucide-react'
-import { useEffect, useState } from 'react'
-
import { ActionBar } from 'components/interfaces/TableGridEditor/SidePanelEditor/ActionBar'
import { useSchemasQuery } from 'data/database/schemas-query'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
+import { Check, ChevronsUpDown, Database, Plus } from 'lucide-react'
+import { useEffect, useId, useState } from 'react'
import {
Button,
cn,
@@ -25,6 +24,7 @@ import {
SidePanel,
} from 'ui'
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
+
import WrapperDynamicColumns from './WrapperDynamicColumns'
import type { Table, TableOption } from './Wrappers.types'
import { makeValidateRequired } from './Wrappers.utils'
@@ -48,6 +48,7 @@ const WrapperTableEditor = ({
initialData,
}: WrapperTableEditorProps) => {
const [open, setOpen] = useState(false)
+ const listboxId = useId()
const [selectedTableIndex, setSelectedTableIndex] = useState
('')
useEffect(() => {
@@ -101,6 +102,8 @@ const WrapperTableEditor = ({
-
+
@@ -246,6 +255,8 @@ export const BillingCustomerDataForm = ({
role="combobox"
size="medium"
disabled={disabled}
+ aria-expanded={showTaxIDsPopover}
+ aria-controls={taxIdListboxId}
className={cn(
'w-full justify-between h-[34px] pr-2',
!selectedTaxId && 'text-muted'
@@ -260,7 +271,12 @@ export const BillingCustomerDataForm = ({
-
+
diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/MemberRow.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/MemberRow.tsx
index 6592bf3696875..5dea2402437de 100644
--- a/apps/studio/components/interfaces/Organization/TeamSettings/MemberRow.tsx
+++ b/apps/studio/components/interfaces/Organization/TeamSettings/MemberRow.tsx
@@ -9,7 +9,6 @@ import { useOrganizationRolesV2Query } from 'data/organization-members/organizat
import { OrganizationMember } from 'data/organizations/organization-members-query'
import { useOrgProjectsInfiniteQuery } from 'data/projects/org-projects-infinite-query'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
-import { getGitHubProfileImgUrl } from 'lib/github'
import { useProfile } from 'lib/profile'
import {
Badge,
@@ -51,8 +50,8 @@ export const MemberRow = ({ member }: MemberRowProps) => {
const isEmailUser = member.username === member.primary_email
const isFlyUser = Boolean(member.primary_email?.endsWith('customer.fly.io'))
- const profileImageUrl =
- isInvitedUser || isEmailUser || isFlyUser ? undefined : getGitHubProfileImgUrl(member.username)
+ // Use generic avatar for all team members instead of attempting to fetch from GitHub
+ const profileImageUrl = undefined
return (
diff --git a/apps/studio/components/interfaces/Organization/Usage/Usage.tsx b/apps/studio/components/interfaces/Organization/Usage/Usage.tsx
index c6e1c0867dd35..6e8baf4aa658f 100644
--- a/apps/studio/components/interfaces/Organization/Usage/Usage.tsx
+++ b/apps/studio/components/interfaces/Organization/Usage/Usage.tsx
@@ -1,8 +1,4 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
-import dayjs from 'dayjs'
-import Link from 'next/link'
-import { useMemo, useState } from 'react'
-
import { useParams } from 'common'
import {
ScaffoldContainer,
@@ -17,13 +13,17 @@ import { OrganizationProjectSelector } from 'components/ui/OrganizationProjectSe
import { useOrgDailyStatsQuery } from 'data/analytics/org-daily-stats-query'
import { useProjectDetailQuery } from 'data/projects/project-detail-query'
import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query'
+import dayjs from 'dayjs'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { TIME_PERIODS_BILLING, TIME_PERIODS_REPORTS } from 'lib/constants/metrics'
import { Check, ChevronDown } from 'lucide-react'
+import Link from 'next/link'
import { useQueryState } from 'nuqs'
+import { useMemo, useState } from 'react'
import { Button, cn, CommandGroup_Shadcn_, CommandItem_Shadcn_ } from 'ui'
import { Admonition } from 'ui-patterns'
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
+
import { Restriction } from '../BillingSettings/Restriction'
import ActiveCompute from './ActiveCompute'
import Activity from './Activity'
@@ -163,13 +163,15 @@ export const Usage = () => {
onSelect={(project) => {
setSelectedProjectRef(project.ref)
}}
- renderTrigger={() => {
+ renderTrigger={({ listboxId, open }) => {
return (
}
>
diff --git a/apps/studio/components/interfaces/Support/ProjectAndPlanInfo.tsx b/apps/studio/components/interfaces/Support/ProjectAndPlanInfo.tsx
index 307d29e6c73e3..91f29c19f7cf8 100644
--- a/apps/studio/components/interfaces/Support/ProjectAndPlanInfo.tsx
+++ b/apps/studio/components/interfaces/Support/ProjectAndPlanInfo.tsx
@@ -1,17 +1,17 @@
-import { AnimatePresence, motion } from 'framer-motion'
-import { Check, ChevronsUpDown, ExternalLink } from 'lucide-react'
-import Link from 'next/link'
-import type { UseFormReturn } from 'react-hook-form'
-import { toast } from 'sonner'
// End of third-party imports
import { useParams } from 'common'
import CopyButton from 'components/ui/CopyButton'
import { OrganizationProjectSelector } from 'components/ui/OrganizationProjectSelector'
+import { AnimatePresence, motion } from 'framer-motion'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
+import { Check, ChevronsUpDown, ExternalLink } from 'lucide-react'
+import Link from 'next/link'
+import type { UseFormReturn } from 'react-hook-form'
+import { toast } from 'sonner'
import {
- cn,
Button,
+ cn,
CommandGroup_Shadcn_,
CommandItem_Shadcn_,
FormControl_Shadcn_,
@@ -20,6 +20,7 @@ import {
import { Admonition } from 'ui-patterns'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
+
import type { ExtendedSupportCategories } from './Support.constants'
import type { SupportFormValues } from './SupportForm.schema'
import { NO_ORG_MARKER, NO_PROJECT_MARKER } from './SupportForm.utils'
@@ -89,13 +90,15 @@ function ProjectSelector({ form, orgSlug, projectRef }: ProjectSelectorProps) {
field.onChange(projects[0]?.ref ?? NO_PROJECT_MARKER)
}}
onSelect={(project) => field.onChange(project.ref)}
- renderTrigger={({ isLoading, project }) => {
+ renderTrigger={({ isLoading, project, listboxId, open }) => {
return (
}
diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnType.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnType.tsx
index eb87f92a313bd..860e8ab29fba3 100644
--- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnType.tsx
+++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnType.tsx
@@ -11,35 +11,35 @@ import {
Type,
} from 'lucide-react'
import Link from 'next/link'
-import { ReactNode, useState } from 'react'
+import { ReactNode, useId, useState } from 'react'
import {
+ Alert_Shadcn_,
AlertDescription_Shadcn_,
AlertTitle_Shadcn_,
- Alert_Shadcn_,
Button,
+ cn,
+ Command_Shadcn_,
CommandEmpty_Shadcn_,
CommandGroup_Shadcn_,
CommandInput_Shadcn_,
CommandItem_Shadcn_,
CommandList_Shadcn_,
CommandSeparator_Shadcn_,
- Command_Shadcn_,
CriticalIcon,
Input,
Label_Shadcn_,
+ Popover_Shadcn_,
PopoverContent_Shadcn_,
PopoverTrigger_Shadcn_,
- Popover_Shadcn_,
ScrollArea,
Tooltip,
TooltipContent,
TooltipTrigger,
- cn,
} from 'ui'
import {
- POSTGRES_DATA_TYPES,
POSTGRES_DATA_TYPE_OPTIONS,
+ POSTGRES_DATA_TYPES,
RECOMMENDED_ALTERNATIVE_DATA_TYPE,
} from '../SidePanelEditor.constants'
import type { PostgresDataTypeOption } from '../SidePanelEditor.types'
@@ -70,6 +70,7 @@ const ColumnType = ({
error,
}: ColumnTypeProps) => {
const [open, setOpen] = useState(false)
+ const listboxId = useId()
const availableTypes = POSTGRES_DATA_TYPES.concat(
enumTypes.map((type) => type.format.replaceAll('"', ''))
)
@@ -173,6 +174,7 @@ const ColumnType = ({
role="combobox"
size={'small'}
aria-expanded={open}
+ aria-controls={listboxId}
className={cn('w-full justify-between', !value && 'text-foreground-lighter')}
iconRight={}
>
@@ -186,7 +188,12 @@ const ColumnType = ({
)}
-
+
}
tooltip={{ content: { side: 'bottom', text: 'Toggle column visibility' } }}
/>
-
+
diff --git a/apps/studio/components/ui/EditorPanel/EditorPanel.tsx b/apps/studio/components/ui/EditorPanel/EditorPanel.tsx
index a31d9fe0359b5..7f660ea02c1fa 100644
--- a/apps/studio/components/ui/EditorPanel/EditorPanel.tsx
+++ b/apps/studio/components/ui/EditorPanel/EditorPanel.tsx
@@ -16,7 +16,7 @@ import { BASE_PATH } from 'lib/constants'
import { useProfile } from 'lib/profile'
import { Book, Maximize2, X } from 'lucide-react'
import { useRouter } from 'next/router'
-import { useState } from 'react'
+import { useId, useState } from 'react'
import { useEditorPanelStateSnapshot } from 'state/editor-panel-state'
import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state'
import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2'
@@ -83,6 +83,7 @@ export const EditorPanel = () => {
const [showWarning, setShowWarning] = useState<'hasWriteOperation' | 'hasUnknownFunctions'>()
const [showResults, setShowResults] = useState(true)
const [isTemplatesOpen, setIsTemplatesOpen] = useState(false)
+ const templatesListboxId = useId()
const errorHeader = error?.formattedError?.split('\n')?.filter((x: string) => x.length > 0)?.[0]
const errorContent =
@@ -167,12 +168,13 @@ export const EditorPanel = () => {
role="combobox"
className="mr-2"
aria-expanded={isTemplatesOpen}
+ aria-controls={templatesListboxId}
icon={}
>
Templates
-
+
diff --git a/apps/studio/components/ui/OrganizationProjectSelector.tsx b/apps/studio/components/ui/OrganizationProjectSelector.tsx
index f53cfa2bfbf38..d030f24554979 100644
--- a/apps/studio/components/ui/OrganizationProjectSelector.tsx
+++ b/apps/studio/components/ui/OrganizationProjectSelector.tsx
@@ -3,7 +3,7 @@ import { useDebounce, useIntersectionObserver } from '@uidotdev/usehooks'
import { OrgProject, useOrgProjectsInfiniteQuery } from 'data/projects/org-projects-infinite-query'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { Check, ChevronsUpDown, HelpCircle } from 'lucide-react'
-import { ReactNode, useEffect, useMemo, useRef, useState } from 'react'
+import { ReactNode, useEffect, useId, useMemo, useRef, useState } from 'react'
import {
Button,
cn,
@@ -34,9 +34,13 @@ interface OrganizationProjectSelectorSelectorProps {
renderTrigger?: ({
isLoading,
project,
+ listboxId,
+ open,
}: {
isLoading: boolean
project?: OrgProject
+ listboxId: string
+ open: boolean
}) => ReactNode
renderActions?: (setOpen: (value: boolean) => void) => ReactNode
onSelect?: (project: OrgProject) => void
@@ -69,6 +73,7 @@ export const OrganizationProjectSelector = ({
const [openInternal, setOpenInternal] = useState(false)
const open = _open ?? openInternal
const setOpen = _setOpen ?? setOpenInternal
+ const listboxId = useId()
const [search, setSearch] = useState('')
const debouncedSearch = useDebounce(search, 500)
@@ -130,13 +135,20 @@ export const OrganizationProjectSelector = ({
{renderTrigger ? (
- renderTrigger({ isLoading: isLoadingProjects || isFetching, project: selectedProject })
+ renderTrigger({
+ isLoading: isLoadingProjects || isFetching,
+ project: selectedProject,
+ listboxId,
+ open,
+ })
) : (
}
>
@@ -149,6 +161,7 @@ export const OrganizationProjectSelector = ({
)}
{
const [files, setFiles] = useState(INITIAL_FILES)
const [selectedFileId, setSelectedFileId] = useState(INITIAL_FILES[0].id)
const [open, setOpen] = useState(false)
+ const templatesListboxId = useId()
const [isPreviewingTemplate, setIsPreviewingTemplate] = useState(false)
const [savedCode, setSavedCode] = useState('')
@@ -311,12 +312,13 @@ const NewFunctionPage = () => {
type="default"
role="combobox"
aria-expanded={open}
+ aria-controls={templatesListboxId}
icon={}
>
Templates
-
+
diff --git a/apps/www/_events/2026-03-19-agency-webinar-ai-prototyping-production.mdx b/apps/www/_events/2026-03-19-agency-webinar-ai-prototyping-production.mdx
new file mode 100644
index 0000000000000..a5cc2c4a7182a
--- /dev/null
+++ b/apps/www/_events/2026-03-19-agency-webinar-ai-prototyping-production.mdx
@@ -0,0 +1,40 @@
+---
+title: 'Ship Fast, Stay Safe: AI Prototyping That Survives Production'
+meta_title: 'Ship Fast, Stay Safe: AI Prototyping That Survives Production'
+subtitle: >-
+ Learn how top agencies balance velocity with control when using AI coding tools to build production applications on Supabase
+meta_description: >-
+ Learn how top agencies balance AI prototyping speed with production safety. Database schema design, RLS policies, validation practices, and client handoff strategies.
+type: webinar
+onDemand: false
+date: '2026-03-19T09:00:00.000-07:00'
+timezone: America/Los_Angeles
+duration: 45 mins
+categories:
+ - webinar
+main_cta:
+ {
+ url: 'https://attendee.gotowebinar.com/register/5606981989518150747',
+ target: '_blank',
+ label: 'Register now',
+ }
+speakers: 'seth_kramer,dave_wilson'
+---
+
+AI coding tools have made prototyping almost instant. Agencies now send working demos to clients within minutes of a kickoff call. But speed creates new risks: databases with no row-level security, permissions that break under load, and prototypes that fall apart the moment they hit production.
+
+Join agency leaders and Supabase as they share how top agencies balance velocity with control. Learn when to let AI move fast, and where experienced developers still need to step in.
+
+## Key takeaways
+
+- Why leading agencies design database schemas and RLS policies before touching the UI
+
+- When to use visual AI builders for speed vs code-first tools for control
+
+- How to prevent AI tools from making breaking changes to production environments
+
+- Lightweight validation practices that preserve velocity without sacrificing safety
+
+- How to hand projects off to clients without handing over production risk
+
+Join us live to participate in the Q&A. Can't make it? We'll send you a link to the recording.
diff --git a/apps/www/public/rss.xml b/apps/www/public/rss.xml
index ee7d8c3ebfcc0..bd8ffb5024d5b 100644
--- a/apps/www/public/rss.xml
+++ b/apps/www/public/rss.xml
@@ -4,15 +4,43 @@
https://supabase.com
Latest news from Supabase
en
- Tue, 03 Feb 2026 00:00:00 -0700
+ Fri, 13 Feb 2026 00:00:00 -0700
-
+ https://supabase.com/blog/supabase-incident-on-february-12-2026
+ Supabase incident on February 12, 2026
+ https://supabase.com/blog/supabase-incident-on-february-12-2026
+ A detailed account of the February 12 outage in us-east-2, what caused it, and the steps we are taking to prevent it from happening again.
+ Fri, 13 Feb 2026 00:00:00 -0700
+
+-
+ https://supabase.com/blog/hydra-joins-supabase
+ Hydra joins Supabase
+ https://supabase.com/blog/hydra-joins-supabase
+ The Hydra team, maintainers of pg_duckdb, is joining Supabase to focus on Postgres + Analytics and Open Warehouse Architecture.
+ Tue, 10 Feb 2026 00:00:00 -0700
+
+-
+ https://supabase.com/blog/x-twitter-oauth-2-provider
+ X / Twitter OAuth 2.0 is now available for Supabase Auth
+ https://supabase.com/blog/x-twitter-oauth-2-provider
+ You can now add "Sign in with X" to your application using the new X / Twitter (OAuth 2.0) provider in Supabase Auth.
+ Fri, 06 Feb 2026 00:00:00 -0700
+
+-
https://supabase.com/blog/bknd-joins-supabase
BKND joins Supabase
https://supabase.com/blog/bknd-joins-supabase
Dennis Senn, creator of BKND, is joining Supabase to build a Lite offering for agentic workloads.
Tue, 03 Feb 2026 00:00:00 -0700
+-
+ https://supabase.com/blog/supabase-is-now-an-official-claude-connector
+ Supabase is now an official Claude connector
+ https://supabase.com/blog/supabase-is-now-an-official-claude-connector
+ Connect your Supabase projects to Claude and manage your entire database infrastructure by telling Claude what you need.
+ Tue, 03 Feb 2026 00:00:00 -0700
+
-
https://supabase.com/blog/supabase-privatelink-available
Supabase PrivateLink is now available
diff --git a/docker/docker-compose.s3.yml b/docker/docker-compose.s3.yml
index b7f8a2c70beff..cb3e25c3e7542 100644
--- a/docker/docker-compose.s3.yml
+++ b/docker/docker-compose.s3.yml
@@ -1,16 +1,16 @@
services:
minio:
- image: minio/minio
- ports:
- - '9000:9000'
- - '9001:9001'
+ image: cgr.dev/chainguard/minio
+ #ports:
+ # - '9000:9000'
+ # - '9001:9001'
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
command: server --console-address ":9001" /data
healthcheck:
- test: [ "CMD", "curl", "-f", "http://minio:9000/minio/health/live" ]
+ test: [ "CMD", "mc", "ready", "local" ]
interval: 2s
timeout: 10s
retries: 5
@@ -18,7 +18,7 @@ services:
- ./volumes/storage:/data:z
minio-createbucket:
- image: minio/mc
+ image: cgr.dev/chainguard/minio-client:latest-dev
depends_on:
minio:
condition: service_healthy
@@ -30,7 +30,8 @@ services:
storage:
container_name: supabase-storage
- image: supabase/storage-api:v1.37.1
+ image: supabase/storage-api:v1.37.8
+ restart: unless-stopped
depends_on:
db:
# Disable this if you are using an external Postgres database
@@ -41,6 +42,8 @@ services:
condition: service_started
minio-createbucket:
condition: service_completed_successfully
+ minio:
+ condition: service_healthy
healthcheck:
test:
[
@@ -54,7 +57,6 @@ services:
timeout: 5s
interval: 5s
retries: 3
- restart: unless-stopped
environment:
ANON_KEY: ${ANON_KEY}
SERVICE_KEY: ${SERVICE_ROLE_KEY}
@@ -64,33 +66,22 @@ services:
REQUEST_ALLOW_X_FORWARDED_PATH: "true"
FILE_SIZE_LIMIT: 52428800
STORAGE_BACKEND: s3
+ # S3 bucket when using S3 backend, directory name when using 'file'
GLOBAL_S3_BUCKET: ${GLOBAL_S3_BUCKET}
+ # S3 Backend configuration
GLOBAL_S3_ENDPOINT: http://minio:9000
GLOBAL_S3_PROTOCOL: http
GLOBAL_S3_FORCE_PATH_STYLE: true
AWS_ACCESS_KEY_ID: ${MINIO_ROOT_USER}
AWS_SECRET_ACCESS_KEY: ${MINIO_ROOT_PASSWORD}
- AWS_DEFAULT_REGION: stub
- FILE_STORAGE_BACKEND_PATH: /var/lib/storage
+ #FILE_STORAGE_BACKEND_PATH: /var/lib/storage
TENANT_ID: ${STORAGE_TENANT_ID}
# TODO: https://github.com/supabase/storage-api/issues/55
REGION: ${REGION}
ENABLE_IMAGE_TRANSFORMATION: "true"
IMGPROXY_URL: http://imgproxy:5001
+ # S3 protocol endpoint configuration
S3_PROTOCOL_ACCESS_KEY_ID: ${S3_PROTOCOL_ACCESS_KEY_ID}
S3_PROTOCOL_ACCESS_KEY_SECRET: ${S3_PROTOCOL_ACCESS_KEY_SECRET}
- volumes:
- - ./volumes/storage:/var/lib/storage:z
-
- imgproxy:
- container_name: supabase-imgproxy
- image: darthsim/imgproxy:v3.8.0
- healthcheck:
- test: [ "CMD", "imgproxy", "health" ]
- timeout: 5s
- interval: 5s
- retries: 3
- environment:
- IMGPROXY_BIND: ":5001"
- IMGPROXY_USE_ETAG: "true"
- IMGPROXY_ENABLE_WEBP_DETECTION: ${IMGPROXY_ENABLE_WEBP_DETECTION}
+ #volumes:
+ # - ./volumes/storage:/var/lib/storage:z
diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml
index 11f952c7d6439..fc6dcb3729fb3 100644
--- a/docker/docker-compose.yml
+++ b/docker/docker-compose.yml
@@ -244,8 +244,14 @@ services:
container_name: supabase-storage
image: supabase/storage-api:v1.37.8
restart: unless-stopped
- volumes:
- - ./volumes/storage:/var/lib/storage:z
+ depends_on:
+ db:
+ # Disable this if you are using an external Postgres database
+ condition: service_healthy
+ rest:
+ condition: service_started
+ imgproxy:
+ condition: service_started
healthcheck:
test:
[
@@ -259,14 +265,6 @@ services:
timeout: 5s
interval: 5s
retries: 3
- depends_on:
- db:
- # Disable this if you are using an external Postgres database
- condition: service_healthy
- rest:
- condition: service_started
- imgproxy:
- condition: service_started
environment:
ANON_KEY: ${ANON_KEY}
SERVICE_KEY: ${SERVICE_ROLE_KEY}
@@ -276,15 +274,25 @@ services:
REQUEST_ALLOW_X_FORWARDED_PATH: "true"
FILE_SIZE_LIMIT: 52428800
STORAGE_BACKEND: file
+ # S3 bucket when using S3 backend, directory name when using 'file'
+ GLOBAL_S3_BUCKET: ${GLOBAL_S3_BUCKET}
+ # S3 Backend configuration
+ #GLOBAL_S3_ENDPOINT: https://your-s3-endpoint
+ #GLOBAL_S3_PROTOCOL: https
+ #GLOBAL_S3_FORCE_PATH_STYLE: true
+ #AWS_ACCESS_KEY_ID: your-access-key-id
+ #AWS_SECRET_ACCESS_KEY: your-secret-access-key
FILE_STORAGE_BACKEND_PATH: /var/lib/storage
TENANT_ID: ${STORAGE_TENANT_ID}
# TODO: https://github.com/supabase/storage-api/issues/55
REGION: ${REGION}
- GLOBAL_S3_BUCKET: ${GLOBAL_S3_BUCKET}
ENABLE_IMAGE_TRANSFORMATION: "true"
IMGPROXY_URL: http://imgproxy:5001
+ # S3 protocol endpoint configuration
S3_PROTOCOL_ACCESS_KEY_ID: ${S3_PROTOCOL_ACCESS_KEY_ID}
S3_PROTOCOL_ACCESS_KEY_SECRET: ${S3_PROTOCOL_ACCESS_KEY_SECRET}
+ volumes:
+ - ./volumes/storage:/var/lib/storage:z
imgproxy:
container_name: supabase-imgproxy
@@ -334,6 +342,7 @@ services:
restart: unless-stopped
volumes:
- ./volumes/functions:/home/deno/functions:Z
+ - deno-cache:/root/.cache/deno
depends_on:
analytics:
condition: service_healthy
@@ -543,3 +552,4 @@ services:
volumes:
db-config:
+ deno-cache:
diff --git a/docker/volumes/functions/hello/index.ts b/docker/volumes/functions/hello/index.ts
index f1e20b90e0157..e3f138b5ecafc 100644
--- a/docker/volumes/functions/hello/index.ts
+++ b/docker/volumes/functions/hello/index.ts
@@ -2,9 +2,7 @@
// https://deno.land/manual/getting_started/setup_your_environment
// This enables autocomplete, go to definition, etc.
-import { serve } from "https://deno.land/std@0.177.1/http/server.ts"
-
-serve(async () => {
+Deno.serve(async () => {
return new Response(
`"Hello from Edge Functions!"`,
{ headers: { "Content-Type": "application/json" } },
diff --git a/docker/volumes/functions/main/index.ts b/docker/volumes/functions/main/index.ts
index a094010b9dade..b593527d687a7 100644
--- a/docker/volumes/functions/main/index.ts
+++ b/docker/volumes/functions/main/index.ts
@@ -1,4 +1,3 @@
-import { serve } from 'https://deno.land/std@0.131.0/http/server.ts'
import * as jose from 'https://deno.land/x/jose@v4.14.4/index.ts'
console.log('main function started')
@@ -30,7 +29,7 @@ async function verifyJWT(jwt: string): Promise {
return true
}
-serve(async (req: Request) => {
+Deno.serve(async (req: Request) => {
if (req.method !== 'OPTIONS' && VERIFY_JWT) {
try {
const token = getAuthToken(req)
diff --git a/e2e/studio/features/realtime-inspector.spec.ts b/e2e/studio/features/realtime-inspector.spec.ts
new file mode 100644
index 0000000000000..64737faa4a7dd
--- /dev/null
+++ b/e2e/studio/features/realtime-inspector.spec.ts
@@ -0,0 +1,153 @@
+import { expect } from '@playwright/test'
+import { test } from '../utils/test.js'
+import {
+ getMessageCount,
+ joinChannel,
+ leaveChannel,
+ navigateToRealtimeInspector,
+ openBroadcastModal,
+ startListening,
+ stopListening,
+ waitForRealtimeMessage,
+} from '../utils/realtime-helpers.js'
+
+const testChannelName = 'pw_realtime_test_channel'
+
+test.describe('Realtime Inspector', () => {
+ test.beforeEach(async ({ page, ref }) => {
+ await navigateToRealtimeInspector(page, ref)
+ })
+
+ test.describe('Basic Inspector UI', () => {
+ test('inspector page loads correctly with empty state', async ({ page }) => {
+ await expect(page.getByRole('button', { name: 'Join a channel' })).toBeVisible()
+
+ const startButton = page.getByRole('button', { name: 'Start listening' })
+ await expect(startButton).toBeVisible()
+ await expect(startButton).toBeDisabled()
+
+ await expect(page.getByText('Create realtime experiences')).toBeVisible()
+ })
+
+ test('channel selection popover opens and works', async ({ page }) => {
+ await page.getByRole('button', { name: 'Join a channel' }).click()
+
+ await expect(page.getByPlaceholder('Enter a channel name')).toBeVisible({ timeout: 5000 })
+ await expect(page.getByRole('button', { name: 'Listen to channel' })).toBeVisible()
+ await expect(page.getByText('Is channel private?')).toBeVisible()
+
+ await page.keyboard.press('Escape')
+ })
+
+ test('can join and leave a channel', async ({ page }) => {
+ await joinChannel(page, testChannelName)
+
+ await expect(page.getByText('Listening', { exact: true })).toBeVisible({ timeout: 10000 })
+ await expect(page.getByRole('button', { name: `Channel: ${testChannelName}` })).toBeVisible()
+
+ await leaveChannel(page)
+
+ await expect(page.getByRole('button', { name: 'Join a channel' })).toBeVisible()
+ })
+
+ test('start/stop listening button works', async ({ page }) => {
+ await joinChannel(page, testChannelName)
+
+ await expect(page.getByText('Listening', { exact: true })).toBeVisible({ timeout: 10000 })
+ await expect(page.getByRole('button', { name: 'Stop listening' })).toBeVisible()
+
+ await stopListening(page)
+
+ await expect(page.getByRole('button', { name: 'Start listening' })).toBeVisible()
+ await expect(page.getByText('Listening', { exact: true })).not.toBeVisible()
+
+ await startListening(page)
+
+ await expect(page.getByText('Listening', { exact: true })).toBeVisible({ timeout: 10000 })
+
+ await leaveChannel(page)
+ })
+ })
+
+ test.describe('Broadcast Messages', () => {
+ test('broadcast messages appear in the UI when listening', async ({ page }) => {
+ await joinChannel(page, testChannelName)
+
+ await expect(page.getByText('Listening', { exact: true })).toBeVisible({ timeout: 10000 })
+
+ await openBroadcastModal(page)
+ await page.getByRole('button', { name: 'Confirm' }).click()
+
+ await expect(page.getByText('Successfully broadcasted message')).toBeVisible({
+ timeout: 10000,
+ })
+
+ const messageRow = await waitForRealtimeMessage(page, { timeout: 30000 })
+ await expect(messageRow).toBeVisible()
+
+ const count = await getMessageCount(page)
+ expect(count).toBeGreaterThanOrEqual(1)
+
+ await leaveChannel(page)
+ })
+
+ test('clicking broadcast message shows detail panel', async ({ page }) => {
+ await joinChannel(page, testChannelName)
+
+ await openBroadcastModal(page)
+ await page.getByRole('button', { name: 'Confirm' }).click()
+ await expect(page.getByText('Successfully broadcasted message')).toBeVisible({
+ timeout: 10000,
+ })
+ await waitForRealtimeMessage(page, { timeout: 30000 })
+
+ const messageRow = page.getByRole('row').filter({ hasText: 'broadcast' }).first()
+ await expect(messageRow).toBeVisible({ timeout: 5000 })
+ await messageRow.click()
+
+ await expect(page.getByText('Timestamp')).toBeVisible({ timeout: 5000 })
+
+ await leaveChannel(page)
+ })
+
+ test('broadcast modal validates JSON payload', async ({ page }) => {
+ await joinChannel(page, testChannelName)
+
+ await openBroadcastModal(page)
+
+ const codeEditor = page.getByRole('textbox', { name: /Editor content/i })
+ await codeEditor.click({ force: true })
+ await page.keyboard.press('ControlOrMeta+KeyA')
+ await page.keyboard.type('{ invalid json }')
+
+ await page.getByRole('button', { name: 'Confirm' }).click()
+
+ await expect(page.getByText('Please provide a valid JSON')).toBeVisible({ timeout: 5000 })
+
+ await page.getByRole('button', { name: 'Cancel' }).click()
+
+ await leaveChannel(page)
+ })
+ })
+
+ test.describe('Message Display', () => {
+ test('messages counter shows correct count', async ({ page }) => {
+ await joinChannel(page, `${testChannelName}_counter`)
+
+ const initialCount = await getMessageCount(page)
+
+ await openBroadcastModal(page)
+ await page.getByRole('button', { name: 'Confirm' }).click()
+ await expect(page.getByText('Successfully broadcasted message')).toBeVisible({
+ timeout: 10000,
+ })
+
+ await waitForRealtimeMessage(page, { timeout: 30000 })
+
+ const newCount = await getMessageCount(page)
+ expect(newCount).toBeGreaterThan(initialCount)
+
+ await leaveChannel(page)
+ })
+ })
+})
diff --git a/e2e/studio/utils/realtime-helpers.ts b/e2e/studio/utils/realtime-helpers.ts
new file mode 100644
index 0000000000000..e1729f2c8f71c
--- /dev/null
+++ b/e2e/studio/utils/realtime-helpers.ts
@@ -0,0 +1,69 @@
+import { expect, Page } from '@playwright/test'
+import { toUrl } from './to-url.js'
+
+export async function navigateToRealtimeInspector(page: Page, ref: string) {
+ await page.goto(toUrl(`/project/${ref}/realtime/inspector`))
+ await expect(page.locator('text=Join a channel')).toBeVisible({ timeout: 30000 })
+}
+
+export async function joinChannel(page: Page, channelName: string) {
+ await page.getByRole('button', { name: /Join a channel|Channel:/ }).click()
+ await expect(page.getByPlaceholder('Enter a channel name')).toBeVisible({ timeout: 5000 })
+ await page.getByPlaceholder('Enter a channel name').fill(channelName)
+ await page.getByRole('button', { name: 'Listen to channel' }).click()
+ await expect(page.getByText('Listening', { exact: true })).toBeVisible({ timeout: 10000 })
+}
+
+export async function leaveChannel(page: Page) {
+ await page.getByRole('button', { name: /Channel:/ }).click()
+ await expect(page.getByRole('button', { name: 'Leave channel' })).toBeVisible({ timeout: 5000 })
+ await page.getByRole('button', { name: 'Leave channel' }).click()
+ await expect(page.getByRole('button', { name: 'Join a channel' })).toBeVisible({ timeout: 5000 })
+}
+
+export async function startListening(page: Page) {
+ const listenButton = page.getByRole('button', { name: 'Start listening' })
+ await expect(listenButton).toBeVisible({ timeout: 5000 })
+ await listenButton.click()
+ await expect(page.getByText('Listening', { exact: true })).toBeVisible({ timeout: 10000 })
+}
+
+export async function stopListening(page: Page) {
+ const stopButton = page.getByRole('button', { name: 'Stop listening' })
+ await expect(stopButton).toBeVisible({ timeout: 5000 })
+ await stopButton.click()
+ await expect(page.getByRole('button', { name: 'Start listening' })).toBeVisible({ timeout: 5000 })
+}
+
+export async function openBroadcastModal(page: Page) {
+ const broadcastButton = page.getByRole('button', { name: 'Broadcast a message' })
+ await expect(broadcastButton).toBeVisible({ timeout: 5000 })
+ await broadcastButton.click()
+ await expect(page.getByText('Broadcast a message to all clients')).toBeVisible({ timeout: 5000 })
+}
+
+export async function waitForRealtimeMessage(page: Page, options?: { timeout?: number }) {
+ const timeout = options?.timeout ?? 30000
+ const gridRow = page.getByRole('row').filter({ hasText: /^\d{4}-\d{2}-\d{2}/ }).first()
+ await expect(gridRow).toBeVisible({ timeout })
+ return gridRow
+}
+
+export async function getMessageCount(page: Page): Promise {
+ const countText = page.locator('text=/Found \\d+ messages/')
+ if ((await countText.count()) === 0) {
+ const noMessages = page.locator('text=No message found yet')
+ if ((await noMessages.count()) > 0) {
+ return 0
+ }
+ const maxMessages = page.locator('text=/showing only the latest 100/')
+ if ((await maxMessages.count()) > 0) {
+ return 100
+ }
+ return 0
+ }
+
+ const text = await countText.textContent()
+ const match = text?.match(/Found (\d+) messages/)
+ return match ? parseInt(match[1], 10) : 0
+}
diff --git a/supabase/migrations/20260218202528_status_cache.sql b/supabase/migrations/20260218202528_status_cache.sql
new file mode 100644
index 0000000000000..49fa7bcdd7fbe
--- /dev/null
+++ b/supabase/migrations/20260218202528_status_cache.sql
@@ -0,0 +1,35 @@
+create table if not exists public.incident_status_cache (
+ id bigint primary key generated always as identity,
+ incident_id text unique not null,
+ shortlink text unique not null,
+ updated_at timestamptz not null default now(),
+ affects_project_creation boolean not null default false,
+ affected_regions text[]
+);
+
+alter table public.incident_status_cache
+enable row level security;
+
+revoke all on public.incident_status_cache from anon;
+revoke all on public.incident_status_cache from authenticated;
+
+create index if not exists idx_incident_status_cache_incident_id
+on public.incident_status_cache (incident_id);
+
+create index if not exists idx_incident_status_cache_shortlink
+on public.incident_status_cache (shortlink);
+
+create or replace function public.set_updated_at()
+returns trigger
+language plpgsql
+as $$
+begin
+ new.updated_at = now();
+ return new;
+end;
+$$;
+
+create trigger set_incident_status_cache_updated_at
+before update on public.incident_status_cache
+for each row
+execute function public.set_updated_at();