From 1cb119b63daea5618251f905090c607236be5489 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Thu, 27 Aug 2026 12:15:15 +0800 Subject: [PATCH 1/7] Set default opt in for explorer to be false (#49611) ## Context As per PR title - default opt in for explorer preview should be false --- .../interfaces/App/FeaturePreview/useFeaturePreviews.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts b/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts index 797164a05e753..fb766a922ffc7 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts +++ b/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts @@ -50,7 +50,7 @@ export const useFeaturePreviews = (): FeaturePreview[] => { enabled: isExplorerEnabled, isNew: true, isPlatformOnly: true, - isDefaultOptIn: true, + isDefaultOptIn: false, getRoute: (ref?: string) => `/project/${ref}/explorer`, }, { From 961fc749d48222adb4207bacf508c761d2eff0d8 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Thu, 27 Aug 2026 14:12:04 +0800 Subject: [PATCH 2/7] Add feature preview banner toast for explorerd (#49606) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Context Adds a feature preview banner toast for the explorer - flagged behind the configcat flag image ## Summary by CodeRabbit - **New Features** - Added an Explorer preview banner to project layouts when the feature is enabled. - Added an “Enable Explorer” call-to-action that opens the feature preview. - Banner dismissal is remembered and persists across sessions. - Added telemetry tracking for banner dismissal and CTA interactions. - **Bug Fixes** - Improved banner behavior and stability when displaying database connection notifications. --- .../ObservabilityLayout.tsx | 1 - .../layouts/ProjectLayout/index.test.tsx | 1 + .../layouts/ProjectLayout/index.tsx | 22 ++++- .../ui/BannerStack/BannerStackProvider.tsx | 1 + .../ui/BannerStack/Banners/BannerExplorer.tsx | 84 +++++++++++++++++++ packages/common/constants/local-storage.ts | 1 + packages/common/telemetry-constants.ts | 24 ++++++ 7 files changed, 132 insertions(+), 2 deletions(-) create mode 100644 apps/studio/components/ui/BannerStack/Banners/BannerExplorer.tsx diff --git a/apps/studio/components/layouts/ObservabilityLayout/ObservabilityLayout.tsx b/apps/studio/components/layouts/ObservabilityLayout/ObservabilityLayout.tsx index acdd6ac0b9862..8283641f5df94 100644 --- a/apps/studio/components/layouts/ObservabilityLayout/ObservabilityLayout.tsx +++ b/apps/studio/components/layouts/ObservabilityLayout/ObservabilityLayout.tsx @@ -56,7 +56,6 @@ const ObservabilityLayoutContent = ({ }) }, [ addBanner, - dismissBanner, isInitialized, isDatabaseConnectionsBannerDismissed, previouslyToggled, diff --git a/apps/studio/components/layouts/ProjectLayout/index.test.tsx b/apps/studio/components/layouts/ProjectLayout/index.test.tsx index 6c7b2601677ec..13090a00efd85 100644 --- a/apps/studio/components/layouts/ProjectLayout/index.test.tsx +++ b/apps/studio/components/layouts/ProjectLayout/index.test.tsx @@ -81,6 +81,7 @@ vi.mock('common', () => ({ `project-integration-banner-dismissed-${ref}-${integrationSource}`, }, isFeatureEnabled: () => false, + useFlag: () => false, })) vi.mock('framer-motion', () => ({ diff --git a/apps/studio/components/layouts/ProjectLayout/index.tsx b/apps/studio/components/layouts/ProjectLayout/index.tsx index 3e63092a140bc..d03fe84dd624f 100644 --- a/apps/studio/components/layouts/ProjectLayout/index.tsx +++ b/apps/studio/components/layouts/ProjectLayout/index.tsx @@ -1,4 +1,4 @@ -import { LOCAL_STORAGE_KEYS, mergeRefs, useParams } from 'common' +import { LOCAL_STORAGE_KEYS, mergeRefs, useFlag, useParams } from 'common' import { AnimatePresence, motion } from 'framer-motion' import { XIcon } from 'lucide-react' import Head from 'next/head' @@ -42,6 +42,7 @@ import { UnhealthyState } from './UnhealthyState' import { UpgradingState } from './UpgradingState' import { CreateBranchModal } from '@/components/interfaces/BranchManagement/CreateBranchModal' import { ProjectAPIDocs } from '@/components/interfaces/ProjectAPIDocs/ProjectAPIDocs' +import { BannerExplorer } from '@/components/ui/BannerStack/Banners/BannerExplorer' import { BannerFreeMicroUpgrade } from '@/components/ui/BannerStack/Banners/BannerFreeMicroUpgrade' import { BANNER_ID, useBannerStack } from '@/components/ui/BannerStack/BannerStackProvider' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' @@ -152,6 +153,9 @@ export const ProjectLayout = forwardRef { + if (!isExplorerEnabled || !isLocalStorageReady || isExplorerBannerDismissed) return + + addBanner({ + id: 'explorer-banner', + priority: 2, + isDismissed: false, + content: , + }) + }, [addBanner, isExplorerEnabled, isExplorerBannerDismissed, isLocalStorageReady]) + useLayoutEffect(() => { const unregister = registerOpenMenu(() => { setMobileSheetContent( diff --git a/apps/studio/components/ui/BannerStack/BannerStackProvider.tsx b/apps/studio/components/ui/BannerStack/BannerStackProvider.tsx index 4c95ece258544..90413237e613a 100644 --- a/apps/studio/components/ui/BannerStack/BannerStackProvider.tsx +++ b/apps/studio/components/ui/BannerStack/BannerStackProvider.tsx @@ -9,6 +9,7 @@ export const BANNER_ID = { TOS_UPDATE: 'tos-update-banner', LOGS_ALL_DEPRECATION: 'logs-all-deprecation-banner', SELECT_26: 'select-2026-banner', + EXPLORER: 'explorer-banner', } as const export type BannerId = (typeof BANNER_ID)[keyof typeof BANNER_ID] diff --git a/apps/studio/components/ui/BannerStack/Banners/BannerExplorer.tsx b/apps/studio/components/ui/BannerStack/Banners/BannerExplorer.tsx new file mode 100644 index 0000000000000..78120cc7b397b --- /dev/null +++ b/apps/studio/components/ui/BannerStack/Banners/BannerExplorer.tsx @@ -0,0 +1,84 @@ +import { LOCAL_STORAGE_KEYS } from 'common' +import { AnimatePresence, motion } from 'framer-motion' +import { Badge, Button } from 'ui' + +import { BannerCard } from '../BannerCard' +import { useBannerStack } from '../BannerStackProvider' +import { useFeaturePreviewModal } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' +import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' +import { useTrack } from '@/lib/telemetry/track' + +export const BannerExplorer = () => { + const track = useTrack() + const { dismissBanner } = useBannerStack() + const { selectFeaturePreview } = useFeaturePreviewModal() + + const [, setIsDismissed] = useLocalStorageQuery( + LOCAL_STORAGE_KEYS.EXPLORER_BANNER_DISMISSED, + false + ) + + return ( + { + setIsDismissed(true) + dismissBanner('explorer-banner') + track('explorer_banner_dismiss_button_clicked') + }} + > +
+
+ + Preview + + +
+ +

User growth

+

+ Track how your user base is trending. +

+
+ +
+

+ select + date_trunc('week', last_sign_in_at) as week, + count(distinct id) as active_users +

+ +
+ +
+
+

Explorer & Notebooks

+

+ New unified workspace for querying data and chatting with Assistant. +

+
+
+ +
+
+ + ) +} diff --git a/packages/common/constants/local-storage.ts b/packages/common/constants/local-storage.ts index cf3e8259db9c1..374cdc1d794c9 100644 --- a/packages/common/constants/local-storage.ts +++ b/packages/common/constants/local-storage.ts @@ -116,6 +116,7 @@ export const LOCAL_STORAGE_KEYS = { `organization-marketplace-banner-dismissed-${orgSlug}-${managedBy}`, PROJECT_INTEGRATION_BANNER_DISMISSED: (ref: string, integrationSource: string) => `project-integration-banner-dismissed-${ref}-${integrationSource}`, + EXPLORER_BANNER_DISMISSED: `explorer-banner-dismissed`, TABLE_EDITOR_QUEUE_OPERATIONS_BANNER_DISMISSED: (ref: string) => `table-editor-queue-operations-banner-dismissed-${ref}`, diff --git a/packages/common/telemetry-constants.ts b/packages/common/telemetry-constants.ts index 335d13f4df51c..a25642186f1fe 100644 --- a/packages/common/telemetry-constants.ts +++ b/packages/common/telemetry-constants.ts @@ -1530,6 +1530,28 @@ export interface DatabaseConnectionsBannerCtaButtonClickedEvent { groups: TelemetryGroups } +/** + * User clicked the dismiss button on the Explorer feature preview banner in studio project pages. + * + * @group Events + * @source studio + */ +export interface ExplorerBannerDismissButtonClickedEvent { + action: 'explorer_banner_dismiss_button_clicked' + groups: TelemetryGroups +} + +/** + * User clicked the CTA button on the Explorer feature preview banner in studio project pages. + * + * @group Events + * @source studio + */ +export interface ExplorerBannerCtaButtonClickedEvent { + action: 'explorer_banner_cta_button_clicked' + groups: TelemetryGroups +} + /** * User clicked a metric card PID in the Overview panel of the Database Connections observability page, selecting it in the activity table below. * @@ -3810,6 +3832,8 @@ export type TelemetryEvent = | DatabaseConnectionsBlockerViewClickedEvent | DatabaseConnectionsBannerDismissButtonClickedEvent | DatabaseConnectionsBannerCtaButtonClickedEvent + | ExplorerBannerDismissButtonClickedEvent + | ExplorerBannerCtaButtonClickedEvent | SessionTerminateButtonClickedEvent | SessionTerminateSubmittedEvent | QueryCancelButtonClickedEvent From df48528443f9be6362497f628dbf03307a8d148d Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Thu, 27 Aug 2026 09:00:11 +0200 Subject: [PATCH 3/7] fix: FormItemLayout does not apply item id correctly (#49593) ## Problem `` does not apply item id correctly. This can be seen on https://supabase.com/design-system/docs/ui-patterns/forms: open the devtool and check the form items labels. They have no `for` attribute. This makes it harder to correctly test and is an accessibility issue. Axe devtool actually report it ## Solution When inside React Hook Form, `` actually generate an `id` (via ``). However, this `id` is overridden in `` and read from context by ``. Simply removing this line fixes it and correctly binds the label to its input --- packages/ui-patterns/src/form/Layout/FormLayout.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/ui-patterns/src/form/Layout/FormLayout.tsx b/packages/ui-patterns/src/form/Layout/FormLayout.tsx index 37f67aa61a314..e944eab71a5af 100644 --- a/packages/ui-patterns/src/form/Layout/FormLayout.tsx +++ b/packages/ui-patterns/src/form/Layout/FormLayout.tsx @@ -398,7 +398,6 @@ export const FormLayout = React.forwardRef< From 777cf83ec40e0b75cbc730c1798d4a3bc3f8c2ae Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Thu, 27 Aug 2026 18:05:03 +1000 Subject: [PATCH 4/7] feat(studio): add leave feedback on replication list (#49582) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What kind of change does this PR introduce? Feature. ## What is the current behavior? Replication destinations list has docs and add-destination actions, but no clear path to leave pipelines feedback. ## What is the new behavior? Adds a **Leave feedback** button that opens the pipelines GitHub discussion. Create destination still opens the existing sheet (no wizard redirect in this PR). | Before | After | | --- | --- | | Replication Database ETL BigTable
ETL Team Supabase | 47954 | | 49523 | Replication Database ETL
BigTable ETL Team Supabase | ## To test 1. Open a project → Database → Replication 2. Click **Leave feedback** in the list toolbar 3. Confirm it opens the pipelines discussion in a new tab 4. Confirm **Add destination** still opens the sheet as today ## Summary by CodeRabbit ## New Features - Added a feedback button to the replication destinations toolbar. - Feedback opens the relevant discussion forum in a new browser tab. ## Improvements - Simplified destination status descriptions for clearer presentation. - Removed inline discussion links from individual destination descriptions. - Centralized feedback access in the replication destinations interface for easier discovery. --- .../DestinationTypeSelection.tsx | 26 +++++-------------- .../Database/Replication/Destinations.tsx | 8 +++++- .../Replication/Replication.constants.ts | 2 ++ 3 files changed, 16 insertions(+), 20 deletions(-) diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationTypeSelection.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationTypeSelection.tsx index fc042ed1de2dd..7e99d4b6323f5 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationTypeSelection.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationTypeSelection.tsx @@ -21,7 +21,6 @@ import { } from '../useIsETLPrivateAlpha' import { DestinationType } from './DestinationPanel.types' import { ReadReplicasMovedCallout } from './ReadReplicasMovedCallout' -import { InlineLink } from '@/components/ui/InlineLink' interface DestinationTypeOption { value: DestinationType @@ -116,24 +115,13 @@ export const DestinationTypeSelection = () => { const selectedOption = options.find((option) => option.value === destinationType) - const stageDescription = - selectedOption?.stage === 'Public Alpha' ? ( - <> - In public alpha and may change.{' '} - - Leave feedback - - - ) : selectedOption?.stage === 'Early Access' ? ( - <> - In early access and may change.{' '} - - Leave feedback - - - ) : selectedOption?.stage === 'Deprecated' ? ( - 'This destination type is deprecated.' - ) : null + const STAGE_DESCRIPTIONS: Record, string> = { + 'Public Alpha': 'In public alpha and may change.', + 'Early Access': 'In early access and may change.', + Deprecated: 'This destination type is deprecated.', + } + + const stageDescription = selectedOption?.stage ? STAGE_DESCRIPTIONS[selectedOption.stage] : null const typeDescription = !editMode || stageDescription ? ( diff --git a/apps/studio/components/interfaces/Database/Replication/Destinations.tsx b/apps/studio/components/interfaces/Database/Replication/Destinations.tsx index 7e95410cedee6..501550521259a 100644 --- a/apps/studio/components/interfaces/Database/Replication/Destinations.tsx +++ b/apps/studio/components/interfaces/Database/Replication/Destinations.tsx @@ -1,6 +1,6 @@ import { useQueryClient } from '@tanstack/react-query' import { useParams } from 'common' -import { MoreVertical, Plus, Search, Workflow, X } from 'lucide-react' +import { MessageSquare, MoreVertical, Plus, Search, Workflow, X } from 'lucide-react' import Link from 'next/link' import { parseAsStringEnum, useQueryState } from 'nuqs' import { useEffect, useMemo, useRef, useState } from 'react' @@ -29,6 +29,7 @@ import { DestinationType } from './DestinationPanel/DestinationPanel.types' import { DestinationRow } from './DestinationRow' import { DisablePipelinesDialog } from './DisablePipelinesDialog' import { EnablePipelinesModal } from './EnablePipelinesCallout' +import { PIPELINES_FEEDBACK_URL } from './Replication.constants' import { useIsETLBigQueryPrivateAlpha, useIsETLClickHousePrivateAlpha, @@ -243,6 +244,11 @@ export const Destinations = () => { + Date: Thu, 27 Aug 2026 10:24:30 +0200 Subject: [PATCH 5/7] feat(studio): deploy worker form (#49613) --- .../interfaces/Workers/DeployWorkerDialog.tsx | 271 ++++++++++++++---- .../interfaces/Workers/WorkerSnippetTabs.tsx | 69 +++-- .../interfaces/Workers/Workers.constants.ts | 59 +++- .../interfaces/Workers/Workers.utils.test.ts | 16 +- .../interfaces/Workers/Workers.utils.ts | 9 +- .../interfaces/Workers/workerSnippets.test.ts | 18 +- .../interfaces/Workers/workerSnippets.ts | 43 +-- .../pages/project/[ref]/workers/index.tsx | 2 +- 8 files changed, 379 insertions(+), 108 deletions(-) diff --git a/apps/studio/components/interfaces/Workers/DeployWorkerDialog.tsx b/apps/studio/components/interfaces/Workers/DeployWorkerDialog.tsx index 5c90e6a8f282d..66e91a9e00b14 100644 --- a/apps/studio/components/interfaces/Workers/DeployWorkerDialog.tsx +++ b/apps/studio/components/interfaces/Workers/DeployWorkerDialog.tsx @@ -1,67 +1,238 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { useForm, useWatch } from 'react-hook-form' import { + Button, Dialog, DialogContent, + DialogFooter, DialogHeader, DialogSection, DialogSectionSeparator, DialogTitle, + Form, + FormControl, + FormField, + Input, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, } from 'ui' +import { Admonition } from 'ui-patterns/Admonition' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import * as z from 'zod' -import { EXAMPLE_WORKER } from './workerSnippets' +import { RuntimeBadge } from './RuntimeBadge' +import { WORKER_DEPLOYABLE_RUNTIMES, WORKER_SIZES, WORKERS_REGION } from './Workers.constants' +import type { WorkerAccess } from './Workers.types' +import { formatSize, generateWorkerName } from './Workers.utils' import { WorkerSnippetTabs } from './WorkerSnippetTabs' +const FORM_ID = 'deploy-worker-form' + +const DeployWorkerFormSchema = z.object({ + name: z + .string() + .trim() + .min(1, 'Name is required') + .regex(/^[a-z0-9-]+$/, 'Lowercase letters, numbers, and hyphens only'), + runtime: z.enum(WORKER_DEPLOYABLE_RUNTIMES), + size: z.enum(WORKER_SIZES), + access: z.enum(['private', 'public']), + instances: z + .union([z.literal(''), z.coerce.number().int().gte(1).lte(10)]) + .refine((value) => value !== '', 'Instances is required'), +}) + +type DeployWorkerFormValues = z.infer + +const DEFAULT_VALUES: DeployWorkerFormValues = { + name: '', + runtime: 'deno', + size: WORKER_SIZES[0], + access: 'private', + instances: 1, +} + +const ACCESS_OPTIONS: { value: WorkerAccess; label: string }[] = [ + { value: 'private', label: 'Private' }, + { value: 'public', label: 'Public' }, +] + interface DeployWorkerDialogProps { open: boolean onOpenChange: (open: boolean) => void } -const STEPS = [ - { - title: 'Scaffold the worker', - description: 'Creates supabase/workers// with an entrypoint for the runtime you pick.', - }, - { - title: 'Configure it', - description: 'Runtime, size, access, and instance count live in supabase/config.toml.', - }, - { - title: 'Push it', - description: 'Builds the image and schedules it on a microVM in US West.', - }, -] +export const DeployWorkerDialog = ({ open, onOpenChange }: DeployWorkerDialogProps) => { + const form = useForm({ + mode: 'onBlur', + resolver: zodResolver(DeployWorkerFormSchema), + defaultValues: { ...DEFAULT_VALUES, name: generateWorkerName() }, + }) + + const [name, runtime, size, access, instances] = useWatch({ + control: form.control, + name: ['name', 'runtime', 'size', 'access', 'instances'], + }) + + return ( + + + + Deploy a worker + + + -export const DeployWorkerDialog = ({ open, onOpenChange }: DeployWorkerDialogProps) => ( - - - - Deploy a worker - - - -

- Workers are deployed with the Supabase CLI. This dashboard is read-only during the private - alpha. -

-
    - {STEPS.map((step, index) => ( -
  1. - - {index + 1} - -
    -

    {step.title}

    -

    {step.description}

    -
    -
  2. - ))} -
-
- - - - - - -
-
-) + + + + + +
+ + ( + + + + + + )} + /> + + ( + + + + )} + /> + + ( + + + + )} + /> + + ( + + + + )} + /> + + ( + + + + field.onChange( + Number.isNaN(e.target.valueAsNumber) ? '' : e.target.valueAsNumber + ) + } + /> + + + )} + /> + + +
+ + + + + + + + + + +
+
+ ) +} diff --git a/apps/studio/components/interfaces/Workers/WorkerSnippetTabs.tsx b/apps/studio/components/interfaces/Workers/WorkerSnippetTabs.tsx index 151050e8fd419..08f181338de2a 100644 --- a/apps/studio/components/interfaces/Workers/WorkerSnippetTabs.tsx +++ b/apps/studio/components/interfaces/Workers/WorkerSnippetTabs.tsx @@ -1,4 +1,5 @@ import { useParams } from 'common' +import { FileCode, Sparkles, Terminal, type LucideIcon } from 'lucide-react' import { useState } from 'react' import { cn } from 'ui' @@ -6,27 +7,31 @@ import { buildWorkerSnippets, type WorkerSnippetInput } from './workerSnippets' import CopyButton from '@/components/ui/CopyButton' import { useProjectSettingsV2Query } from '@/data/config/project-settings-v2-query' -export type WorkerSnippetTab = 'config' | 'cli' | 'curl' | 'js' | 'python' +export type WorkerSnippetTab = 'ai' | 'config' | 'cli' | 'js' | 'python' const TAB_LABEL: Record = { + ai: 'AI Prompt', config: 'config.toml', cli: 'CLI', - curl: 'cURL', js: 'JavaScript', python: 'Python', } +const TAB_ICON: Record = { + ai: Sparkles, + config: FileCode, + cli: Terminal, + js: FileCode, + python: FileCode, +} + interface WorkerSnippetTabsProps { input: Omit tabs?: [WorkerSnippetTab, ...WorkerSnippetTab[]] className?: string } -export const WorkerSnippetTabs = ({ - input, - tabs = ['cli', 'curl'], - className, -}: WorkerSnippetTabsProps) => { +export const WorkerSnippetTabs = ({ input, tabs = ['cli'], className }: WorkerSnippetTabsProps) => { const { ref } = useParams() const [active, setActive] = useState(tabs[0]) @@ -37,9 +42,9 @@ export const WorkerSnippetTabs = ({ protocol: settings?.app_config?.protocol, }) const snippetByTab: Record = { + ai: snippets.aiPrompt, config: snippets.configToml, cli: snippets.cli, - curl: snippets.curl, js: snippets.javascript, python: snippets.python, } @@ -49,22 +54,26 @@ export const WorkerSnippetTabs = ({ return (
- {tabs.map((tab) => ( - - ))} + {tabs.map((tab) => { + const Icon = TAB_ICON[tab] + return ( + + ) + })}
@@ -75,9 +84,15 @@ export const WorkerSnippetTabs = ({ aria-label="Copy snippet" className="absolute right-2 top-2 text-foreground-lighter hover:text-foreground" /> -
-          {value}
-        
+ {activeTab === 'ai' ? ( +

+ {value} +

+ ) : ( +
+            {value}
+          
+ )}
) diff --git a/apps/studio/components/interfaces/Workers/Workers.constants.ts b/apps/studio/components/interfaces/Workers/Workers.constants.ts index 7c3b911d0f15d..f61b6e3e5a390 100644 --- a/apps/studio/components/interfaces/Workers/Workers.constants.ts +++ b/apps/studio/components/interfaces/Workers/Workers.constants.ts @@ -5,7 +5,62 @@ export const WORKERS_REGION = 'us-west-2' export const WORKERS_REGION_LABEL = 'US West (Oregon)' export const WORKERS_REGION_SHORT = 'US West' -// Workers answer on the project's own domain, alongside /functions/v1. +// Sizes are fixed at deploy time — matches the `size` values the API accepts. +export const WORKER_SIZES = ['2gb-1vcpu', '4gb-2vcpu'] as const + +export const WORKER_NAME_WORDS = [ + 'swift', + 'nimble', + 'brisk', + 'bold', + 'sleek', + 'vivid', + 'lucid', + 'crisp', + 'keen', + 'agile', + 'fierce', + 'bright', + 'orchid', + 'lotus', + 'iris', + 'dahlia', + 'tulip', + 'jasmine', + 'magnolia', + 'azalea', + 'poppy', + 'marigold', + 'hibiscus', + 'camellia', + 'synergy', + 'bloom', + 'scalable', + 'pivot', + 'paradigm', + 'holistic', + 'leverage', + 'stream', + 'viral', + 'turnkey', + 'quantum', + 'photon', + 'nebula', + 'comet', + 'orbit', + 'cosmos', + 'meteor', + 'pulsar', + 'quasar', + 'vector', + 'cipher', + 'nova', + 'zenith', + 'warp', + 'rocket', + 'stellar', +] as const + export const workerUrl = ({ endpoint, protocol = 'https', @@ -59,6 +114,8 @@ export const RUNTIMES: Record = { }, } +export const WORKER_DEPLOYABLE_RUNTIMES = ['node', 'deno', 'dockerfile'] as const + interface WorkerStateMeta { label: string dotClassName: string diff --git a/apps/studio/components/interfaces/Workers/Workers.utils.test.ts b/apps/studio/components/interfaces/Workers/Workers.utils.test.ts index 275f0763f5c04..da791ba034949 100644 --- a/apps/studio/components/interfaces/Workers/Workers.utils.test.ts +++ b/apps/studio/components/interfaces/Workers/Workers.utils.test.ts @@ -1,12 +1,13 @@ import { describe, expect, it } from 'vitest' -import { getWorkerStateMeta, workerUrl } from './Workers.constants' +import { getWorkerStateMeta, WORKER_NAME_WORDS, workerUrl } from './Workers.constants' import type { Worker } from './Workers.types' import { filterWorkers, formatResources, formatRuntime, formatSize, + generateWorkerName, getPage, isWorkersForbidden, isWorkersUnavailable, @@ -163,6 +164,19 @@ describe('workerUrl', () => { }) }) +describe('generateWorkerName', () => { + it('produces a name that already passes the CLI naming rules', () => { + const name = generateWorkerName() + expect(name).toMatch(/^worker-[a-z]+-\d{6}$/) + expect(WORKER_NAME_WORDS).toContain(name.split('-')[1]) + }) + + it('varies across calls', () => { + const names = new Set(Array.from({ length: 20 }, () => generateWorkerName())) + expect(names.size).toBeGreaterThan(1) + }) +}) + describe('getWorkerStateMeta', () => { it('labels every build state', () => { expect(getWorkerStateMeta(worker({ name: 'a', buildState: 'building' })).label).toBe('Building') diff --git a/apps/studio/components/interfaces/Workers/Workers.utils.ts b/apps/studio/components/interfaces/Workers/Workers.utils.ts index d0f32d15edf26..ddbcae1ee2094 100644 --- a/apps/studio/components/interfaces/Workers/Workers.utils.ts +++ b/apps/studio/components/interfaces/Workers/Workers.utils.ts @@ -1,4 +1,4 @@ -import { RUNTIMES, type RuntimeMeta } from './Workers.constants' +import { RUNTIMES, WORKER_NAME_WORDS, type RuntimeMeta } from './Workers.constants' import type { Worker, WorkerAccess, WorkerBuildState } from './Workers.types' import { ResponseError } from '@/types' @@ -54,6 +54,13 @@ export const formatSize = (size: string): string => { export const formatResources = (worker: Worker): string => `${formatSize(worker.size)} · ${worker.declaredInstances} inst` +// Suggests a friendly, already-valid starting name so the deploy dialog isn't blank. +export const generateWorkerName = (): string => { + const word = WORKER_NAME_WORDS[Math.floor(Math.random() * WORKER_NAME_WORDS.length)] + const number = Math.floor(Math.random() * 900000) + 100000 + return `worker-${word}-${number}` +} + // A project outside the alpha allow-list gets a 404, not a 403. export const isWorkersUnavailable = (error: Error | null): boolean => error instanceof ResponseError && error.code === 404 diff --git a/apps/studio/components/interfaces/Workers/workerSnippets.test.ts b/apps/studio/components/interfaces/Workers/workerSnippets.test.ts index 283d93e8bd707..7070d5f014db6 100644 --- a/apps/studio/components/interfaces/Workers/workerSnippets.test.ts +++ b/apps/studio/components/interfaces/Workers/workerSnippets.test.ts @@ -10,25 +10,21 @@ const input = (overrides: Partial[0]> = { }) describe('buildWorkerSnippets', () => { - it('points every call snippet at the worker URL', () => { - const { curl, javascript, python } = buildWorkerSnippets(input({ name: 'embed' })) + it('points the invoke examples at the worker URL', () => { + const { javascript, python } = buildWorkerSnippets(input({ name: 'embed' })) const url = 'https://abcdefgh.supabase.co/workers/v1/embed' - expect(curl).toContain(`'${url}'`) expect(javascript).toContain(`'${url}'`) expect(python).toContain(`"${url}"`) }) - it('leaves a placeholder URL until the project settings resolve', () => { - const { curl } = buildWorkerSnippets(input({ endpoint: undefined })) - expect(curl).toContain('[YOUR WORKER URL]') + it('leaves a placeholder invoke URL until the project settings resolve', () => { + const { javascript } = buildWorkerSnippets(input({ endpoint: undefined })) + expect(javascript).toContain('[YOUR WORKER URL]') }) - it('asks for the anon key on a public worker and the service role key on a private one', () => { - expect(buildWorkerSnippets(input({ access: 'public' })).curl).toContain('[YOUR ANON KEY]') - expect(buildWorkerSnippets(input({ access: 'private' })).curl).toContain( - '[YOUR SERVICE ROLE KEY]' - ) + it('asks for the anon key to invoke a public worker and the service role key for a private one', () => { + expect(buildWorkerSnippets(input({ access: 'public' })).javascript).toContain('[YOUR ANON KEY]') expect(buildWorkerSnippets(input({ access: 'private' })).javascript).toContain( '[YOUR SERVICE ROLE KEY]' ) diff --git a/apps/studio/components/interfaces/Workers/workerSnippets.ts b/apps/studio/components/interfaces/Workers/workerSnippets.ts index cbcc6e2beb7b9..02499a1fcb0c3 100644 --- a/apps/studio/components/interfaces/Workers/workerSnippets.ts +++ b/apps/studio/components/interfaces/Workers/workerSnippets.ts @@ -1,4 +1,4 @@ -import { WORKERS_REGION, workerUrl } from './Workers.constants' +import { RUNTIMES, WORKERS_REGION, workerUrl } from './Workers.constants' import type { WorkerAccess } from './Workers.types' import { formatSize } from './Workers.utils' import { CLI_NAME } from '@/lib/constants/workers' @@ -14,9 +14,9 @@ export interface WorkerSnippetInput { } export interface WorkerSnippets { + aiPrompt: string configToml: string cli: string - curl: string javascript: string python: string } @@ -40,12 +40,13 @@ export function buildWorkerSnippets(input: WorkerSnippetInput): WorkerSnippets { const cli = [ `supabase ${CLI_NAME} new ${name} --runtime ${runtime}`, - `supabase ${CLI_NAME} push ${name}`, + // size comes from config.toml — push has no flag for it. Same for access: the CLI + // doesn't have a route to a private worker yet, so this always deploys as public. + `supabase ${CLI_NAME} push ${name} --instances ${input.instances}`, + ...(input.access === 'private' ? [`# note: the CLI can only deploy public workers today`] : []), ].join('\n') - const configToml = [ - `# supabase/config.toml`, - ``, + const configBlock = [ `[${CLI_NAME}.${name}]`, `runtime = "${runtime}"`, `size = "${input.size}" # ${formatSize(input.size)}`, @@ -54,16 +55,26 @@ export function buildWorkerSnippets(input: WorkerSnippetInput): WorkerSnippets { `# region is locked to ${WORKERS_REGION} at alpha`, ].join('\n') - const authHeader = - input.access === 'public' - ? ` -H 'Authorization: Bearer [YOUR ANON KEY]' \\` - : ` -H 'Authorization: Bearer [YOUR SERVICE ROLE KEY]' \\` + const configToml = [`# supabase/config.toml`, ``, configBlock].join('\n') + + const runtimeMeta = RUNTIMES[runtime] ?? RUNTIMES.node + // The entrypoint metadata is " " — the filename is the last token. + const entrypointFile = runtimeMeta.entrypoint.split(' ').pop() - const curl = [ - `curl -L -X POST '${url}' \\`, - authHeader, - ` -H 'Content-Type: application/json' \\`, - ` --data '{"name":"world"}'`, + const aiPrompt = [ + `Scaffold and deploy a Supabase Workers worker named "${name}" using the ${runtimeMeta.label} runtime:`, + ``, + `1. Create a supabase/${CLI_NAME}/${name}/ directory with a ${runtimeMeta.label} entrypoint (${entrypointFile}) that responds with "Hello, world!".`, + ``, + `2. Add this block to supabase/config.toml:`, + '```toml', + configBlock, + '```', + ``, + `3. Run \`supabase ${CLI_NAME} push ${name}\` to deploy it.`, + ...(input.access === 'private' + ? [``, `Note: the CLI can only deploy public workers today.`] + : []), ].join('\n') const keyPlaceholder = input.access === 'public' ? '[YOUR ANON KEY]' : '[YOUR SERVICE ROLE KEY]' @@ -91,7 +102,7 @@ export function buildWorkerSnippets(input: WorkerSnippetInput): WorkerSnippets { `print(res.json())`, ].join('\n') - return { configToml, cli, curl, javascript, python } + return { aiPrompt, configToml, cli, javascript, python } } export interface WorkerCliCommand { diff --git a/apps/studio/pages/project/[ref]/workers/index.tsx b/apps/studio/pages/project/[ref]/workers/index.tsx index af939bb920928..73debf88890ce 100644 --- a/apps/studio/pages/project/[ref]/workers/index.tsx +++ b/apps/studio/pages/project/[ref]/workers/index.tsx @@ -70,7 +70,7 @@ const WorkersPage: NextPageWithLayout = () => { )} {isMissingPermission && } From 57a6f74407776fdd83584e0b975edd8472e80da1 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Thu, 27 Aug 2026 10:34:10 +0200 Subject: [PATCH 6/7] Revert "fix: FormItemLayout does not apply item id correctly" (#49635) Reverts supabase/supabase#49593 because we currently provide `id` manually in some places and that breaks many tests. We didn't see the failures because the PR only modified `ui-patterns` which isn't in the paths checked to actually run the tests (this must be fixed too). ## Summary by CodeRabbit * **Bug Fixes** * Improved form accessibility by correctly associating labels with their corresponding fields in React form layouts. --- packages/ui-patterns/src/form/Layout/FormLayout.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ui-patterns/src/form/Layout/FormLayout.tsx b/packages/ui-patterns/src/form/Layout/FormLayout.tsx index e944eab71a5af..37f67aa61a314 100644 --- a/packages/ui-patterns/src/form/Layout/FormLayout.tsx +++ b/packages/ui-patterns/src/form/Layout/FormLayout.tsx @@ -398,6 +398,7 @@ export const FormLayout = React.forwardRef< From 10950d286b8f6869b8c0a5b979f464340ab8ea6e Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Thu, 27 Aug 2026 08:52:52 +0000 Subject: [PATCH 7/7] chore(studio): address review comments on Multigres topology diagram (#49592) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _Requested by **Alaister Young** · [Slack thread](https://supabase.slack.com/archives/C0161K73J1J/p1787738517181409?thread_ts=1787635785.354489&cid=C0161K73J1J)_ Follow-up to #49298, which was squash-merged before @joshenlim's last review round was addressed. Picking up the review comments here. ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Chore — dead code removal and comment corrections. No behavior change. ## What is the current behavior? Three of @joshenlim's review comments on #49298 are still open on master: - [Dead code in `ha-cluster-cells-query.ts`](https://github.com/supabase/supabase/pull/49298#discussion_r3861029414) — "seems to be dead code? no one's consuming this file" - [Dead code in `ha-cluster-databases-query.ts`](https://github.com/supabase/supabase/pull/49298#discussion_r3861031230) — "likewise - seems to be dead code" - [`STATUS_BADGE_VARIANTS` statuses](https://github.com/supabase/supabase/pull/49298#discussion_r3861095281) — "just to sanity check these are the only statuses? are there any failure states? e.g 'Failed'" Concretely, on master today: - `apps/studio/data/ha-admin/ha-cluster-cells-query.ts` and `apps/studio/data/ha-admin/ha-cluster-databases-query.ts` ship query options that nothing imports. The diagram only reads `/poolers` and `/gateways`. - `multipoolerSchema.lifecycleStatus` is an undocumented `z.string()`, while its neighbours `type` and `servingStatus` both list their expected proto values in a comment. - The comment above `HA_POOLER_STATUS_LABELS` claims the labels "Matches the status vocabulary of the read replica surfaces (getStatusLabel)". They don't — `getStatusLabel` in `ReadReplicas/ReadReplicas.utils.ts` also returns `Failed`, `Restarting`, `Resizing` and `Restoring`, none of which the HA labels have. ## What is the new behavior? - Deleted both dead query modules and pruned the orphaned `cells` and `databases` factories from `haAdminKeys`, keeping `poolers` and `gateways`. Verified by grep that neither file name nor any of their exported symbols (`haClusterCellsQueryOptions`, `HaClusterCellsData`, `haClusterDatabasesQueryOptions`, `HaClusterDatabasesData`, the `*Variables`/`*Error` types) nor `haAdminKeys.cells` / `haAdminKeys.databases` has a single reference left anywhere outside the deleted files. No re-export shims left behind. `get-ha-admin.ts` stays — `poolers` and `gateways` still use it. - Documented `lifecycleStatus` against the actual enum, `PoolerLifecycleStatus` in [multigres `proto/clustermetadata.proto`](https://github.com/multigres/multigres/blob/main/proto/clustermetadata.proto): `LIFECYCLE_UNKNOWN` (zero value, omitted from JSON) | `STARTING` | `ACTIVE` | `STOPPING` | `SHUTDOWN` | `QUARANTINED`. `getPoolerStatus` already maps every member. - Reworded the `HA_POOLER_STATUS_LABELS` comment to say the labels are a subset drawn from the read replica vocabulary rather than a match for it, and noted where the read replica `Failed` lands on the HA side. **On the `Failed` question:** the answer from the proto is that there is no dedicated failure member. The terminal states are `QUARANTINED` — the pooler "has given up trying to become a healthy replica: it cannot automatically recover to a functioning state (e.g. it could not complete a pg_rewind, could not restore from backup to start postgres, or fell irrecoverably behind on replication)", kept alive for forensics — and `SHUTDOWN`, "durably down". Both already map to `unhealthy` / the `Unhealthy` warning badge, so the four statuses on `STATUS_BADGE_VARIANTS` are complete for the enum as it stands. If we'd rather show `QUARANTINED` as its own `Failed` status with a destructive badge (matching the read replica surface), that's a small follow-up — a product/copy call rather than a gap, so not folded in here. **Not included: [the replication page UX comment](https://github.com/supabase/supabase/pull/49298#discussion_r3861055216)** ("is there any other content we plan to add here? it feels empty atm... it's just a repeat of the home page + settings/infrastructure"). @joshenlim flagged that one himself as "UX feedback which can be addressed separately". It's a product and IA question about what that page is for, not something to answer with a code change here — leaving it for @alaister and design. ## Additional context - Verified locally: `tsc --noEmit` (0 errors), ESLint on the touched files (clean), Prettier check (clean), and `HaTopology.utils.test.ts` + `HaInstanceConfiguration.utils.test.ts` (26/26 passing). CI is green as well. - Exhaustive grep across the repo (excluding `node_modules`/`.git`/build output, covering `apps/**` incl. `lite-studio`, `packages/**` and `e2e/**`) confirmed zero remaining references to the deleted files, their exported symbols, and the removed key factories. - No test changes: the diff deletes unreferenced code and edits comments only, so there's no new behavior to cover. `HaTopology.utils.test.ts` already pins every `lifecycleStatus` value listed in the new comment. --- _Generated by [Claude Code](https://claude.ai/code/session_012StGVQSmPzpGTXrduo9Xyi)_ --------- Co-authored-by: Claude Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com> --- .../HaTopology.utils.ts | 9 +++++- .../data/ha-admin/ha-cluster-cells-query.ts | 27 ----------------- .../ha-admin/ha-cluster-databases-query.ts | 30 ------------------- .../data/ha-admin/ha-cluster-poolers-query.ts | 9 ++++++ apps/studio/data/ha-admin/keys.ts | 3 -- 5 files changed, 17 insertions(+), 61 deletions(-) delete mode 100644 apps/studio/data/ha-admin/ha-cluster-cells-query.ts delete mode 100644 apps/studio/data/ha-admin/ha-cluster-databases-query.ts diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/HaTopology.utils.ts b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/HaTopology.utils.ts index 1624caf53b2cb..8c1867d6105db 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/HaTopology.utils.ts +++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/HaTopology.utils.ts @@ -54,6 +54,8 @@ export const getPoolerStatus = (pooler: Multipooler): HaPoolerStatus => { // Lifecycle values may arrive with or without the proto enum prefix. const lifecycle = (pooler.lifecycleStatus?.status ?? '').replace(/^LIFECYCLE_/, '') + // The proto's two terminal states: QUARANTINED (gave up recovering, kept alive + // for forensics) and SHUTDOWN (durably down). There is no separate FAILED state. if (lifecycle === 'QUARANTINED' || lifecycle === 'SHUTDOWN') return 'unhealthy' if (lifecycle === 'STARTING') return 'coming_up' if (lifecycle === 'STOPPING') return 'going_down' @@ -66,7 +68,12 @@ export const getPoolerStatus = (pooler: Multipooler): HaPoolerStatus => { return 'healthy' } -// Matches the status vocabulary of the read replica surfaces (getStatusLabel). +// Labels are drawn from the read replica status vocabulary (`getStatusLabel` in +// ReadReplicas.utils.ts) so both surfaces read the same way, but they are only a +// subset of it. The read replica labels also cover 'Failed', 'Restarting', +// 'Resizing' and 'Restoring', which have no one-to-one lifecycle equivalents; +// the closest to 'Failed' is QUARANTINED, surfaced as 'Unhealthy' alongside +// SHUTDOWN. export const HA_POOLER_STATUS_LABELS: Record = { healthy: 'Healthy', coming_up: 'Coming up', diff --git a/apps/studio/data/ha-admin/ha-cluster-cells-query.ts b/apps/studio/data/ha-admin/ha-cluster-cells-query.ts deleted file mode 100644 index d5571f502c348..0000000000000 --- a/apps/studio/data/ha-admin/ha-cluster-cells-query.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { queryOptions } from '@tanstack/react-query' -import { z } from 'zod' - -import { getHaAdmin, parseHaAdminResponse } from './get-ha-admin' -import { haAdminKeys } from './keys' -import { IS_PLATFORM } from '@/lib/constants' -import type { ResponseError } from '@/types' - -export type HaClusterCellsVariables = { projectRef?: string } -export type HaClusterCellsError = ResponseError - -// Every field is optional because proto3 JSON omits zero values. -const haClusterCellsResponseSchema = z.object({ names: z.array(z.string()).optional() }) - -async function getHaClusterCells({ projectRef }: HaClusterCellsVariables, signal?: AbortSignal) { - const data = await getHaAdmin(projectRef, 'cells', signal) - return parseHaAdminResponse(haClusterCellsResponseSchema, data) -} - -export type HaClusterCellsData = Awaited> - -export const haClusterCellsQueryOptions = ({ projectRef }: HaClusterCellsVariables) => - queryOptions({ - queryKey: haAdminKeys.cells(projectRef), - queryFn: ({ signal }) => getHaClusterCells({ projectRef }, signal), - enabled: IS_PLATFORM && typeof projectRef !== 'undefined', - }) diff --git a/apps/studio/data/ha-admin/ha-cluster-databases-query.ts b/apps/studio/data/ha-admin/ha-cluster-databases-query.ts deleted file mode 100644 index 15cfd3adfb13b..0000000000000 --- a/apps/studio/data/ha-admin/ha-cluster-databases-query.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { queryOptions } from '@tanstack/react-query' -import { z } from 'zod' - -import { getHaAdmin, parseHaAdminResponse } from './get-ha-admin' -import { haAdminKeys } from './keys' -import { IS_PLATFORM } from '@/lib/constants' -import type { ResponseError } from '@/types' - -export type HaClusterDatabasesVariables = { projectRef?: string } -export type HaClusterDatabasesError = ResponseError - -// Every field is optional because proto3 JSON omits zero values. -const haClusterDatabasesResponseSchema = z.object({ names: z.array(z.string()).optional() }) - -async function getHaClusterDatabases( - { projectRef }: HaClusterDatabasesVariables, - signal?: AbortSignal -) { - const data = await getHaAdmin(projectRef, 'databases', signal) - return parseHaAdminResponse(haClusterDatabasesResponseSchema, data) -} - -export type HaClusterDatabasesData = Awaited> - -export const haClusterDatabasesQueryOptions = ({ projectRef }: HaClusterDatabasesVariables) => - queryOptions({ - queryKey: haAdminKeys.databases(projectRef), - queryFn: ({ signal }) => getHaClusterDatabases({ projectRef }, signal), - enabled: IS_PLATFORM && typeof projectRef !== 'undefined', - }) diff --git a/apps/studio/data/ha-admin/ha-cluster-poolers-query.ts b/apps/studio/data/ha-admin/ha-cluster-poolers-query.ts index b88b12cbc1431..bc6a23c9d8200 100644 --- a/apps/studio/data/ha-admin/ha-cluster-poolers-query.ts +++ b/apps/studio/data/ha-admin/ha-cluster-poolers-query.ts @@ -28,6 +28,15 @@ const multipoolerSchema = z.object({ // 'SERVING' | 'DISABLED' | 'DRAINING' servingStatus: z.string().optional(), hostname: z.string().optional(), + // `PoolerLifecycleStatus` (multigres proto/clustermetadata.proto): + // 'STARTING' | 'ACTIVE' | 'STOPPING' | 'SHUTDOWN' | 'QUARANTINED', optionally + // prefixed with 'LIFECYCLE_'. 'LIFECYCLE_UNKNOWN' is the zero value, so it is + // omitted from JSON. There is no dedicated failure member — QUARANTINED is the + // terminal failure state (the pooler gave up recovering, e.g. a failed + // pg_rewind or backup restore, and is kept alive for forensics) and SHUTDOWN is + // "durably down". `getPoolerStatus` maps every member. An unrecognized value + // (a future enum member) falls through to the `servingStatus` check, and since + // SERVING is that enum's zero value it usually renders as Healthy until mapped. lifecycleStatus: z.object({ status: z.string().optional() }).optional(), routingState: z .object({ diff --git a/apps/studio/data/ha-admin/keys.ts b/apps/studio/data/ha-admin/keys.ts index 3193018650a30..d07096e90a580 100644 --- a/apps/studio/data/ha-admin/keys.ts +++ b/apps/studio/data/ha-admin/keys.ts @@ -1,7 +1,4 @@ export const haAdminKeys = { - cells: (projectRef: string | undefined) => ['projects', projectRef, 'ha-admin', 'cells'] as const, - databases: (projectRef: string | undefined) => - ['projects', projectRef, 'ha-admin', 'databases'] as const, poolers: (projectRef: string | undefined) => ['projects', projectRef, 'ha-admin', 'poolers'] as const, gateways: (projectRef: string | undefined) =>