Merge branch 'master' into qiao/table

This commit is contained in:
Han Qiao authored and GitHub committed 2025-05-15 13:01:44 +08:00
commit dd6bc61ae6
80 files changed
+1419 -2132

No files matched your search

@@ -81,7 +81,7 @@ Used for actions that might have a side effect, but not as serious as a destruct
#### Destructive (currently `danger`)
Used for actions that will have a serios destructive side effect, like deleting data.
Used for actions that will have a serious destructive side effect, like deleting data.
prop `type` will probably be changed to `destructive` in the future.
@@ -125,7 +125,7 @@ Use the `actions` prop to add any actions like a `<Button />`.
/>
```
You can also use the prop `illustration` to pass in a custom illustration, it accets any React Node.
You can also use the prop `illustration` to pass in a custom illustration, it accepts any React Node.
```jsx {4-7}
<InnerSideBarEmptyPanel
+4 -2
View File
@@ -1,7 +1,7 @@
import { IS_PLATFORM } from 'common'
import { unstable_cache } from 'next/cache'
import { type NavMenuSection } from '~/components/Navigation/Navigation.types'
import { REVALIDATION_TAGS } from '~/features/helpers.fetch'
import Layout from '~/layouts/guides'
import { supabaseMisc } from '~/lib/supabaseMisc'
@@ -13,7 +13,9 @@ export default async function IntegrationsLayout({ children }: { children: React
// Will need to turn on revalidation later, just turning it off for now so we
// can slowly turn things back on while monitoring usage
const getPartners = unstable_cache(getPartnersImpl, [], { revalidate: false })
const getPartners = unstable_cache(getPartnersImpl, [], {
tags: [REVALIDATION_TAGS.PARTNERS],
})
async function getPartnersImpl() {
if (!IS_PLATFORM) return []
@@ -30,6 +30,8 @@ const getLinearTeam = (pathname: string) => {
return 'Dev Workflows'
case 'integrations':
return 'API'
case 'security':
return 'Security'
case 'platform':
case 'monitoring-troubleshooting':
return 'Infra'
@@ -368,7 +368,7 @@ When developing with Expo, you can test Sign in with Apple via the Expo Go app,
) {
install(GoTrue)
install(ComposeAuth) {
nativeAppleLogin()
appleNativeLogin()
}
}
```
+2 -1
View File
@@ -10,6 +10,7 @@ import { ONE_DAY_IN_SECONDS } from './helpers.time'
export const REVALIDATION_TAGS = {
GRAPHQL: 'graphql',
PARTNERS: 'partners',
WRAPPERS: 'wrappers',
} as const
// Casting to avoid problems with using this as a Zod enum, TypeScript does
@@ -33,4 +34,4 @@ const fetchRevalidatePerDay = fetchWithNextOptions({
next: { revalidate: ONE_DAY_IN_SECONDS },
})
export { fetchWithNextOptions, fetchRevalidatePerDay }
export { fetchRevalidatePerDay, fetchWithNextOptions }
@@ -5,7 +5,6 @@ import { useEffect } from 'react'
import { LOCAL_STORAGE_KEYS, useParams } from 'common'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { useFlag } from 'hooks/ui/useFlag'
import { IS_PLATFORM } from 'lib/constants'
import { useAppStateSnapshot } from 'state/app-state'
import { removeTabsByEditor } from 'state/tabs'
@@ -48,11 +47,11 @@ const FeaturePreviewModal = () => {
groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' },
})
if (selectedFeatureKey === LOCAL_STORAGE_KEYS.UI_TABLE_EDITOR_TABS) {
removeTabsByEditor(ref as string | undefined, 'table')
if (ref && selectedFeatureKey === LOCAL_STORAGE_KEYS.UI_TABLE_EDITOR_TABS) {
removeTabsByEditor(ref, 'table')
}
if (selectedFeatureKey === LOCAL_STORAGE_KEYS.UI_SQL_EDITOR_TABS) {
removeTabsByEditor(ref as string | undefined, 'sql')
if (ref && selectedFeatureKey === LOCAL_STORAGE_KEYS.UI_SQL_EDITOR_TABS) {
removeTabsByEditor(ref, 'sql')
}
}
@@ -5,7 +5,9 @@ import { Info } from 'lucide-react'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import AlertError from 'components/ui/AlertError'
import Panel from 'components/ui/Panel'
import { useProjectPostgrestConfigQuery } from 'data/config/project-postgrest-config-query'
import { useDatabasePoliciesQuery } from 'data/database-policies/database-policies-query'
import { useTableRolesAccessQuery } from 'data/tables/table-roles-access-query'
import { cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
import PolicyRow from './PolicyRow'
@@ -39,6 +41,30 @@ export const PolicyTableRow = ({
onSelectDeletePolicy = noop,
}: PolicyTableRowProps) => {
const { project } = useProjectContext()
// [Joshen] Changes here are so that warnings are more accurate and granular instead of purely relying if RLS is disabled or enabled
// The following scenarios are technically okay if the table has RLS disabled, in which it won't be publicly readable / writable
// - If the schema is not exposed through the API via Postgrest
// - If the anon and authenticated roles do not have access to the table
// Ideally we should just rely on the security lints as the source of truth, but the security lints currently have limitations
// - They only consider the public schema
// - They do not consider roles
// Eventually if the security lints are able to cover those, we can look to using them as the source of truth instead then
const { data: config } = useProjectPostgrestConfigQuery({ projectRef: project?.ref })
const exposedSchemas = config?.db_schema ? config?.db_schema.replace(/ /g, '').split(',') : []
const isRLSEnabled = table.rls_enabled
const isTableExposedThroughAPI = exposedSchemas.includes(table.schema)
const { data: roles = [] } = useTableRolesAccessQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
schema: table.schema,
table: table.name,
})
const hasAnonAuthenticatedRolesAccess = roles.length !== 0
const isPubliclyReadableWritable =
!isRLSEnabled && isTableExposedThroughAPI && hasAnonAuthenticatedRolesAccess
const { data, error, isLoading, isError, isSuccess } = useDatabasePoliciesQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
@@ -46,6 +72,7 @@ export const PolicyTableRow = ({
const policies = (data ?? [])
.filter((policy) => policy.schema === table.schema && policy.table === table.name)
.sort((a, b) => a.name.localeCompare(b.name))
const rlsEnabledNoPolicies = isRLSEnabled && policies.length === 0
return (
<Panel
@@ -59,27 +86,40 @@ export const PolicyTableRow = ({
/>
}
>
{!table.rls_enabled && !isLocked && (
{(isPubliclyReadableWritable || rlsEnabledNoPolicies) && (
<div
className={cn(
'dark:bg-alternative-200 bg-surface-200 px-6 py-2 text-xs flex items-center gap-2',
policies.length === 0 ? '' : 'border-b'
)}
>
<div className="w-1.5 h-1.5 bg-warning-600 rounded-full" />
<span className="font-bold text-warning-600">Warning:</span>{' '}
<div
className={cn(
'w-1.5 h-1.5 rounded-full bg-warning-600 ',
rlsEnabledNoPolicies && 'bg-selection'
)}
/>
<span
className={cn('font-bold text-warning-600', rlsEnabledNoPolicies && 'text-foreground')}
>
{isPubliclyReadableWritable ? 'Warning' : 'Note'}:
</span>{' '}
<span className="text-foreground-light">
Row Level Security is disabled. Your table is publicly readable and writable.
{isPubliclyReadableWritable
? 'Row Level Security is disabled. Your table is publicly readable and writable.'
: 'Row Level Security is enabled, but no policies exist. No data will be selectable via Supabase APIs.'}
</span>
<Tooltip>
<TooltipTrigger asChild>
<Info className="w-3 h-3" />
</TooltipTrigger>
<TooltipContent className="w-[400px]">
Anyone with the project's anonymous key can modify or delete your data. Enable RLS and
create access policies to keep your data secure.
</TooltipContent>
</Tooltip>
{isPubliclyReadableWritable && (
<Tooltip>
<TooltipTrigger asChild>
<Info className="w-3 h-3" />
</TooltipTrigger>
<TooltipContent className="w-[400px]">
Anyone with the project's anonymous key can modify or delete your data. Enable RLS
and create access policies to keep your data secure.
</TooltipContent>
</Tooltip>
)}
</div>
)}
{isLoading && (
@@ -8,8 +8,10 @@ export const AdditionalMonthlySpend = ({
return (
<div className="text-sm text-foreground-lighter border-t p-5">
<p>
The new project will have the same compute size and disk size as this project. You will be
able to update the compute size and disk size after the new project is created in{' '}
The new project will start with the same compute size as your current project, but the disk
size will be slightly larger (1.25×) to ensure the restore completes successfully. You will
be able to update the compute size and increase the disk size after the new project is
created in{' '}
<span className="font-mono text-xs tracking-tighter text-foreground-light">
Project Settings &gt; Compute and Disk
</span>
@@ -53,7 +53,7 @@ const FormSchema = z.object({
activation: z.string(),
enabled_mode: z.string(),
orientation: z.string(),
function_name: z.string(),
function_name: z.string().min(1, 'Please select a database function for your trigger to call'),
function_schema: z.string(),
events: z.array(z.string()).min(1, 'Please select at least one event'),
@@ -374,46 +374,62 @@ export const TriggerSheet = ({ selectedTrigger, open, setOpen }: TriggerSheetPro
<Separator />
<div className="px-5 flex flex-col gap-y-2">
<p className="text-smn">Function to trigger</p>
{function_name.length === 0 ? (
<button
type="button"
className={cn(
'relative w-full rounded border border-default',
'bg-surface-200 px-5 py-1 shadow-sm transition-all',
'hover:border-strong hover:bg-overlay-hover'
)}
onClick={() => setShowFunctionSelector(true)}
>
<FormBoxEmpty
icon={<Terminal size={14} strokeWidth={2} />}
text="Choose a function to trigger"
/>
</button>
) : (
<div
className={cn(
'relative w-full flex items-center justify-between',
'space-x-3 px-5 py-4 border border-default',
'rounded shadow-sm transition-shadow'
)}
>
<div className="flex items-center gap-2">
<div className="flex h-6 w-6 items-center justify-center rounded bg-foreground text-background focus-within:bg-opacity-10">
<Terminal size="18" strokeWidth={2} width={14} />
<FormField_Shadcn_
name="function_name"
control={form.control}
render={() => (
<FormItemLayout layout="vertical" className="px-5">
<FormControl_Shadcn_>
<div className="flex flex-col gap-y-2">
<p className="text-smn">Function to trigger</p>
{function_name.length === 0 ? (
<button
type="button"
className={cn(
'relative w-full rounded border border-default',
'bg-surface-200 px-5 py-1 shadow-sm transition-all',
'hover:border-strong hover:bg-overlay-hover'
)}
onClick={() => setShowFunctionSelector(true)}
>
<FormBoxEmpty
icon={<Terminal size={14} strokeWidth={2} />}
text="Choose a function to trigger"
/>
</button>
) : (
<div
className={cn(
'relative w-full flex items-center justify-between',
'space-x-3 px-5 py-4 border border-default',
'rounded shadow-sm transition-shadow'
)}
>
<div className="flex items-center gap-2">
<div className="flex h-6 w-6 items-center justify-center rounded bg-foreground text-background focus-within:bg-opacity-10">
<Terminal size="18" strokeWidth={2} width={14} />
</div>
<p>
<span className="text-sm text-foreground-light">
{function_schema}
</span>
.
<span className="text-sm text-foreground">{function_name}</span>
</p>
</div>
<Button
type="default"
onClick={() => setShowFunctionSelector(true)}
>
Change function
</Button>
</div>
)}
</div>
<p>
<span className="text-sm text-foreground-light">{function_schema}</span>
.<span className="text-sm text-foreground">{function_name}</span>
</p>
</div>
<Button type="default" onClick={() => setShowFunctionSelector(true)}>
Change function
</Button>
</div>
</FormControl_Shadcn_>
</FormItemLayout>
)}
</div>
/>
</>
)}
</form>
@@ -4,7 +4,6 @@ import { ResourceItem } from 'components/ui/Resource/ResourceItem'
import { ResourceList } from 'components/ui/Resource/ResourceList'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { useFlag } from 'hooks/ui/useFlag'
import { Code, Terminal } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
@@ -28,7 +27,6 @@ export const FunctionsEmptyState = () => {
const { ref } = useParams()
const router = useRouter()
const aiSnap = useAiAssistantStateSnapshot()
const edgeFunctionCreate = useFlag('edgeFunctionCreate')
const { mutate: sendEvent } = useSendEventMutation()
const org = useSelectedOrganization()
@@ -41,30 +39,28 @@ export const FunctionsEmptyState = () => {
</CardHeader>
<CardContent className="p-0 grid grid-cols-[repeat(auto-fit,minmax(240px,1fr))] divide-y md:divide-y-0 md:divide-x divide-default items-stretch">
{/* Editor Option */}
{edgeFunctionCreate && (
<div className="p-8">
<div className="flex items-center gap-2">
<Code strokeWidth={1.5} size={20} />
<h4 className="text-base text-foreground">Via Editor</h4>
</div>
<p className="text-sm text-foreground-light mb-4 mt-1">
Create and edit functions directly in the browser. Download to local at any time.
</p>
<Button
type="default"
onClick={() => {
router.push(`/project/${ref}/functions/new`)
sendEvent({
action: 'edge_function_via_editor_button_clicked',
properties: { origin: 'no_functions_block' },
groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' },
})
}}
>
Open Editor
</Button>
<div className="p-8">
<div className="flex items-center gap-2">
<Code strokeWidth={1.5} size={20} />
<h4 className="text-base text-foreground">Via Editor</h4>
</div>
)}
<p className="text-sm text-foreground-light mb-4 mt-1">
Create and edit functions directly in the browser. Download to local at any time.
</p>
<Button
type="default"
onClick={() => {
router.push(`/project/${ref}/functions/new`)
sendEvent({
action: 'edge_function_via_editor_button_clicked',
properties: { origin: 'no_functions_block' },
groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' },
})
}}
>
Open Editor
</Button>
</div>
{/* AI Assistant Option */}
<div className="p-8">
@@ -138,33 +134,29 @@ export const FunctionsEmptyState = () => {
</div>
</CardContent>
</Card>
{edgeFunctionCreate && (
<>
<ScaffoldSectionTitle className="text-xl mb-4 mt-12">
Start with a template
</ScaffoldSectionTitle>
<ResourceList>
{EDGE_FUNCTION_TEMPLATES.map((template) => (
<ResourceItem
key={template.name}
media={<Code strokeWidth={1.5} size={16} className="-translate-y-[9px]" />}
onClick={() => {
sendEvent({
action: 'edge_function_template_clicked',
properties: { templateName: template.name, origin: 'functions_page' },
groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' },
})
}}
>
<Link href={`/project/${ref}/functions/new?template=${template.value}`}>
<p>{template.name}</p>
<p className="text-sm text-foreground-lighter">{template.description}</p>
</Link>
</ResourceItem>
))}
</ResourceList>
</>
)}
<ScaffoldSectionTitle className="text-xl mb-4 mt-12">
Start with a template
</ScaffoldSectionTitle>
<ResourceList>
{EDGE_FUNCTION_TEMPLATES.map((template) => (
<ResourceItem
key={template.name}
media={<Code strokeWidth={1.5} size={16} className="-translate-y-[9px]" />}
onClick={() => {
sendEvent({
action: 'edge_function_template_clicked',
properties: { templateName: template.name, origin: 'functions_page' },
groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' },
})
}}
>
<Link href={`/project/${ref}/functions/new?template=${template.value}`}>
<p>{template.name}</p>
<p className="text-sm text-foreground-lighter">{template.description}</p>
</Link>
</ResourceItem>
))}
</ResourceList>
</>
)
}
@@ -143,7 +143,7 @@ export const AdvisorWidget = () => {
<ButtonTooltip
type="text"
className="px-1 opacity-0 group-hover:opacity-100 w-7"
icon={<AiIconAnimation className="w-5 h-5" />}
icon={<AiIconAnimation size={16} />}
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
@@ -19,6 +19,7 @@ import ProjectCard from './ProjectCard'
import ShimmeringCard from './ShimmeringCard'
export interface ProjectListProps {
forOrganization?: Organization
rewriteHref?: (projectRef: string) => string
search?: string
filterStatus?: string[]
@@ -27,12 +28,13 @@ export interface ProjectListProps {
const ProjectList = ({
search = '',
forOrganization,
rewriteHref,
filterStatus,
resetFilterStatus,
}: ProjectListProps) => {
const { slug } = useParams()
const organization = useSelectedOrganization()
const organization = useSelectedOrganization() ?? forOrganization
const {
data: allProjects = [],
@@ -48,7 +50,9 @@ const ProjectList = ({
} = usePermissionsQuery()
const { data: resourceWarnings } = useResourceWarningsQuery()
const orgProjects = allProjects.filter((x) => x.organization_slug === slug)
const orgProjects = allProjects.filter(
(x) => x.organization_slug === slug || x.organization_slug === organization?.slug
)
const isLoadingPermissions = IS_PLATFORM ? _isLoadingPermissions : false
const hasFilterStatusApplied = filterStatus !== undefined && filterStatus.length !== 2
@@ -6,6 +6,7 @@ import { useDebounce } from 'use-debounce'
import { useCompletion } from 'ai/react'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { useCronTimezoneQuery } from 'data/database-cron-jobs/database-cron-timezone-query'
import { constructHeaders } from 'data/fetchers'
import { BASE_PATH } from 'lib/constants'
import {
Accordion_Shadcn_,
@@ -78,7 +79,11 @@ export const CronJobScheduleSection = ({ form, supportsSeconds }: CronJobSchedul
useEffect(() => {
if (useNaturalLanguage && debouncedValue) {
generateCronSyntax(debouncedValue)
constructHeaders().then((headers) =>
generateCronSyntax(debouncedValue, {
headers: { Authorization: headers.get('Authorization') ?? '' },
})
)
return () => stop()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -12,12 +12,10 @@ import { FormSection, FormSectionContent } from 'components/ui/Forms/FormSection
import NoPermission from 'components/ui/NoPermission'
import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query'
import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions'
import { useFlag } from 'hooks/ui/useFlag'
import { CreditTopUp } from './CreditTopUp'
const CreditBalance = () => {
const { slug } = useParams()
const creditTopUpEnabled = useFlag('creditTopUp')
const { isSuccess: isPermissionsLoaded, can: canReadSubscriptions } =
useAsyncCheckProjectPermissions(PermissionAction.BILLING_READ, 'stripe.subscriptions')
@@ -55,32 +53,30 @@ const CreditBalance = () => {
{isPermissionsLoaded && !canReadSubscriptions ? (
<NoPermission resourceText="view this organization's credits" />
) : (
<>
<FormPanel footer={creditTopUpEnabled ? <CreditTopUp slug={slug} /> : null}>
<FormSection>
<FormSectionContent fullWidth loading={isLoading}>
{isError && (
<AlertError
subject="Failed to retrieve organization customer profile"
error={error}
/>
)}
<FormPanel footer={<CreditTopUp slug={slug} />}>
<FormSection>
<FormSectionContent fullWidth loading={isLoading}>
{isError && (
<AlertError
subject="Failed to retrieve organization customer profile"
error={error}
/>
)}
{isSuccess && (
<div className="flex w-full justify-between items-center">
<span>Balance</span>
<div className="flex items-center space-x-1">
{isDebt && <h4 className="opacity-50">-</h4>}
<h4 className="opacity-50">$</h4>
<h2 className="text-2xl relative">{balance}</h2>
{isCredit && <h4 className="opacity-50">/credits</h4>}
</div>
{isSuccess && (
<div className="flex w-full justify-between items-center">
<span>Balance</span>
<div className="flex items-center space-x-1">
{isDebt && <h4 className="opacity-50">-</h4>}
<h4 className="opacity-50">$</h4>
<h2 className="text-2xl relative">{balance}</h2>
{isCredit && <h4 className="opacity-50">/credits</h4>}
</div>
)}
</FormSectionContent>
</FormSection>
</FormPanel>
</>
</div>
)}
</FormSectionContent>
</FormSection>
</FormPanel>
)}
</ScaffoldSectionContent>
</ScaffoldSection>
@@ -17,7 +17,6 @@ import type { OrgPlan } from 'data/subscriptions/types'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { useFlag } from 'hooks/ui/useFlag'
import { formatCurrency } from 'lib/helpers'
import { pickFeatures, pickFooter, plans as subscriptionsPlans } from 'shared-data/plans'
import { useOrgSettingsPageStateSnapshot } from 'state/organization-settings'
@@ -36,7 +35,6 @@ const PlanUpdateSidePanel = () => {
const { mutate: sendEvent } = useSendEventMutation()
const originalPlanRef = useRef<string>()
const allowOrioleDB = useFlag('allowOrioleDb')
const [showExitSurvey, setShowExitSurvey] = useState(false)
const [showUpgradeSurvey, setShowUpgradeSurvey] = useState(false)
@@ -52,8 +50,8 @@ const PlanUpdateSidePanel = () => {
(it) => it.organization_id === selectedOrganization?.id
)
const { data } = useOrganizationQuery({ slug }, { enabled: allowOrioleDB })
const hasOrioleProjects = allowOrioleDB ? false : !!data?.has_oriole_project
const { data } = useOrganizationQuery({ slug })
const hasOrioleProjects = !!data?.has_oriole_project
const snap = useOrgSettingsPageStateSnapshot()
const visible = snap.panelKey === 'subscriptionPlan'
@@ -7,7 +7,6 @@ import { useContentQuery } from 'data/content/content-query'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { useFlag } from 'hooks/ui/useFlag'
import { Metric, METRIC_CATEGORIES, METRICS } from 'lib/constants/metrics'
import { Dashboards } from 'types'
import {
@@ -41,7 +40,6 @@ interface MetricOptionsProps {
export const MetricOptions = ({ config, handleChartSelection }: MetricOptionsProps) => {
const { ref: projectRef } = useParams()
const selectedOrganization = useSelectedOrganization()
const supportSQLBlocks = useFlag('reportsV2')
const [search, setSearch] = useState('')
const { projectAuthAll: authEnabled, projectStorageAll: storageEnabled } = useIsFeatureEnabled([
@@ -98,70 +96,68 @@ export const MetricOptions = ({ config, handleChartSelection }: MetricOptionsPro
</DropdownMenuSub>
)
})}
{supportSQLBlocks && (
<DropdownMenuSub>
<DropdownMenuSubTrigger className="space-x-2">
<SQL_ICON
size={14}
strokeWidth={1.5}
className="fill-foreground-light w-5 h-4 shrink-0 grow-0 -ml-0.5"
/>
<p>SQL Snippets</p>
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent className="p-0">
<Command_Shadcn_ shouldFilter={false}>
<CommandInput_Shadcn_
autoFocus
placeholder="Search snippets..."
value={search}
onValueChange={setSearch}
/>
<CommandList_Shadcn_>
{isLoading ? (
<div className="flex flex-col p-1 gap-y-1">
<ShimmeringLoader />
<ShimmeringLoader className="w-3/4" />
</div>
) : (
<CommandEmpty_Shadcn_>No snippets found</CommandEmpty_Shadcn_>
)}
<CommandGroup_Shadcn_>
{snippets?.map((snippet) => (
<CommandItem_Shadcn_
key={snippet.id}
value={snippet.id}
className="cursor-pointer"
onSelect={() => {
if (!config?.layout.find((x) => x.id === snippet.id)) {
handleChartSelection({
metric: {
id: snippet.id,
key: `snippet_${snippet.id}`,
label: snippet.name,
},
isAddingChart: true,
})
sendEvent({
action: 'custom_report_add_sql_block_clicked',
groups: {
project: projectRef ?? 'Unknown',
organization: selectedOrganization?.slug ?? 'Unknown',
},
})
}
}}
>
{snippet.name}
</CommandItem_Shadcn_>
))}
</CommandGroup_Shadcn_>
</CommandList_Shadcn_>
</Command_Shadcn_>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
)}
<DropdownMenuSub>
<DropdownMenuSubTrigger className="space-x-2">
<SQL_ICON
size={14}
strokeWidth={1.5}
className="fill-foreground-light w-5 h-4 shrink-0 grow-0 -ml-0.5"
/>
<p>SQL Snippets</p>
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent className="p-0">
<Command_Shadcn_ shouldFilter={false}>
<CommandInput_Shadcn_
autoFocus
placeholder="Search snippets..."
value={search}
onValueChange={setSearch}
/>
<CommandList_Shadcn_>
{isLoading ? (
<div className="flex flex-col p-1 gap-y-1">
<ShimmeringLoader />
<ShimmeringLoader className="w-3/4" />
</div>
) : (
<CommandEmpty_Shadcn_>No snippets found</CommandEmpty_Shadcn_>
)}
<CommandGroup_Shadcn_>
{snippets?.map((snippet) => (
<CommandItem_Shadcn_
key={snippet.id}
value={snippet.id}
className="cursor-pointer"
onSelect={() => {
if (!config?.layout.find((x) => x.id === snippet.id)) {
handleChartSelection({
metric: {
id: snippet.id,
key: `snippet_${snippet.id}`,
label: snippet.name,
},
isAddingChart: true,
})
sendEvent({
action: 'custom_report_add_sql_block_clicked',
groups: {
project: projectRef ?? 'Unknown',
organization: selectedOrganization?.slug ?? 'Unknown',
},
})
}
}}
>
{snippet.name}
</CommandItem_Shadcn_>
))}
</CommandGroup_Shadcn_>
</CommandList_Shadcn_>
</Command_Shadcn_>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
</>
)
}
@@ -9,7 +9,7 @@ import { useSelectedProject } from 'hooks/misc/useSelectedProject'
import { useProfile } from 'lib/profile'
import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state'
import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2'
import { makeActiveTabPermanent } from 'state/tabs'
import { useTabsStateSnapshot } from 'state/tabs'
import { cn } from 'ui'
import { Admonition } from 'ui-patterns'
import { untitledSnippetTitle } from './SQLEditor.constants'
@@ -50,6 +50,7 @@ const MonacoEditor = ({
const { ref, content } = useParams()
const project = useSelectedProject()
const snapV2 = useSqlEditorV2StateSnapshot()
const tabsSnap = useTabsStateSnapshot()
const aiSnap = useAiAssistantStateSnapshot()
@@ -152,7 +153,7 @@ const MonacoEditor = ({
function handleEditorChange(value: string | undefined) {
// make active tab permanent
makeActiveTabPermanent(ref)
tabsSnap.makeActiveTabPermanent()
const snippetCheck = snapV2.snippets[id]
@@ -13,7 +13,7 @@ import type { SqlSnippet } from 'data/content/sql-snippets-query'
import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2'
import { createTabId, updateTab } from 'state/tabs'
import { createTabId, useTabsStateSnapshot } from 'state/tabs'
import { AiIconAnimation, Button, Form, Input, Modal } from 'ui'
import { useIsSQLEditorTabsEnabled } from '../App/FeaturePreview/FeaturePreviewContext'
import { subscriptionHasHipaaAddon } from '../Billing/Subscription/Subscription.utils'
@@ -35,6 +35,7 @@ const RenameQueryModal = ({
const organization = useSelectedOrganization()
const snapV2 = useSqlEditorV2StateSnapshot()
const tabsSnap = useTabsStateSnapshot()
const { data: subscription } = useOrgSubscriptionQuery(
{ orgSlug: organization?.slug },
{ enabled: visible }
@@ -111,7 +112,7 @@ const RenameQueryModal = ({
if (isSQLEditorTabsEnabled && ref) {
const tabId = createTabId('sql', { id })
updateTab(ref, tabId, { label: nameInput })
tabsSnap.updateTab(tabId, { label: nameInput })
}
toast.success('Successfully renamed snippet!')
@@ -36,7 +36,7 @@ import {
useGetImpersonatedRoleState,
} from 'state/role-impersonation-state'
import { getSqlEditorV2StateSnapshot, useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2'
import { createTabId, getTabsStore, updateTab } from 'state/tabs'
import { createTabId, useTabsStateSnapshot } from 'state/tabs'
import {
Button,
DropdownMenu,
@@ -52,7 +52,6 @@ import {
TooltipTrigger,
cn,
} from 'ui'
import { useSnapshot } from 'valtio'
import { useIsSQLEditorTabsEnabled } from '../App/FeaturePreview/FeaturePreviewContext'
import { subscriptionHasHipaaAddon } from '../Billing/Subscription/Subscription.utils'
import { useSqlEditorDiff, useSqlEditorPrompt } from './hooks'
@@ -90,8 +89,7 @@ export const SQLEditor = () => {
const org = useSelectedOrganization()
const queryClient = useQueryClient()
const store = getTabsStore(ref)
const tabs = useSnapshot(store)
const tabs = useTabsStateSnapshot()
const aiSnap = useAiAssistantStateSnapshot()
const snapV2 = useSqlEditorV2StateSnapshot()
const getImpersonatedRoleState = useGetImpersonatedRoleState()
@@ -222,7 +220,7 @@ export const SQLEditor = () => {
snapV2.renameSnippet({ id, name })
if (isSQLEditorTabsEnabled && ref) {
const tabId = createTabId('sql', { id })
updateTab(ref, tabId, { label: name })
tabs.updateTab(tabId, { label: name })
}
} catch (error) {
// [Joshen] No error handler required as this happens in the background and not necessary to ping the user
@@ -529,7 +527,7 @@ export const SQLEditor = () => {
return () => {
if (ref) {
const tabId = createTabId('sql', { id })
updateTab(ref, tabId, { scrollTop: scrollTopRef.current })
tabs.updateTab(tabId, { scrollTop: scrollTopRef.current })
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -7,7 +7,6 @@ import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import BarChart from 'components/ui/Charts/BarChart'
import NoDataPlaceholder from 'components/ui/Charts/NoDataPlaceholder'
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
import { useFlag } from 'hooks/ui/useFlag'
import Link from 'next/link'
import {
Badge,
@@ -69,7 +68,6 @@ export const ChartConfig = ({
onConfigChange,
}: ChartConfigProps) => {
const { ref } = useParams()
const supportSQLBlocks = useFlag('reportsV2')
const [acknowledged, setAcknowledged] = useLocalStorageQuery(
LOCAL_STORAGE_KEYS.SQL_EDITOR_SQL_BLOCK_ACKNOWLEDGED(ref as string),
@@ -203,7 +201,7 @@ export const ChartConfig = ({
)}
</div>
{supportSQLBlocks && !acknowledged && (
{!acknowledged && (
<Admonition showIcon={false} type="tip" className="p-2 relative group">
<Tooltip>
<TooltipTrigger
@@ -151,11 +151,11 @@ const UtilityTabResults = forwardRef<HTMLDivElement, UtilityTabResultsProps>(
)}
{!hasHipaaAddon && (
<Button
icon={<AiIconAnimation className="scale-75 w-3 h-3" loading={isDebugging} />}
icon={<AiIconAnimation size={16} loading={isDebugging} />}
disabled={!!isDisabled || isDebugging}
onClick={onDebug}
>
Debug with Supabase AI
Debug with Assistant
</Button>
)}
</div>
@@ -133,11 +133,10 @@ const DeleteConfirmationDialogs = ({
if (selectedColumnToDelete === undefined) return
deleteColumn({
id: selectedColumnToDelete.id,
column: selectedColumnToDelete,
cascade: isDeleteWithCascade,
projectRef: project.ref,
connectionString: project?.connectionString,
table: selectedTable,
})
}
@@ -293,7 +293,7 @@ export const ForeignKeySelector = ({
Select columns from{' '}
<code className="text-xs">
{fk.schema}.{fk.table}
</code>
</code>{' '}
to reference to
</label>
<div className="grid grid-cols-10 gap-y-2">
@@ -25,7 +25,7 @@ import { getTables } from 'data/tables/tables-query'
import { useUrlState } from 'hooks/ui/useUrlState'
import { useGetImpersonatedRoleState } from 'state/role-impersonation-state'
import { useTableEditorStateSnapshot } from 'state/table-editor'
import { createTabId, updateTab } from 'state/tabs'
import { createTabId, useTabsStateSnapshot } from 'state/tabs'
import type { Dictionary } from 'types'
import { SonnerProgress } from 'ui'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
@@ -69,6 +69,7 @@ const SidePanelEditor = ({
}: SidePanelEditorProps) => {
const { ref } = useParams()
const snap = useTableEditorStateSnapshot()
const tabsSnap = useTabsStateSnapshot()
const isTableEditorTabsEnabled = useIsTableEditorTabsEnabled()
const [_, setParams] = useUrlState({ arrayKeys: ['filter', 'sort'] })
@@ -246,7 +247,7 @@ const SidePanelEditor = ({
: await updateColumn({
projectRef: project?.ref!,
connectionString: project?.connectionString,
id: columnId as string,
originalColumn: selectedColumnToEdit as PostgresColumn,
payload: payload as UpdateColumnPayload,
selectedTable,
primaryKey,
@@ -494,7 +495,7 @@ const SidePanelEditor = ({
if (isTableEditorTabsEnabled && ref && payload.name) {
// [Joshen] Only table entities can be updated via the dashboard
const tabId = createTabId(ENTITY_TYPE.TABLE, { id: selectedTable.id })
updateTab(ref, tabId, { label: payload.name })
tabsSnap.updateTab(tabId, { label: payload.name })
}
toast.success(`Successfully updated ${table.name}!`, { id: toastId })
}
@@ -1,4 +1,4 @@
import type { PostgresPrimaryKey, PostgresTable } from '@supabase/postgres-meta'
import type { PostgresColumn, PostgresPrimaryKey, PostgresTable } from '@supabase/postgres-meta'
import { chunk, find, isEmpty, isEqual } from 'lodash'
import Papa from 'papaparse'
import { toast } from 'sonner'
@@ -289,7 +289,7 @@ export const createColumn = async ({
export const updateColumn = async ({
projectRef,
connectionString,
id,
originalColumn,
payload,
selectedTable,
primaryKey,
@@ -300,7 +300,7 @@ export const updateColumn = async ({
}: {
projectRef: string
connectionString?: string | null
id: string
originalColumn: PostgresColumn
payload: UpdateColumnPayload
selectedTable: PostgresTable
primaryKey?: Constraint
@@ -311,10 +311,10 @@ export const updateColumn = async ({
}) => {
try {
const { isPrimaryKey, ...formattedPayload } = payload
const column = await updateDatabaseColumn({
await updateDatabaseColumn({
projectRef,
connectionString,
id,
originalColumn,
payload: formattedPayload,
})
@@ -326,22 +326,23 @@ export const updateColumn = async ({
await dropConstraint(
projectRef,
connectionString,
column.schema,
column.table,
originalColumn.schema,
originalColumn.table,
primaryKey.name
)
}
const columnName = formattedPayload.name ?? originalColumn.name
const primaryKeyColumns = isPrimaryKey
? existingPrimaryKeys.concat([column.name])
: existingPrimaryKeys.filter((x) => x !== column.name)
? existingPrimaryKeys.concat([columnName])
: existingPrimaryKeys.filter((x) => x !== columnName)
if (primaryKeyColumns.length) {
await addPrimaryKey(
projectRef,
connectionString,
column.schema,
column.table,
originalColumn.schema,
originalColumn.table,
primaryKeyColumns
)
}
@@ -352,13 +353,13 @@ export const updateColumn = async ({
await updateForeignKeys({
projectRef,
connectionString,
table: { schema: column.schema, name: column.table },
table: { schema: originalColumn.schema, name: originalColumn.table },
foreignKeys: foreignKeyRelations,
existingForeignKeyRelations,
})
}
if (!skipSuccessMessage) toast.success(`Successfully updated column "${column.name}"`)
if (!skipSuccessMessage) toast.success(`Successfully updated column "${originalColumn.name}"`)
} catch (error: any) {
return { error }
}
@@ -725,7 +726,7 @@ export const updateTable = async ({
await deleteDatabaseColumn({
projectRef,
connectionString,
id: column.id,
column,
})
}
@@ -759,7 +760,7 @@ export const updateTable = async ({
const res = await updateColumn({
projectRef: projectRef,
connectionString: connectionString,
id: column.id,
originalColumn: originalColumn,
payload: columnPayload,
selectedTable: updatedTable,
skipPKCreation: true,
@@ -21,7 +21,7 @@ import { useUrlState } from 'hooks/ui/useUrlState'
import { PROTECTED_SCHEMAS } from 'lib/constants/schemas'
import { useAppStateSnapshot } from 'state/app-state'
import { TableEditorTableStateContextProvider } from 'state/table-editor-table'
import { createTabId, handleTabClose, makeActiveTabPermanent, useTabsStore } from 'state/tabs'
import { createTabId, useTabsStateSnapshot } from 'state/tabs'
import { Button } from 'ui'
import { Admonition, GenericSkeletonLoader } from 'ui-patterns'
import { useIsTableEditorTabsEnabled } from '../App/FeaturePreview/FeaturePreviewContext'
@@ -44,7 +44,7 @@ export const TableGridEditor = ({
const appSnap = useAppStateSnapshot()
const { ref: projectRef, id } = useParams()
const tabs = useTabsStore(projectRef)
const tabs = useTabsStateSnapshot()
const isTableEditorTabsEnabled = useIsTableEditorTabsEnabled()
const { selectedSchema } = useQuerySchemaState()
@@ -83,7 +83,7 @@ export const TableGridEditor = ({
if (isTableEditorTabsEnabled && selectedTable) {
// Close tab
const tabId = createTabId(selectedTable.entity_type, { id: selectedTable.id })
handleTabClose({ ref: projectRef, id: tabId, router, editor, onClearDashboardHistory })
tabs.handleTabClose({ id: tabId, router, editor, onClearDashboardHistory })
} else {
const tables = await getTables(selectedSchema)
if (tables.length > 0) {
@@ -122,8 +122,7 @@ export const TableGridEditor = ({
className="mt-2"
onClick={() => {
if (tabId) {
handleTabClose({
ref: projectRef,
tabs.handleTabClose({
id: tabId,
router,
editor,
@@ -156,7 +155,7 @@ export const TableGridEditor = ({
return (
// When any click happens in a table tab, the tab becomes permanent
<div className="h-full" onClick={() => makeActiveTabPermanent(project?.ref)}>
<div className="h-full" onClick={() => tabs.makeActiveTabPermanent()}>
<TableEditorTableStateContextProvider
key={`table-editor-table-${selectedTable.id}`}
projectRef={projectRef}
@@ -18,7 +18,6 @@ import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { withAuth } from 'hooks/misc/withAuth'
import { useFlag } from 'hooks/ui/useFlag'
import {
Button,
Popover_Shadcn_,
@@ -43,7 +42,6 @@ const EdgeFunctionDetailsLayout = ({
const org = useSelectedOrganization()
const { mutate: sendEvent } = useSendEventMutation()
const edgeFunctionCreate = useFlag('edgeFunctionCreate')
const isNewAPIDocsEnabled = useIsAPIDocsSidePanelEnabled()
const { isLoading, can: canReadFunctions } = useAsyncCheckProjectPermissions(
PermissionAction.FUNCTIONS_READ,
@@ -86,14 +84,10 @@ const EdgeFunctionDetailsLayout = ({
label: 'Logs',
href: `/project/${ref}/functions/${functionSlug}/logs`,
},
...(edgeFunctionCreate
? [
{
label: 'Code',
href: `/project/${ref}/functions/${functionSlug}/code`,
},
]
: []),
{
label: 'Code',
href: `/project/${ref}/functions/${functionSlug}/code`,
},
{
label: 'Details',
href: `/project/${ref}/functions/${functionSlug}/details`,
@@ -194,7 +188,7 @@ const EdgeFunctionDetailsLayout = ({
</div>
</PopoverContent_Shadcn_>
</Popover_Shadcn_>
{edgeFunctionCreate && !!functionSlug && (
{!!functionSlug && (
<Button
type="default"
icon={<Send />}
@@ -7,6 +7,7 @@ import { DatabaseSelectorStateContextProvider } from 'state/database-selector'
import { RoleImpersonationStateContextProvider } from 'state/role-impersonation-state'
import { StorageExplorerStateContextProvider } from 'state/storage-explorer'
import { TableEditorStateContextProvider } from 'state/table-editor'
import { TabsStateContextProvider } from 'state/tabs'
export interface ProjectContextType {
project?: Project
@@ -42,18 +43,22 @@ export const ProjectContextProvider = ({
return (
<ProjectContext.Provider value={value}>
<TableEditorStateContextProvider key={`table-editor-state-${projectRef}`}>
<AiAssistantStateContextProvider
key={`ai-assistant-state-${projectRef}`}
projectRef={projectRef}
>
<StorageExplorerStateContextProvider key={`storage-explorer-state-${projectRef}`}>
<DatabaseSelectorStateContextProvider key={`database-selector-state-${projectRef}`}>
<RoleImpersonationStateContextProvider key={`role-impersonation-state-${projectRef}`}>
{children}
</RoleImpersonationStateContextProvider>
</DatabaseSelectorStateContextProvider>
</StorageExplorerStateContextProvider>
</AiAssistantStateContextProvider>
<TabsStateContextProvider key={`tabs-state-${projectRef}`} projectRef={projectRef}>
<AiAssistantStateContextProvider
key={`ai-assistant-state-${projectRef}`}
projectRef={projectRef}
>
<StorageExplorerStateContextProvider key={`storage-explorer-state-${projectRef}`}>
<DatabaseSelectorStateContextProvider key={`database-selector-state-${projectRef}`}>
<RoleImpersonationStateContextProvider
key={`role-impersonation-state-${projectRef}`}
>
{children}
</RoleImpersonationStateContextProvider>
</DatabaseSelectorStateContextProvider>
</StorageExplorerStateContextProvider>
</AiAssistantStateContextProvider>
</TabsStateContextProvider>
</TableEditorStateContextProvider>
</ProjectContext.Provider>
)
@@ -7,7 +7,7 @@ import { untitledSnippetTitle } from 'components/interfaces/SQLEditor/SQLEditor.
import { createSqlSnippetSkeletonV2 } from 'components/interfaces/SQLEditor/SQLEditor.utils'
import { EmptyPrivateQueriesPanel } from 'components/layouts/SQLEditorLayout/PrivateSqlSnippetEmpty'
import EditorMenuListSkeleton from 'components/layouts/TableEditorLayout/EditorMenuListSkeleton'
import { sqlEditorTabsCleanup } from 'components/layouts/Tabs/Tabs.utils'
import { useSqlEditorTabsCleanup } from 'components/layouts/Tabs/Tabs.utils'
import { useContentCountQuery } from 'data/content/content-count-query'
import { useContentDeleteMutation } from 'data/content/content-delete-mutation'
import { getContentById } from 'data/content/content-id-query'
@@ -28,7 +28,7 @@ import {
useSnippetFolders,
useSqlEditorV2StateSnapshot,
} from 'state/sql-editor-v2'
import { createTabId, getTabsStore, makeTabPermanent, removeTabs, useTabsStore } from 'state/tabs'
import { createTabId, useTabsStateSnapshot } from 'state/tabs'
import { SqlSnippets } from 'types'
import { Separator, TreeView } from 'ui'
import {
@@ -58,8 +58,7 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
const project = useSelectedProject()
const snapV2 = useSqlEditorV2StateSnapshot()
const tabStore = getTabsStore(projectRef)
const tabs = useTabsStore(projectRef)
const tabs = useTabsStateSnapshot()
const isSQLEditorTabsEnabled = useIsSQLEditorTabsEnabled()
const [sectionVisibility, setSectionVisibility] = useLocalStorage<SectionState>(
@@ -274,10 +273,13 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
[projectSnippetsTreeState]
)
const allSnippetsInView = [
...(privateSnippetsPages?.pages.flatMap((x) => x.contents) ?? []),
...(sharedSqlSnippetsData?.pages.flatMap((x) => x.contents) ?? []),
]
const allSnippetsInView = useMemo(
() => [
...(privateSnippetsPages?.pages.flatMap((x) => x.contents) ?? []),
...(sharedSqlSnippetsData?.pages.flatMap((x) => x.contents) ?? []),
],
[privateSnippetsPages, sharedSqlSnippetsData]
)
// ==========================
// Snippet mutations from RQ
@@ -295,7 +297,7 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
// Update Tabs state - currently unknown how to differentiate between sql and non-sql content
// so we're just deleting all tabs for with matching IDs
const tabIds = data.map((id) => createTabId('sql', { id }))
removeTabs(projectRef, tabIds)
tabs.removeTabs(tabIds)
}
},
onError: (error, data) => {
@@ -537,11 +539,12 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
})
}, [projectRef, sharedSqlSnippetsData?.pages])
const sqlEditorTabsCleanup = useSqlEditorTabsCleanup()
useEffect(() => {
if (projectRef && isSuccess && isSQLEditorTabsEnabled) {
sqlEditorTabsCleanup({ ref: projectRef, snippets: allSnippetsInView as any })
if (isSuccess && isSQLEditorTabsEnabled) {
sqlEditorTabsCleanup({ snippets: allSnippetsInView as any })
}
}, [isSuccess, isSharedSqlSnippetsSuccess, allSnippetsInView, isSQLEditorTabsEnabled])
}, [allSnippetsInView, isSQLEditorTabsEnabled, isSuccess, sqlEditorTabsCleanup])
return (
<>
@@ -577,7 +580,7 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
const tabId = createTabId('sql', {
id: element?.metadata?.id as unknown as Snippet['id'],
})
const isPreview = isSQLEditorTabsEnabled && tabStore.previewTabId === tabId
const isPreview = isSQLEditorTabsEnabled && tabs.previewTabId === tabId
const isActive = !isPreview && element.metadata?.id === id
const isSelected = selectedSnippets.some((x) => x.id === element.metadata?.id)
@@ -589,7 +592,7 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
isPreview={isPreview}
onDoubleClick={(e) => {
e.preventDefault()
makeTabPermanent(projectRef, tabId)
tabs.makeTabPermanent(tabId)
}}
element={element}
onSelectDelete={() => {
@@ -661,7 +664,7 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
const tabId = createTabId('sql', {
id: element?.metadata?.id as unknown as Snippet['id'],
})
const isPreview = isSQLEditorTabsEnabled && tabStore.previewTabId === tabId
const isPreview = isSQLEditorTabsEnabled && tabs.previewTabId === tabId
const isActive = !isPreview && element.metadata?.id === id
const isSelected = selectedSnippets.some((x) => x.id === element.metadata?.id)
@@ -673,7 +676,7 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
isPreview={isPreview}
onDoubleClick={(e) => {
e.preventDefault()
makeTabPermanent(projectRef, tabId)
tabs.makeTabPermanent(tabId)
}}
element={element}
onSelectDelete={() => {
@@ -751,7 +754,7 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
const tabId = createTabId('sql', {
id: element?.metadata?.id as unknown as Snippet['id'],
})
const isPreview = isSQLEditorTabsEnabled && tabStore.previewTabId === tabId
const isPreview = isSQLEditorTabsEnabled && tabs.previewTabId === tabId
const isActive = !isPreview && element.metadata?.id === id
const isSelected = selectedSnippets.some((x) => x.id === element.metadata?.id)
@@ -829,7 +832,7 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
}}
onDoubleClick={(e) => {
e.preventDefault()
makeTabPermanent(projectRef, tabId)
tabs.makeTabPermanent(tabId)
}}
/>
)
@@ -3,7 +3,6 @@ import { Clipboard, Copy, Download, Edit, Lock, MoreHorizontal, Trash, Unlock }
import Link from 'next/link'
import Papa from 'papaparse'
import { toast } from 'sonner'
import { useSnapshot } from 'valtio'
import { IS_PLATFORM } from 'common'
import {
@@ -28,7 +27,7 @@ import { fetchAllTableRows } from 'data/table-rows/table-rows-query'
import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState'
import { copyToClipboard } from 'lib/helpers'
import { useTableEditorStateSnapshot } from 'state/table-editor'
import { createTabId, getTabsStore, makeTabPermanent } from 'state/tabs'
import { createTabId, useTabsStateSnapshot } from 'state/tabs'
import {
cn,
DropdownMenu,
@@ -66,10 +65,9 @@ const EntityListItem: ItemRenderer<Entity, EntityListItemProps> = ({
// For tabs preview flag logic
const isTableEditorTabsEnabled = useIsTableEditorTabsEnabled()
const tabId = createTabId(entity.type, { id: entity.id })
const tabStore = getTabsStore(projectRef)
const isPreview = isTableEditorTabsEnabled ? tabStore.previewTabId === tabId : false
const tabs = useTabsStateSnapshot()
const isPreview = isTableEditorTabsEnabled ? tabs.previewTabId === tabId : false
const tabs = useSnapshot(tabStore)
const isOpened = Object.values(tabs.tabsMap).some((tab) => tab.metadata?.tableId === entity.id)
const isActive = Number(id) === entity.id
const canEdit = isActive && !isLocked
@@ -233,7 +231,7 @@ const EntityListItem: ItemRenderer<Entity, EntityListItemProps> = ({
onDoubleClick={(e) => {
e.preventDefault()
const tabId = createTabId(entity.type, { id: entity.id })
makeTabPermanent(projectRef, tabId)
tabs.makeTabPermanent(tabId)
}}
>
<>
@@ -40,7 +40,7 @@ import {
InnerSideBarFilters,
} from 'ui-patterns/InnerSideMenu'
import { useProjectContext } from '../ProjectLayout/ProjectContext'
import { tableEditorTabsCleanUp } from '../Tabs/Tabs.utils'
import { useTableEditorTabsCleanUp } from '../Tabs/Tabs.utils'
import EntityListItem from './EntityListItem'
import { TableMenuEmptyState } from './TableMenuEmptyState'
@@ -115,12 +115,13 @@ const TableEditorMenu = () => {
}
}, [selectedTable?.schema])
const tableEditorTabsCleanUp = useTableEditorTabsCleanUp()
useEffect(() => {
// Clean up tabs + recent items for any tables that might have been removed outside of the dashboard session
if (isTableEditorTabsEnabled && ref && entityTypes && !searchText) {
tableEditorTabsCleanUp({ ref, schemas: [selectedSchema], entities: entityTypes })
if (isTableEditorTabsEnabled && entityTypes && !searchText) {
tableEditorTabsCleanUp({ schemas: [selectedSchema], entities: entityTypes })
}
}, [entityTypes])
}, [entityTypes, isTableEditorTabsEnabled, searchText, selectedSchema, tableEditorTabsCleanUp])
return (
<>
@@ -16,7 +16,7 @@ import { uuidv4 } from 'lib/helpers'
import { useProfile } from 'lib/profile'
import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2'
import { useTableEditorStateSnapshot } from 'state/table-editor'
import { getTabsStore } from 'state/tabs'
import { createTabId, useTabsStateSnapshot } from 'state/tabs'
import {
Button,
cn,
@@ -41,12 +41,12 @@ export function NewTab() {
const snap = useTableEditorStateSnapshot()
const snapV2 = useSqlEditorV2StateSnapshot()
const tabs = useTabsStateSnapshot()
const [templates] = partition(SQL_TEMPLATES, { type: 'template' })
const [quickstarts] = partition(SQL_TEMPLATES, { type: 'quickstart' })
const { mutate: sendEvent } = useSendEventMutation()
const canCreateSQLSnippet = useCheckPermissions(PermissionAction.CREATE, 'user_content', {
resource: { type: 'sql', owner_id: profile?.id },
subject: { id: profile?.id },
@@ -96,16 +96,14 @@ export function NewTab() {
snapV2.addSnippet({ projectRef: ref, snippet })
snapV2.addNeedsSaving(snippet.id)
const store = getTabsStore(ref)
const tabId = `sql-${snippet.id}`
store.openTabs = [...store.openTabs, tabId]
store.tabsMap[tabId] = {
const tabId = createTabId('sql', { id: snippet.id })
tabs.addTab({
id: tabId,
type: 'sql',
label: name,
metadata: { sqlId: snippet.id },
}
store.activeTab = tabId
})
router.push(`/project/${ref}/sql/${snippet.id}`)
} catch (error: any) {
@@ -2,30 +2,26 @@ import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import { AnimatePresence, motion } from 'framer-motion'
import Link from 'next/link'
import { useSnapshot } from 'valtio'
import { useParams } from 'common'
import { EntityTypeIcon } from 'components/ui/EntityTypeIcon'
import { ENTITY_TYPE } from 'data/entity-types/entity-type-constants'
import type { RecentItem } from 'state/recent-items'
import { getRecentItemsStore } from 'state/recent-items'
import { editorEntityTypes } from 'state/tabs'
import { editorEntityTypes, useTabsStateSnapshot } from 'state/tabs'
import { useEditorType } from '../editors/EditorsLayout.hooks'
dayjs.extend(relativeTime)
export function RecentItems() {
const { ref } = useParams()
const store = getRecentItemsStore(ref)
const recentItemsSnap = useSnapshot(store)
const tabs = useTabsStateSnapshot()
const editor = useEditorType()
if (!recentItemsSnap?.items || !ref) return null
if (!tabs?.recentItems || !ref) return null
// Filter items based on editor type
const filteredItems = !editor
? [...recentItemsSnap.items]
: recentItemsSnap.items.filter((item) => editorEntityTypes[editor].includes(item.type))
? [...tabs.recentItems]
: tabs.recentItems.filter((item) => editorEntityTypes[editor].includes(item.type))
const sortedItems = filteredItems.sort((a, b) => b.timestamp - a.timestamp)
@@ -50,7 +46,7 @@ export function RecentItems() {
) : (
<AnimatePresence>
<div className="grid grid-cols-1 gap-12 gap-y-0">
{sortedItems.map((item: RecentItem, index: number) => (
{sortedItems.map((item, index: number) => (
<motion.div
key={item.id}
initial={{ opacity: 0 }}
@@ -5,9 +5,8 @@ import { X } from 'lucide-react'
import { useRouter } from 'next/router'
import { useMemo } from 'react'
import { useParams } from 'common'
import { EntityTypeIcon } from 'components/ui/EntityTypeIcon'
import { makeTabPermanent, type Tab } from 'state/tabs'
import { useTabsStateSnapshot, type Tab } from 'state/tabs'
import { cn, TabsTrigger_Shadcn_ } from 'ui'
/**
@@ -30,9 +29,8 @@ export const SortableTab = ({
onClose: (id: string) => void
}) => {
const router = useRouter()
const { ref } = useParams()
const currentSchema = (router.query.schema as string) || 'public'
const tabs = useTabsStateSnapshot()
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: tab.id,
})
@@ -73,7 +71,7 @@ export const SortableTab = ({
>
<TabsTrigger_Shadcn_
value={tab.id}
onDoubleClick={() => makeTabPermanent(ref, tab.id)}
onDoubleClick={() => tabs.makeTabPermanent(tab.id)}
className={cn(
'flex items-center gap-2 pl-3 pr-2.5 text-xs',
'bg-dash-sidebar/50 dark:bg-surface-100/50',
@@ -1,14 +1,12 @@
import { motion } from 'framer-motion'
import { useSnapshot } from 'valtio'
import { useParams } from 'common'
import { EntityTypeIcon } from 'components/ui/EntityTypeIcon'
import { getTabsStore } from 'state/tabs'
import { useTabsStateSnapshot } from 'state/tabs'
export const TabPreview = ({ tab }: { tab: string }) => {
const { ref } = useParams()
const store = getTabsStore(ref)
const tabs = useSnapshot(store)
const tabs = useTabsStateSnapshot()
const tabData = tabs.tabsMap[tab]
+5 -12
View File
@@ -15,14 +15,7 @@ import { LOCAL_STORAGE_KEYS, useParams } from 'common'
import { TabsUpdateTooltip } from 'components/interfaces/App/FeaturePreview/TableEditorTabs'
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
import { useAppStateSnapshot } from 'state/app-state'
import {
editorEntityTypes,
handleTabClose,
handleTabDragEnd,
handleTabNavigation,
useTabsStore,
type Tab,
} from 'state/tabs'
import { editorEntityTypes, useTabsStateSnapshot, type Tab } from 'state/tabs'
import { cn, Tabs_Shadcn_, TabsList_Shadcn_, TabsTrigger_Shadcn_ } from 'ui'
import { useEditorType } from '../editors/EditorsLayout.hooks'
import { CollapseButton } from './CollapseButton'
@@ -35,7 +28,7 @@ export const EditorTabs = () => {
const appSnap = useAppStateSnapshot()
const editor = useEditorType()
const tabs = useTabsStore(ref)
const tabs = useTabsStateSnapshot()
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
@@ -68,7 +61,7 @@ export const EditorTabs = () => {
const newIndex = tabs.openTabs.indexOf(over.id.toString())
if (oldIndex !== newIndex) {
handleTabDragEnd(ref, oldIndex, newIndex, active.id.toString(), router)
tabs.handleTabDragEnd(oldIndex, newIndex, active.id.toString(), router)
}
}
@@ -78,11 +71,11 @@ export const EditorTabs = () => {
appSnap.setDashboardHistory(ref, editor === 'table' ? 'editor' : editor, undefined)
}
}
handleTabClose({ ref, id, router, editor, onClearDashboardHistory })
tabs.handleTabClose({ id, router, editor, onClearDashboardHistory })
}
const handleTabChange = (id: string) => {
handleTabNavigation(ref, id, router)
tabs.handleTabNavigation(id, router)
}
return (
+88 -103
View File
@@ -1,122 +1,107 @@
import { Entity } from 'data/entity-types/entity-types-infinite-query'
import {
getRecentItemsByType,
getRecentItemsStore,
RecentItem,
removeRecentItems,
} from 'state/recent-items'
import { createTabId, editorEntityTypes, getTabsStore, removeTabs, updateTab } from 'state/tabs'
import useLatest from 'hooks/misc/useLatest'
import { useCallback } from 'react'
import { createTabId, editorEntityTypes, useTabsStateSnapshot } from 'state/tabs'
export const tableEditorTabsCleanUp = ({
ref,
schemas,
entities,
}: {
ref: string
schemas: string[]
entities: Entity[]
}) => {
// Identity all entities by tab ID
const entitiesById: string[] = entities.map((x: Entity) => createTabId(x.type, { id: x.id }))
export function useTableEditorTabsCleanUp() {
const tabs = useTabsStateSnapshot()
const tabMapRef = useLatest(tabs.tabsMap)
const recentItemsRef = useLatest(tabs.recentItems)
const openTabsRef = useLatest(tabs.openTabs)
// Remove any recent items that no longer exists in the database
const recentItemsStore = getRecentItemsStore(ref)
return useCallback(({ schemas, entities }: { schemas: string[]; entities: Entity[] }) => {
// Identity all entities by tab ID
const entitiesById: string[] = entities.map((x: Entity) => createTabId(x.type, { id: x.id }))
const recentItemsFilteredToSchemas: string[] = []
for (const schema of schemas) {
recentItemsFilteredToSchemas.push(
...recentItemsStore?.items
?.filter((x: RecentItem) => x.metadata?.schema === schema)
.map((x) => x.id)
)
}
const recentItemsFilteredToSchemas: string[] = []
for (const schema of schemas) {
recentItemsFilteredToSchemas.push(
...recentItemsRef.current.filter((x) => x.metadata?.schema === schema).map((x) => x.id)
)
}
const recentItemsToRemove = [
...recentItemsFilteredToSchemas.filter((entityId) => !entitiesById.includes(entityId)),
]
const recentItemsToRemove = [
...recentItemsFilteredToSchemas.filter((entityId) => !entitiesById.includes(entityId)),
]
removeRecentItems(ref, recentItemsToRemove)
tabs.removeRecentItems(recentItemsToRemove)
// Close any tabs which might have been opened but no longer exists in the database
const tabsStore = getTabsStore(ref)
const tabsFilteredToSchemas: string[] = []
for (const schema of schemas) {
tabsFilteredToSchemas.push(
...openTabsRef.current.filter((tabId) => {
const tab = tabMapRef.current[tabId]
return tab.metadata?.schema === schema
})
)
}
const tabsFilteredToSchemas: string[] = []
for (const schema of schemas) {
tabsFilteredToSchemas.push(
...tabsStore?.openTabs?.filter((tabId) => {
const tab = tabsStore.tabsMap[tabId]
return tab.metadata?.schema === schema
})
)
}
const tableEditorTabsToBeCleaned = [
...tabsFilteredToSchemas.filter(
(id) => !id.startsWith('sql') && !id.startsWith('schema') && !entitiesById.includes(id)
),
]
const tableEditorTabsToBeCleaned = [
...tabsFilteredToSchemas.filter(
(id) => !id.startsWith('sql') && !id.startsWith('schema') && !entitiesById.includes(id)
),
]
// perform tabs cleanup
tabs.removeTabs(tableEditorTabsToBeCleaned)
// perform tabs cleanup
removeTabs(ref, tableEditorTabsToBeCleaned)
// [Joshen] Validate for opened tabs, if their label matches the entity's name - update label if not
// As the entity could've been renamed outside of the table editor
// e.g Using the SQL editor to rename the entity
const openTabs = openTabsRef.current
.map((id) => tabMapRef.current[id])
.filter((tab) => editorEntityTypes['table']?.includes(tab.type))
// [Joshen] Validate for opened tabs, if their label matches the entity's name - update label if not
// As the entity could've been renamed outside of the table editor
// e.g Using the SQL editor to rename the entity
const openTabs = tabsStore.openTabs
.map((id) => tabsStore.tabsMap[id])
.filter((tab) => editorEntityTypes['table']?.includes(tab.type))
openTabs.forEach((tab) => {
const entity = entities?.find((x) => tab.metadata?.tableId === x.id)
if (!!entity && entity.name !== tab.label) updateTab(ref, tab.id, { label: entity.name })
})
openTabs.forEach((tab) => {
const entity = entities?.find((x) => tab.metadata?.tableId === x.id)
if (!!entity && entity.name !== tab.label) tabs.updateTab(tab.id, { label: entity.name })
})
}, [])
}
export const sqlEditorTabsCleanup = ({
ref,
snippets,
}: {
ref: string
snippets: { id: string; type: string; name: string }[]
}) => {
// these are tabs that are static content
// these canot be removed from localstorage based on this query request
const IGNORED_TAB_IDS = ['sql-templates', 'sql-quickstarts']
export function useSqlEditorTabsCleanup() {
const tabs = useTabsStateSnapshot()
const tabMapRef = useLatest(tabs.tabsMap)
const openTabsRef = useLatest(tabs.openTabs)
// Identify all SQL snippets / content by their tab ids
const currentContentIds = [
...snippets
.filter((content) => content.type === 'sql')
.map((content) => createTabId('sql', { id: content.id })),
// append ignored tab IDs
...IGNORED_TAB_IDS,
]
return useCallback(({ snippets }: { snippets: { id: string; type: string; name: string }[] }) => {
// these are tabs that are static content
// these canot be removed from localstorage based on this query request
const IGNORED_TAB_IDS = ['sql-templates', 'sql-quickstarts']
// Remove any snippet tabs that might no longer be existing (removed outside of the dashboard session)
const tabsStore = getTabsStore(ref)
const snippetTabsToBeCleaned = tabsStore.openTabs.filter(
(id: string) => id.startsWith('sql') && !currentContentIds.includes(id)
)
removeTabs(ref, snippetTabsToBeCleaned)
// Identify all SQL snippets / content by their tab ids
const currentContentIds = [
...snippets
.filter((content) => content.type === 'sql')
.map((content) => createTabId('sql', { id: content.id })),
// append ignored tab IDs
...IGNORED_TAB_IDS,
]
// Remove any recent items that might no longer be existing (removed outside of the dashboard session)
const recentItems = getRecentItemsByType(ref, 'sql')
removeRecentItems(
ref,
recentItems
? recentItems.filter((item) => !currentContentIds.includes(item.id)).map((item) => item.id)
: []
)
// Remove any snippet tabs that might no longer be existing (removed outside of the dashboard session)
const snippetTabsToBeCleaned = openTabsRef.current.filter(
(id: string) => id.startsWith('sql') && !currentContentIds.includes(id)
)
tabs.removeTabs(snippetTabsToBeCleaned)
// [Joshen] Validate for opened tabs, if their label matches the snippet's name - update label if not
// As the snippets name could've been updated outside of the SQL Editor session
// e.g for a shared snippet, the owner could've updated the name of the snippet
const openSqlTabs = tabsStore.openTabs
.map((id) => tabsStore.tabsMap[id])
.filter((tab) => editorEntityTypes['sql']?.includes(tab.type))
// Remove any recent items that might no longer be existing (removed outside of the dashboard session)
const recentItems = tabs.getRecentItemsByType('sql')
tabs.removeRecentItems(
recentItems
? recentItems.filter((item) => !currentContentIds.includes(item.id)).map((item) => item.id)
: []
)
openSqlTabs.forEach((tab) => {
const snippet = snippets?.find((x) => tab.metadata?.sqlId === x.id)
if (!!snippet && snippet.name !== tab.label) updateTab(ref, tab.id, { label: snippet.name })
})
// [Joshen] Validate for opened tabs, if their label matches the snippet's name - update label if not
// As the snippets name could've been updated outside of the SQL Editor session
// e.g for a shared snippet, the owner could've updated the name of the snippet
const openSqlTabs = openTabsRef.current
.map((id) => tabMapRef.current[id])
.filter((tab) => editorEntityTypes['sql']?.includes(tab.type))
openSqlTabs.forEach((tab) => {
const snippet = snippets?.find((x) => tab.metadata?.sqlId === x.id)
if (!!snippet && snippet.name !== tab.label) tabs.updateTab(tab.id, { label: snippet.name })
})
}, [])
}
@@ -14,7 +14,8 @@ import { PermissionAction } from '@supabase/shared-types/out/constants'
import { ChartConfig } from 'components/interfaces/SQLEditor/UtilityPanel/ChartConfig'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useFlag } from 'hooks/ui/useFlag'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { useSelectedProject } from 'hooks/misc/useSelectedProject'
import { useProfile } from 'lib/profile'
import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state'
import { Dashboards } from 'types'
@@ -26,8 +27,6 @@ import { AssistantSnippetProps } from './AIAssistant.types'
import { identifyQueryType } from './AIAssistant.utils'
import CollapsibleCodeBlock from './CollapsibleCodeBlock'
import { MessageContext } from './Message'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { useSelectedProject } from 'hooks/misc/useSelectedProject'
export const OrderedList = memo(({ children }: { children: ReactNode }) => (
<ol className="flex flex-col gap-y-4">{children}</ol>
@@ -168,7 +167,6 @@ export const MarkdownPre = ({
const { isLoading, readOnly } = useContext(MessageContext)
const { mutate: sendEvent } = useSendEventMutation()
const snap = useAiAssistantStateSnapshot()
const supportSQLBlocks = useFlag('reportsV2')
const project = useSelectedProject()
const org = useSelectedOrganization()
@@ -209,8 +207,7 @@ export const MarkdownPre = ({
// Strip props from the content for both SQL and edge functions
const cleanContent = rawContent.replace(/(?:--|\/\/)\s*props:\s*\{[^}]+\}/, '').trim()
const isDraggableToReports =
supportSQLBlocks && canCreateSQLSnippet && router.pathname.endsWith('/reports/[id]')
const isDraggableToReports = canCreateSQLSnippet && router.pathname.endsWith('/reports/[id]')
useEffect(() => {
chartConfig.current = {
@@ -1,9 +1,11 @@
import pgMeta from '@supabase/pg-meta'
import { PGColumn } from '@supabase/pg-meta/src/pg-meta-columns'
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { databaseKeys } from 'data/database/keys'
import { entityTypeKeys } from 'data/entity-types/keys'
import { del, handleError } from 'data/fetchers'
import { executeSql } from 'data/sql/execute-sql-query'
import { tableEditorKeys } from 'data/table-editor/keys'
import { tableRowKeys } from 'data/table-rows/keys'
import { viewKeys } from 'data/views/keys'
@@ -12,32 +14,26 @@ import type { ResponseError } from 'types'
export type DatabaseColumnDeleteVariables = {
projectRef: string
connectionString?: string | null
id: string
column: Pick<PGColumn, 'id' | 'name' | 'schema' | 'table' | 'table_id'>
cascade?: boolean
table?: { id: number; schema: string; name: string }
}
export async function deleteDatabaseColumn({
projectRef,
connectionString,
id,
column,
cascade = false,
}: DatabaseColumnDeleteVariables) {
let headers = new Headers()
if (connectionString) headers.set('x-connection-encrypted', connectionString)
const { sql } = pgMeta.columns.remove(column, { cascade })
const { data, error } = await del('/platform/pg-meta/{ref}/columns', {
params: {
header: { 'x-connection-encrypted': connectionString! },
path: { ref: projectRef },
// cascade is expected to be a string 'true' or 'false'
query: { id, cascade },
},
headers,
const { result } = await executeSql({
projectRef,
connectionString,
sql,
queryKey: ['column', 'delete', column.id],
})
if (error) handleError(error)
return data
return result
}
type DatabaseColumnDeleteData = Awaited<ReturnType<typeof deleteDatabaseColumn>>
@@ -55,29 +51,25 @@ export const useDatabaseColumnDeleteMutation = ({
(vars) => deleteDatabaseColumn(vars),
{
async onSuccess(data, variables, context) {
const { projectRef, table } = variables
const { projectRef, column } = variables
await Promise.all([
// refetch all entities in the sidebar because deleting a column may regenerate a view (and change its id)
queryClient.invalidateQueries(entityTypeKeys.list(projectRef)),
...(table !== undefined
? [
queryClient.invalidateQueries(
databaseKeys.foreignKeyConstraints(projectRef, table?.schema)
),
queryClient.invalidateQueries(tableEditorKeys.tableEditor(projectRef, table.id)),
queryClient.invalidateQueries(databaseKeys.tableDefinition(projectRef, table.id)),
// invalidate all views from this schema, not sure if this is needed since you can't actually delete a column
// which has a view dependent on it
queryClient.invalidateQueries(viewKeys.listBySchema(projectRef, table.schema)),
]
: []),
queryClient.invalidateQueries(
databaseKeys.foreignKeyConstraints(projectRef, column.schema)
),
queryClient.invalidateQueries(tableEditorKeys.tableEditor(projectRef, column.table_id)),
queryClient.invalidateQueries(databaseKeys.tableDefinition(projectRef, column.table_id)),
// invalidate all views from this schema, not sure if this is needed since you can't actually delete a column
// which has a view dependent on it
queryClient.invalidateQueries(viewKeys.listBySchema(projectRef, column.schema)),
])
if (table !== undefined) {
// We need to invalidate tableRowsAndCount after tableEditor
// to ensure the query sent is correct
await queryClient.invalidateQueries(tableRowKeys.tableRowsAndCount(projectRef, table.id))
}
// We need to invalidate tableRowsAndCount after tableEditor
// to ensure the query sent is correct
await queryClient.invalidateQueries(
tableRowKeys.tableRowsAndCount(projectRef, column.table_id)
)
await onSuccess?.(data, variables, context)
},
@@ -1,8 +1,10 @@
import pgMeta from '@supabase/pg-meta'
import { PGColumn } from '@supabase/pg-meta/src/pg-meta-columns'
import { useMutation, UseMutationOptions } from '@tanstack/react-query'
import { toast } from 'sonner'
import type { components } from 'data/api'
import { handleError, patch } from 'data/fetchers'
import { executeSql } from 'data/sql/execute-sql-query'
import type { ResponseError } from 'types'
export type UpdateColumnBody = components['schemas']['UpdateColumnBody']
@@ -10,31 +12,48 @@ export type UpdateColumnBody = components['schemas']['UpdateColumnBody']
export type DatabaseColumnUpdateVariables = {
projectRef: string
connectionString?: string | null
id: string
originalColumn: Pick<
PGColumn,
| 'id'
| 'name'
| 'schema'
| 'table'
| 'table_id'
| 'ordinal_position'
| 'is_identity'
| 'is_unique'
>
payload: components['schemas']['UpdateColumnBody']
}
export async function updateDatabaseColumn({
projectRef,
connectionString,
id,
originalColumn,
payload,
}: DatabaseColumnUpdateVariables) {
let headers = new Headers()
if (connectionString) headers.set('x-connection-encrypted', connectionString)
const { data, error } = await patch('/platform/pg-meta/{ref}/columns', {
params: {
header: { 'x-connection-encrypted': connectionString! },
path: { ref: projectRef },
query: { id },
},
body: payload,
headers,
const { sql } = pgMeta.columns.update(originalColumn, {
name: payload.name,
type: payload.type,
drop_default: payload.dropDefault,
default_value: payload.defaultValue,
default_value_format: payload.defaultValueFormat,
is_identity: payload.isIdentity,
identity_generation: payload.identityGeneration,
is_nullable: payload.isNullable,
is_unique: payload.isUnique,
comment: payload.comment,
check: payload.check,
})
if (error) handleError(error)
return data
const { result } = await executeSql({
projectRef,
connectionString,
sql,
queryKey: ['column', 'update', originalColumn.id],
})
return result
}
type DatabaseColumnUpdateData = Awaited<ReturnType<typeof updateDatabaseColumn>>
+6
View File
@@ -3,4 +3,10 @@ export const tableKeys = {
['projects', projectRef, 'tables', schema, includeColumns].filter(Boolean),
retrieve: (projectRef: string | undefined, name: string, schema: string) =>
['projects', projectRef, 'tables', schema, name].filter(Boolean),
rolesAccess: (projectRef: string | undefined, schema: string, table: string) => [
'projects',
projectRef,
'roles-access',
{ schema, table },
],
}
@@ -0,0 +1,70 @@
import { useQuery, UseQueryOptions } from '@tanstack/react-query'
import { executeSql, ExecuteSqlError } from '../sql/execute-sql-query'
import { tableKeys } from './keys'
type TableRolesAccessArgs = {
schema: string
table: string
}
/**
* [Joshen] Specifically just checking for anon and authenticated roles since this is
* just to verify if the table is exposed via the Supabase API
*/
export const getTableRolesAccessSql = ({ schema, table }: TableRolesAccessArgs) => {
const sql = /* SQL */ `
SELECT grantee, privilege_type
FROM information_schema.role_table_grants
WHERE table_schema = '${schema}'
AND table_name = '${table}'
AND grantee IN ('anon', 'authenticated');
`.trim()
return sql
}
export type TableRolesAccessVariables = TableRolesAccessArgs & {
projectRef?: string
connectionString?: string | null
}
export async function getTableRolesAccess(
{ schema, table, projectRef, connectionString }: TableRolesAccessVariables,
signal?: AbortSignal
) {
if (!schema) {
throw new Error('schema is required')
}
const sql = getTableRolesAccessSql({ schema, table })
const { result } = (await executeSql(
{ projectRef, connectionString, sql, queryKey: ['TableRolesAccess', schema] },
signal
)) as { result: { grantee: string; privilege_type: string }[] }
const res = []
if (result.some((x) => x.grantee === 'anon')) res.push('anon')
if (result.some((x) => x.grantee === 'authenticated')) res.push('authenticated')
return res
}
export type TableRolesAccessData = Awaited<ReturnType<typeof getTableRolesAccess>>
export type TableRolesAccessError = ExecuteSqlError
export const useTableRolesAccessQuery = <TData = TableRolesAccessData>(
{ projectRef, connectionString, schema, table }: TableRolesAccessVariables,
{
enabled = true,
...options
}: UseQueryOptions<TableRolesAccessData, TableRolesAccessError, TData> = {}
) =>
useQuery<TableRolesAccessData, TableRolesAccessError, TData>(
tableKeys.rolesAccess(projectRef, schema, table),
({ signal }) => getTableRolesAccess({ projectRef, connectionString, schema, table }, signal),
{
enabled: enabled && typeof projectRef !== 'undefined' && typeof schema !== 'undefined',
...options,
}
)
+7 -259
View File
@@ -1,6 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { apiAuthenticate } from './apiAuthenticate'
import { readOnly } from './supabaseClient'
const mocks = vi.hoisted(() => {
return {
@@ -11,25 +10,11 @@ const mocks = vi.hoisted(() => {
},
error: null,
}),
getIdentity: vi.fn().mockReturnValue({
identity: null,
error: null,
}),
getAuth0Id: vi.fn(),
}
})
// Mock dependencies
vi.mock('./supabaseClient', () => ({
readOnly: {
from: vi.fn(),
},
}))
vi.mock('lib/gotrue', () => ({
getAuthUser: mocks.getAuthUser,
getIdentity: mocks.getIdentity,
getAuth0Id: mocks.getAuth0Id,
}))
describe('apiAuthenticate', () => {
@@ -51,26 +36,12 @@ describe('apiAuthenticate', () => {
},
error: null,
})
mocks.getIdentity.mockReturnValue({
identity: null,
error: null,
})
})
it('should return error when request is not available', async () => {
const result = await apiAuthenticate(null as any, mockRes)
expect(result).toStrictEqual({ error: new Error('Request is not available') })
})
it('should return error when response is not available', async () => {
const result = await apiAuthenticate(mockReq, null as any)
expect(result).toStrictEqual({ error: new Error('Response is not available') })
})
it('should return error when authorization token is missing', async () => {
const reqWithoutToken = { ...mockReq, headers: {} }
const result = await apiAuthenticate(reqWithoutToken, mockRes)
expect(result).toStrictEqual({ error: { name: 'Error', message: 'missing access token' } })
expect(result).toStrictEqual({ error: new Error('missing access token') })
})
it('should return error when auth user fetch fails', async () => {
@@ -80,239 +51,16 @@ describe('apiAuthenticate', () => {
})
const result = await apiAuthenticate(mockReq, mockRes)
expect(result).toStrictEqual({ error: { name: 'Error', message: 'Auth failed' } })
})
it('should handle identity error', async () => {
mocks.getIdentity.mockReturnValue({
identity: null,
error: new Error('Identity error'),
})
const result = await apiAuthenticate(mockReq, mockRes)
expect(result).toStrictEqual({ error: { name: 'Error', message: 'Identity error' } })
})
it('should set auth0 id when identity provider is present', async () => {
mocks.getIdentity.mockReturnValue({
identity: {
provider: 'auth0',
id: 'auth0-id',
},
error: null,
})
mocks.getAuth0Id.mockReturnValue('auth0-user-id')
// Mock user query
vi.mocked(readOnly.from).mockReturnValue({
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
single: vi
.fn()
.mockResolvedValue({ data: { id: 'test-user-id', primary_email: 'test@example.com' } }),
} as any)
const result = await apiAuthenticate(mockReq, mockRes)
expect(result).toStrictEqual({
id: 'test-user-id',
primary_email: 'test@example.com',
})
expect(mocks.getAuth0Id).toHaveBeenCalledWith('auth0', 'auth0-id')
})
it('should return user when user_id_supabase is present', async () => {
// Mock identity to return auth0 provider
mocks.getIdentity.mockReturnValue({
identity: {
provider: 'auth0',
id: 'auth0-id',
},
error: null,
})
mocks.getAuth0Id.mockReturnValue('auth0-user-id')
// Mock user query to return a user with auth0_id
vi.mocked(readOnly.from).mockReturnValue({
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
single: vi.fn().mockResolvedValue({
data: {
id: 'supabase-user-id',
auth0_id: 'auth0-user-id',
primary_email: 'test@example.com',
},
}),
} as any)
const result = await apiAuthenticate(mockReq, mockRes)
expect(result).toStrictEqual({
id: 'supabase-user-id',
auth0_id: 'auth0-user-id',
primary_email: 'test@example.com',
})
expect(result).toStrictEqual({ error: new Error('Auth failed') })
})
it('should return error when user does not exist', async () => {
vi.mocked(readOnly.from).mockReturnValue({
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
single: vi.fn().mockResolvedValue({ data: null }),
} as any)
mocks.getAuthUser.mockResolvedValue({
user: null,
error: null,
})
const result = await apiAuthenticate(mockReq, mockRes)
expect(result).toStrictEqual({ error: new Error('The user does not exist') })
})
it('should check organization permissions when orgSlug is provided', async () => {
const reqWithOrg = {
...mockReq,
query: { slug: 'test-org' },
}
// Mock user query
vi.mocked(readOnly.from).mockReturnValueOnce({
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
single: vi
.fn()
.mockResolvedValue({ data: { id: 'test-user-id', primary_email: 'test@example.com' } }),
} as any)
// Mock organization query
vi.mocked(readOnly.from).mockReturnValueOnce({
select: vi.fn().mockReturnThis(),
match: vi.fn().mockReturnThis(),
single: vi.fn().mockResolvedValue({ data: { id: 'org-id' } }),
} as any)
// Mock member check
vi.mocked(readOnly.from).mockReturnValueOnce({
select: vi.fn().mockReturnThis(),
match: vi.fn().mockReturnThis(),
single: vi.fn().mockResolvedValue({ data: { id: 'member-id' }, status: 200 }),
} as any)
const result = await apiAuthenticate(reqWithOrg, mockRes)
expect(result).toStrictEqual({
id: 'test-user-id',
primary_email: 'test@example.com',
})
})
it('should return error when user lacks organization permissions', async () => {
const reqWithOrg = {
...mockReq,
query: { slug: 'test-org' },
}
// Mock user query
vi.mocked(readOnly.from).mockReturnValueOnce({
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
single: vi
.fn()
.mockResolvedValue({ data: { id: 'test-user-id', primary_email: 'test@example.com' } }),
} as any)
// Mock organization query
vi.mocked(readOnly.from).mockReturnValueOnce({
select: vi.fn().mockReturnThis(),
match: vi.fn().mockReturnThis(),
single: vi.fn().mockResolvedValue({ data: { id: 'org-id' } }),
} as any)
// Mock member check failure
vi.mocked(readOnly.from).mockReturnValueOnce({
select: vi.fn().mockReturnThis(),
match: vi.fn().mockReturnThis(),
single: vi.fn().mockRejectedValue(new Error('Permission denied')),
} as any)
const result = await apiAuthenticate(reqWithOrg, mockRes)
expect(result).toStrictEqual({
error: { name: 'Error', message: 'The user does not have permission' },
})
})
it('should handle unknown errors gracefully', async () => {
mocks.getAuthUser.mockRejectedValue(new Error('Unexpected error'))
const result = await apiAuthenticate(mockReq, mockRes)
expect(result).toStrictEqual({ error: { name: 'Error', message: 'Unexpected error' } })
})
it('should get organization from project reference', async () => {
const reqWithProject = {
...mockReq,
query: { ref: 'test-project' },
}
// Mock user query
vi.mocked(readOnly.from).mockReturnValueOnce({
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
single: vi
.fn()
.mockResolvedValue({ data: { id: 'test-user-id', primary_email: 'test@example.com' } }),
} as any)
// Mock project query
vi.mocked(readOnly.from).mockReturnValueOnce({
select: vi.fn().mockReturnThis(),
match: vi.fn().mockReturnThis(),
single: vi.fn().mockResolvedValue({ data: { organization_id: 'org-id' } }),
} as any)
// Mock member check
vi.mocked(readOnly.from).mockReturnValueOnce({
select: vi.fn().mockReturnThis(),
match: vi.fn().mockReturnThis(),
single: vi.fn().mockResolvedValue({ data: { id: 'member-id' }, status: 200 }),
} as any)
const result = await apiAuthenticate(reqWithProject, mockRes)
expect(result).toStrictEqual({
id: 'test-user-id',
primary_email: 'test@example.com',
})
})
it('should use organization_id from projectRef in member check', async () => {
const reqWithProject = {
...mockReq,
query: { ref: 'project-xyz' },
}
// Mock user query
vi.mocked(readOnly.from).mockReturnValueOnce({
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
single: vi
.fn()
.mockResolvedValue({ data: { id: 'user-123', primary_email: 'user@example.com' } }),
} as any)
// Mock project query to return a specific organization_id
vi.mocked(readOnly.from).mockReturnValueOnce({
select: vi.fn().mockReturnThis(),
match: vi.fn().mockReturnThis(),
single: vi.fn().mockResolvedValue({ data: { organization_id: 'org-abc' } }),
} as any)
// Spy on the match function for the member check
const matchSpy = vi.fn().mockReturnThis()
vi.mocked(readOnly.from).mockReturnValueOnce({
select: vi.fn().mockReturnThis(),
match: matchSpy,
single: vi.fn().mockResolvedValue({ data: { id: 'member-1' }, status: 200 }),
} as any)
const result = await apiAuthenticate(reqWithProject, mockRes)
expect(result).toStrictEqual({
id: 'user-123',
primary_email: 'user@example.com',
})
// Ensure the member check used the org id from the project lookup
expect(matchSpy).toHaveBeenCalledWith({ organization_id: 'org-abc', user_id: 'user-123' })
})
})
+15 -103
View File
@@ -1,44 +1,30 @@
import { getAuthUser } from 'lib/gotrue'
import type { NextApiRequest, NextApiResponse } from 'next'
import { readOnly } from './supabaseClient'
import type { SupaResponse, User } from 'types'
import { getAuth0Id, getAuthUser, getIdentity } from 'lib/gotrue'
import type { ResponseError, SupaResponse, User } from 'types'
/**
* Use this method on api routes to check if user is authenticated and having required permissions.
* This method can only be used from the server side.
* Member permission is mandatory whenever orgSlug/projectRef query param exists
* @param {NextApiRequest} req
* @param {NextApiResponse} res
* @param {Object} config requireUserDetail: bool, requireOwner: bool
* @param {NextApiResponse} _res
*
* @returns {Object<user, error, description>}
* user null, with error and description if not authenticated or not enough permissions
*/
export async function apiAuthenticate(
req: NextApiRequest,
res: NextApiResponse
_res: NextApiResponse
): Promise<SupaResponse<User>> {
if (!req) {
return { error: new Error('Request is not available') }
}
if (!res) {
return { error: new Error('Response is not available') }
}
const { slug: orgSlug, ref: projectRef } = req.query
try {
const user = await fetchUser(req, res)
const user = await fetchUser(req)
if (!user) {
return { error: new Error('The user does not exist') }
}
if (orgSlug || projectRef) await checkMemberPermission(req, user)
return user
} catch (error: any) {
console.error('Error at apiAuthenticate', error)
return { error: { name: 'Error', message: error.message ?? 'unknown' } }
} catch (error) {
return { error: error as ResponseError }
}
}
@@ -46,93 +32,19 @@ export async function apiAuthenticate(
* @returns
* user with only id prop or detail object. It depends on requireUserDetail config
*/
async function fetchUser(req: NextApiRequest, res: NextApiResponse): Promise<any> {
let user_id_supabase = null
let user_id_auth0 = null
let gotrue_id = null
let email = null
const token = req.headers.authorization
async function fetchUser(req: NextApiRequest): Promise<any> {
const token = req.headers.authorization?.replace('Bearer ', '')
if (!token) {
throw new Error('missing access token')
}
let { user: gotrue_user, error: authError } = await getAuthUser(token)
if (authError) {
throw authError
}
if (gotrue_user !== null) {
gotrue_id = gotrue_user?.id
email = gotrue_user.email
let { identity, error } = getIdentity(gotrue_user)
if (error) throw error
if (identity?.provider !== undefined) {
user_id_auth0 = getAuth0Id(identity?.provider, identity?.id)
}
const { user, error } = await getAuthUser(token)
if (error) {
throw error
}
if (user_id_supabase) {
return {
id: user_id_supabase,
primary_email: email,
}
if (!user) {
throw new Error('The user does not exist')
}
const query = readOnly.from('users').select(
`
id, auth0_id, primary_email, username, first_name, last_name, mobile, is_alpha_user
`
)
const { data } = await query.eq('gotrue_id', gotrue_id).single()
return data
}
async function checkMemberPermission(req: NextApiRequest, user: any) {
const org = await getOrganization(req)
if (!org) {
throw new Error('User organization does not exist')
}
try {
const response = await readOnly
.from('members')
.select('id')
.match({ organization_id: org.id, user_id: user.id })
.single()
if (!response || response.status != 200) {
throw new Error('The user does not have permission')
}
return true
} catch (error) {
throw new Error('The user does not have permission')
}
}
async function getOrganization(req: NextApiRequest) {
const { slug: orgSlug, ref: projectRef } = req.query
if (!orgSlug && !projectRef) {
throw new Error('Not enough info to check user permissions')
}
if (orgSlug) {
const { data } = await readOnly
.from('organizations')
.select('id')
.match({ slug: orgSlug })
.single()
return { id: data.id }
}
if (projectRef) {
const { data } = await readOnly
.from('projects')
.select('organization_id')
.match({ ref: projectRef })
.single()
return { id: data.organization_id }
}
return null
return user
}
@@ -1,49 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { readOnly } from './supabaseClient'
vi.mock('lib/constants', () => ({
IS_PLATFORM: true,
}))
const readOnlyErrMessage = 'Read only error'
vi.mock('@supabase/supabase-js', () => ({
createClient: vi.fn(() => ({
from: vi.fn(() => ({
insert: () => {
throw readOnlyErrMessage
},
delete: () => {
throw readOnlyErrMessage
},
update: () => {
throw readOnlyErrMessage
},
})),
rpc: () => {
throw readOnlyErrMessage
},
})),
}))
describe('supabaseClient', () => {
it('should be defined', () => {
expect(readOnly).toBeDefined()
})
it('should throw on inserts', () => {
expect(() => readOnly.from('').insert({})).toThrowError()
})
it('should throw on deletes', () => {
expect(() => readOnly.from('').delete({})).toThrowError()
})
it('should throw on updates', () => {
expect(() => readOnly.from('').update({})).toThrowError()
})
it('should throw on rpc', () => {
expect(() => readOnly.rpc({})).toThrowError()
})
})
-26
View File
@@ -1,26 +0,0 @@
import { createClient } from '@supabase/supabase-js'
import { IS_PLATFORM } from '../constants'
let readOnly: any
if (IS_PLATFORM) {
readOnly = createClient(process.env.READ_ONLY_URL ?? '', process.env.READ_ONLY_API_KEY ?? '')
const readOnlyErrMessage = Error('This client is for read-only actions. Use readWrite instead.')
// overwrites function calls
// for readOnly
readOnly.from('').insert = () => {
throw readOnlyErrMessage
}
readOnly.from('').delete = () => {
throw readOnlyErrMessage
}
readOnly.from('').update = () => {
throw readOnlyErrMessage
}
readOnly.rpc = () => {
throw readOnlyErrMessage
}
}
export { readOnly }
+2 -1
View File
@@ -1,4 +1,5 @@
import { getAccessToken, gotrueClient, type User } from 'common'
import { getAccessToken, type User } from 'common/auth'
import { gotrueClient } from 'common/gotrue'
export const auth = gotrueClient
export { getAccessToken }
-4
View File
@@ -7,16 +7,12 @@ export const config = {
// [Joshen] Return 404 for all next.js API endpoints EXCEPT the ones we use in hosted:
const HOSTED_SUPPORTED_API_URLS = [
'/ai/sql/suggest',
'/ai/sql/generate-v2',
'/ai/sql/generate-v3',
'/ai/edge-function/complete',
'/ai/onboarding/design',
'/ai/sql/complete',
'/ai/sql/title',
'/ai/sql/debug',
'/ai/sql/cron',
'/ai/docs',
'/get-ip-address',
'/get-utc-time',
'/edge-functions/test',
-150
View File
@@ -1,150 +0,0 @@
import { SupabaseClient } from '@supabase/supabase-js'
import { ApplicationError, UserError, clippy } from 'ai-commands/edge'
import { NextRequest } from 'next/server'
import OpenAI from 'openai'
export const config = {
runtime: 'edge',
/* To avoid OpenAI errors, restrict to the Vercel Edge Function regions that
overlap with the OpenAI API regions.
Reference for Vercel regions: https://vercel.com/docs/edge-network/regions#region-list
Reference for OpenAI regions: https://help.openai.com/en/articles/5347006-openai-api-supported-countries-and-territories
*/
regions: [
'arn1',
'bom1',
'cdg1',
'cle1',
'cpt1',
'dub1',
'fra1',
'gru1',
'hnd1',
'iad1',
'icn1',
'kix1',
'lhr1',
'pdx1',
'sfo1',
'sin1',
'syd1',
],
}
const openAiKey = process.env.OPENAI_API_KEY
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL as string
const supabaseServiceKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY as string
export default async function handler(req: NextRequest) {
if (!openAiKey) {
return new Response(
JSON.stringify({
error: 'No OPENAI_API_KEY set. Create this environment variable to use AI features.',
}),
{
status: 500,
headers: { 'Content-Type': 'application/json' },
}
)
}
if (!supabaseUrl) {
return new Response(
JSON.stringify({
error:
'No NEXT_PUBLIC_SUPABASE_URL set. Create this environment variable to use AI features.',
}),
{
status: 500,
headers: { 'Content-Type': 'application/json' },
}
)
}
if (!supabaseServiceKey) {
return new Response(
JSON.stringify({
error:
'No NEXT_PUBLIC_SUPABASE_ANON_KEY set. Create this environment variable to use AI features.',
}),
{
status: 500,
headers: { 'Content-Type': 'application/json' },
}
)
}
const { method } = req
switch (method) {
case 'POST':
return handlePost(req)
default:
return new Response(
JSON.stringify({ data: null, error: { message: `Method ${method} Not Allowed` } }),
{
status: 405,
headers: { 'Content-Type': 'application/json', Allow: 'POST' },
}
)
}
}
async function handlePost(request: NextRequest) {
const openai = new OpenAI({ apiKey: openAiKey })
const body = await (request.json() as Promise<{
messages: { content: string; role: 'user' | 'assistant' }[]
}>)
const { messages } = body
if (!messages) {
throw new UserError('Missing messages in request data')
}
const supabaseClient = new SupabaseClient(supabaseUrl, supabaseServiceKey)
try {
const response = await clippy(openai, supabaseClient, messages)
// Proxy the streamed SSE response from OpenAI
return new Response(response.body, {
headers: {
'Content-Type': 'text/event-stream',
},
})
} catch (error: unknown) {
console.error(error)
if (error instanceof UserError) {
return new Response(
JSON.stringify({
error: error.message,
data: error.data,
}),
{
status: 400,
headers: { 'Content-Type': 'application/json' },
}
)
} else if (error instanceof ApplicationError) {
// Print out application errors with their additional data
console.error(`${error.message}: ${JSON.stringify(error.data)}`)
} else {
// Print out unexpected errors as is to help with debugging
console.error(error)
}
// TODO: include more response info in debug environments
return new Response(
JSON.stringify({
error: 'There was an error processing your request',
}),
{
status: 500,
headers: { 'Content-Type': 'application/json' },
}
)
}
}
@@ -2,6 +2,7 @@ import { openai } from '@ai-sdk/openai'
import pgMeta from '@supabase/pg-meta'
import { streamText } from 'ai'
import { executeSql } from 'data/sql/execute-sql-query'
import apiWrapper from 'lib/api/apiWrapper'
import { NextApiRequest, NextApiResponse } from 'next'
import { getTools } from '../sql/tools'
@@ -9,7 +10,7 @@ export const maxDuration = 30
const openAiKey = process.env.OPENAI_API_KEY
const pgMetaSchemasList = pgMeta.schemas.list()
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!openAiKey) {
return new Response(
JSON.stringify({
@@ -35,6 +36,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}
}
const wrapper = (req: NextApiRequest, res: NextApiResponse) =>
apiWrapper(req, res, handler, { withAuth: true })
export default wrapper
async function handlePost(req: NextApiRequest, res: NextApiResponse) {
try {
const { completionMetadata, projectRef, connectionString, includeSchemaMetadata } = req.body
@@ -1,7 +1,7 @@
import { openai } from '@ai-sdk/openai'
import { streamText } from 'ai'
import { streamText, tool } from 'ai'
import apiWrapper from 'lib/api/apiWrapper'
import { NextApiRequest, NextApiResponse } from 'next'
import { tool } from 'ai'
import { z } from 'zod'
const openAiKey = process.env.OPENAI_API_KEY
@@ -46,7 +46,7 @@ const getTools = () => {
}
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!openAiKey) {
return res.status(400).json({
error: 'No OPENAI_API_KEY set. Create this environment variable to use AI features.',
@@ -64,6 +64,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}
}
const wrapper = (req: NextApiRequest, res: NextApiResponse) =>
apiWrapper(req, res, handler, { withAuth: true })
export default wrapper
async function handlePost(req: NextApiRequest, res: NextApiResponse) {
const { messages } = req.body
@@ -1,7 +1,10 @@
import apiWrapper from 'lib/api/apiWrapper'
import { NextApiRequest, NextApiResponse } from 'next'
export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler)
const wrapper = (req: NextApiRequest, res: NextApiResponse) =>
apiWrapper(req, res, handler, { withAuth: true })
export default wrapper
async function handler(req: NextApiRequest, res: NextApiResponse) {
const { method } = req
+7 -1
View File
@@ -2,6 +2,7 @@ import { openai } from '@ai-sdk/openai'
import pgMeta from '@supabase/pg-meta'
import { streamText } from 'ai'
import { executeSql } from 'data/sql/execute-sql-query'
import apiWrapper from 'lib/api/apiWrapper'
import { NextApiRequest, NextApiResponse } from 'next'
import { getTools } from '../sql/tools'
@@ -9,7 +10,7 @@ export const maxDuration = 30
const openAiKey = process.env.OPENAI_API_KEY
const pgMetaSchemasList = pgMeta.schemas.list()
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!openAiKey) {
return new Response(
JSON.stringify({
@@ -150,3 +151,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
})
}
}
const wrapper = (req: NextApiRequest, res: NextApiResponse) =>
apiWrapper(req, res, handler, { withAuth: true })
export default wrapper
+2 -1
View File
@@ -54,6 +54,7 @@ export async function handlePost(req: NextApiRequest, res: NextApiResponse) {
}
}
const wrapper = (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler)
const wrapper = (req: NextApiRequest, res: NextApiResponse) =>
apiWrapper(req, res, handler, { withAuth: true })
export default wrapper
-80
View File
@@ -1,80 +0,0 @@
import { ContextLengthError, EmptySqlError, debugSql } from 'ai-commands'
import apiWrapper from 'lib/api/apiWrapper'
import { NextApiRequest, NextApiResponse } from 'next'
import { OpenAI } from 'openai'
const openAiKey = process.env.OPENAI_API_KEY
const openai = new OpenAI({ apiKey: openAiKey })
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!openAiKey) {
return res.status(500).json({
error: 'No OPENAI_API_KEY set. Create this environment variable to use AI features.',
})
}
const { method } = req
switch (method) {
case 'POST':
return handlePost(req, res)
default:
res.setHeader('Allow', ['POST'])
res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } })
}
}
export async function handlePost(req: NextApiRequest, res: NextApiResponse) {
const {
body: { errorMessage, sql, entityDefinitions },
} = req
try {
const result = await debugSql(openai, errorMessage, sql, entityDefinitions)
return res.json(result)
} catch (error) {
if (error instanceof Error) {
console.error(`AI SQL debugging failed: ${error.message}`)
const hasEntityDefinitions = entityDefinitions !== undefined && entityDefinitions.length > 0
if (error instanceof ContextLengthError) {
// If there are more entity definitions than the SQL provided, attribute the
// error to the database metadata
if (hasEntityDefinitions) {
const definitionsLength = entityDefinitions.reduce(
(sum: number, def: string) => sum + def.length,
0
)
if (definitionsLength > sql.length) {
return res.status(400).json({
error:
'Your database metadata is too large for Supabase AI to ingest. Try disabling database metadata in AI settings.',
})
}
}
// Otherwise attribute the error to the SQL being too large
return res.status(400).json({
error:
'Your SQL query is too large for Supabase AI to ingest. Try splitting it into smaller queries.',
})
}
if (error instanceof EmptySqlError) {
res.status(400).json({
error: 'Unable to debug SQL. No fix identified for the error.',
})
}
} else {
console.log(`Unknown error: ${error}`)
}
return res.status(500).json({
error: 'There was an unknown error debugging the SQL snippet. Please try again.',
})
}
}
const wrapper = (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler)
export default wrapper
-107
View File
@@ -1,107 +0,0 @@
import { StreamingTextResponse } from 'ai'
import { chatRlsPolicy, chatSql } from 'ai-commands/edge'
import { SupportedAssistantEntities } from 'components/ui/AIAssistantPanel/AIAssistant.types'
import { DatabasePoliciesData } from 'data/database-policies/database-policies-query'
import { NextRequest } from 'next/server'
import OpenAI from 'openai'
export const config = {
runtime: 'edge',
/* To avoid OpenAI errors, restrict to the Vercel Edge Function regions that
overlap with the OpenAI API regions.
Reference for Vercel regions: https://vercel.com/docs/edge-network/regions#region-list
Reference for OpenAI regions: https://help.openai.com/en/articles/5347006-openai-api-supported-countries-and-territories
*/
regions: [
'arn1',
'bom1',
'cdg1',
'cle1',
'cpt1',
'dub1',
'fra1',
'gru1',
'hnd1',
'iad1',
'icn1',
'kix1',
'lhr1',
'pdx1',
'sfo1',
'sin1',
'syd1',
],
}
const openAiKey = process.env.OPENAI_API_KEY
export default async function handler(req: NextRequest) {
if (!openAiKey) {
return new Response(
JSON.stringify({
error: 'No OPENAI_API_KEY set. Create this environment variable to use AI features.',
}),
{
status: 500,
headers: { 'Content-Type': 'application/json' },
}
)
}
const { method } = req
switch (method) {
case 'POST':
return handlePost(req)
default:
return new Response(
JSON.stringify({ data: null, error: { message: `Method ${method} Not Allowed` } }),
{
status: 405,
headers: { 'Content-Type': 'application/json', Allow: 'POST' },
}
)
}
}
async function handlePost(request: NextRequest) {
const openai = new OpenAI({ apiKey: openAiKey })
const body = await (request.json() as Promise<{
context?: SupportedAssistantEntities
messages: { content: string; role: 'user' | 'assistant' }[]
existingSql?: string
entityDefinitions: string[]
existingPolicies?: DatabasePoliciesData
}>)
const { messages, existingSql, entityDefinitions, context, existingPolicies } = body
try {
if (context === 'rls-policies') {
const stream = await chatRlsPolicy(
openai,
messages,
entityDefinitions,
existingPolicies ?? [],
existingSql
)
return new StreamingTextResponse(stream)
} else {
const stream = await chatSql(openai, messages, existingSql, entityDefinitions, context)
return new StreamingTextResponse(stream)
}
} catch (error) {
if (error instanceof Error) {
console.error(`AI SQL generation-v2 failed: ${error.message}`)
} else {
console.error(`AI SQL generation-v2 failed: ${error}`)
}
return new Response(JSON.stringify({ error: 'There was an error processing your request' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
})
}
}
+7 -1
View File
@@ -4,13 +4,14 @@ import { streamText } from 'ai'
import { NextApiRequest, NextApiResponse } from 'next'
import { executeSql } from 'data/sql/execute-sql-query'
import apiWrapper from 'lib/api/apiWrapper'
import { getTools } from './tools'
export const maxDuration = 30
const openAiKey = process.env.OPENAI_API_KEY
const pgMetaSchemasList = pgMeta.schemas.list()
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!openAiKey) {
return res.status(500).json({
error: 'No OPENAI_API_KEY set. Create this environment variable to use AI features.',
@@ -28,6 +29,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}
}
const wrapper = (req: NextApiRequest, res: NextApiResponse) =>
apiWrapper(req, res, handler, { withAuth: true })
export default wrapper
async function handlePost(req: NextApiRequest, res: NextApiResponse) {
const { messages, projectRef, connectionString, includeSchemaMetadata, schema, table } = req.body
-101
View File
@@ -1,101 +0,0 @@
import { StreamingTextResponse } from 'ai'
import { chatRlsPolicy } from 'ai-commands/edge'
import { DatabasePoliciesData } from 'data/database-policies/database-policies-query'
import { NextRequest } from 'next/server'
import OpenAI from 'openai'
export const config = {
runtime: 'edge',
/* To avoid OpenAI errors, restrict to the Vercel Edge Function regions that
overlap with the OpenAI API regions.
Reference for Vercel regions: https://vercel.com/docs/edge-network/regions#region-list
Reference for OpenAI regions: https://help.openai.com/en/articles/5347006-openai-api-supported-countries-and-territories
*/
regions: [
'arn1',
'bom1',
'cdg1',
'cle1',
'cpt1',
'dub1',
'fra1',
'gru1',
'hnd1',
'iad1',
'icn1',
'kix1',
'lhr1',
'pdx1',
'sfo1',
'sin1',
'syd1',
],
}
const openAiKey = process.env.OPENAI_API_KEY
export default async function handler(req: NextRequest) {
if (!openAiKey) {
return new Response(
JSON.stringify({
error: 'No OPENAI_API_KEY set. Create this environment variable to use AI features.',
}),
{
status: 500,
headers: { 'Content-Type': 'application/json' },
}
)
}
const { method } = req
switch (method) {
case 'POST':
return handlePost(req)
default:
return new Response(
JSON.stringify({ data: null, error: { message: `Method ${method} Not Allowed` } }),
{
status: 405,
headers: { 'Content-Type': 'application/json', Allow: 'POST' },
}
)
}
}
async function handlePost(request: NextRequest) {
const openai = new OpenAI({ apiKey: openAiKey })
const body = await (request.json() as Promise<{
messages: { content: string; role: 'user' | 'assistant' }[]
entityDefinitions: string[]
existingPolicies: DatabasePoliciesData
policyDefinition: string
}>)
const { messages, entityDefinitions, existingPolicies, policyDefinition } = body
try {
const stream = await chatRlsPolicy(
openai,
messages,
entityDefinitions,
existingPolicies,
policyDefinition
)
return new StreamingTextResponse(stream)
} catch (error) {
console.error(error)
return new Response(
JSON.stringify({
error: 'There was an error processing your request',
}),
{
status: 500,
headers: { 'Content-Type': 'application/json' },
}
)
}
}
+2 -1
View File
@@ -52,6 +52,7 @@ export async function handlePost(req: NextApiRequest, res: NextApiResponse) {
}
}
const wrapper = (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler)
const wrapper = (req: NextApiRequest, res: NextApiResponse) =>
apiWrapper(req, res, handler, { withAuth: true })
export default wrapper
@@ -1,8 +1,8 @@
import { NextApiRequest, NextApiResponse } from 'next'
import { PG_META_URL } from 'lib/constants'
import apiWrapper from 'lib/api/apiWrapper'
import { constructHeaders } from 'lib/api/apiHelpers'
import apiWrapper from 'lib/api/apiWrapper'
import { get } from 'lib/common/fetch'
import { PG_META_URL } from 'lib/constants'
import { NextApiRequest, NextApiResponse } from 'next'
export default (req: NextApiRequest, res: NextApiResponse) =>
apiWrapper(req, res, handler, { withAuth: true })
@@ -23,16 +23,9 @@ const handleGetAll = async (req: NextApiRequest, res: NextApiResponse) => {
const headers = constructHeaders(req.headers)
const response = await get(`${PG_META_URL}/types`, { headers })
const includeSystemSchemas = req.query['include_system_schemas'] === 'true'
if (response.error) {
return res.status(400).json({ error: response.error })
}
if (!includeSystemSchemas) {
const types = response?.filter((x: any) => x.schema === 'public')
return res.status(200).json(types)
}
return res.status(200).json(response)
}
+1 -4
View File
@@ -151,7 +151,6 @@ const Wizard: NextPageWithLayout = () => {
const projectCreationDisabled = useFlag('disableProjectCreationAndUpdate')
const showPostgresVersionSelector = useFlag('showPostgresVersionSelector')
const cloudProviderEnabled = useFlag('enableFlyCloudProvider')
const allowOrioleDB = useFlag('allowOrioleDb')
const { data: membersExceededLimit } = useFreeProjectLimitCheckQuery(
{ slug },
{ enabled: isFreePlan }
@@ -898,9 +897,7 @@ const Wizard: NextPageWithLayout = () => {
)}
<SecurityOptions form={form} />
{allowOrioleDB && !!availableOrioleVersion && (
<AdvancedConfiguration form={form} />
)}
{!!availableOrioleVersion && <AdvancedConfiguration form={form} />}
</>
)}
@@ -9,13 +9,13 @@ import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectConte
import TableEditorLayout from 'components/layouts/TableEditorLayout/TableEditorLayout'
import TableEditorMenu from 'components/layouts/TableEditorLayout/TableEditorMenu'
import { useTableEditorQuery } from 'data/table-editor/table-editor-query'
import { addTab, createTabId, getTabsStore } from 'state/tabs'
import { createTabId, useTabsStateSnapshot } from 'state/tabs'
import type { NextPageWithLayout } from 'types'
const TableEditorPage: NextPageWithLayout = () => {
const { id: _id, ref: projectRef } = useParams()
const id = _id ? Number(_id) : undefined
const store = getTabsStore(projectRef)
const store = useTabsStateSnapshot()
const { project } = useProjectContext()
const { data: selectedTable, isLoading } = useTableEditorQuery({
@@ -37,7 +37,7 @@ const TableEditorPage: NextPageWithLayout = () => {
if (isTableEditorTabsEnabled && selectedTable && projectRef) {
const tabId = createTabId(selectedTable.entity_type, { id: selectedTable.id })
if (!store.tabsMap[tabId]) {
addTab(projectRef, {
store.addTab({
id: tabId,
type: selectedTable.entity_type,
label: selectedTable.name,
@@ -49,7 +49,7 @@ const TableEditorPage: NextPageWithLayout = () => {
})
} else {
// If tab already exists, just make it active
store.activeTab = tabId
store.makeTabActive(tabId)
}
}
}, [selectedTable, id, projectRef, isTableEditorTabsEnabled])
@@ -10,13 +10,13 @@ import { NewTab } from 'components/layouts/Tabs/NewTab'
import { useRouter } from 'next/router'
import { useEffect } from 'react'
import { useAppStateSnapshot } from 'state/app-state'
import { editorEntityTypes, getTabsStore } from 'state/tabs'
import { editorEntityTypes, useTabsStateSnapshot } from 'state/tabs'
import type { NextPageWithLayout } from 'types'
const TableEditorPage: NextPageWithLayout = () => {
const router = useRouter()
const { ref: projectRef } = useParams()
const tabStore = getTabsStore(projectRef)
const tabStore = useTabsStateSnapshot()
const appSnap = useAppStateSnapshot()
const isTableEditorTabsEnabled = useIsTableEditorTabsEnabled()
@@ -1,11 +1,10 @@
import { common, dirname, relative } from '@std/path/posix'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { AlertCircle, CornerDownLeft, Loader2 } from 'lucide-react'
import { useRouter } from 'next/router'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { LogoLoader } from 'ui'
import { useParams } from 'common'
import { DeployEdgeFunctionWarningModal } from 'components/interfaces/EdgeFunctions/DeployEdgeFunctionWarningModal'
import DefaultLayout from 'components/layouts/DefaultLayout'
@@ -20,8 +19,8 @@ import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useOrgOptedIntoAi } from 'hooks/misc/useOrgOptedIntoAi'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { useSelectedProject } from 'hooks/misc/useSelectedProject'
import { useFlag } from 'hooks/ui/useFlag'
import { BASE_PATH, IS_PLATFORM } from 'lib/constants'
import { LogoLoader } from 'ui'
const CodePage = () => {
const router = useRouter()
@@ -29,7 +28,6 @@ const CodePage = () => {
const project = useSelectedProject()
const isOptedInToAI = useOrgOptedIntoAi()
const includeSchemaMetadata = isOptedInToAI || !IS_PLATFORM
const edgeFunctionCreate = useFlag('edgeFunctionCreate')
const { mutate: sendEvent } = useSendEventMutation()
const org = useSelectedOrganization()
const [showDeployWarning, setShowDeployWarning] = useState(false)
@@ -149,13 +147,6 @@ const CodePage = () => {
onUpdate()
}
// TODO (Saxon): Remove this once the flag is fully launched
useEffect(() => {
if (edgeFunctionCreate !== undefined && !edgeFunctionCreate) {
router.push(`/project/${ref}/functions`)
}
}, [edgeFunctionCreate])
useEffect(() => {
// Set files from API response when available
if (selectedFunction?.entrypoint_path && functionFiles) {
@@ -11,13 +11,11 @@ import { PageLayout } from 'components/layouts/PageLayout/PageLayout'
import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold'
import Table from 'components/to-be-cleaned/Table'
import AlertError from 'components/ui/AlertError'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { DocsButton } from 'components/ui/DocsButton'
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
import { useEdgeFunctionsQuery } from 'data/edge-functions/edge-functions-query'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { useFlag } from 'hooks/ui/useFlag'
import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state'
import type { NextPageWithLayout } from 'types'
import {
@@ -45,7 +43,6 @@ const EdgeFunctionsPage: NextPageWithLayout = () => {
isError,
isSuccess,
} = useEdgeFunctionsQuery({ projectRef: ref })
const edgeFunctionCreate = useFlag('edgeFunctionCreate')
const { mutate: sendEvent } = useSendEventMutation()
const org = useSelectedOrganization()
@@ -59,25 +56,23 @@ const EdgeFunctionsPage: NextPageWithLayout = () => {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80">
{edgeFunctionCreate && (
<DropdownMenuItem
onSelect={() => {
router.push(`/project/${ref}/functions/new`)
sendEvent({
action: 'edge_function_via_editor_button_clicked',
properties: { origin: 'secondary_action' },
groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' },
})
}}
className="gap-4"
>
<Code className="shrink-0" size={16} strokeWidth={1.5} />
<div>
<span className="text-foreground">Via Editor</span>
<p>Write and deploy in the browser</p>
</div>
</DropdownMenuItem>
)}
<DropdownMenuItem
onSelect={() => {
router.push(`/project/${ref}/functions/new`)
sendEvent({
action: 'edge_function_via_editor_button_clicked',
properties: { origin: 'secondary_action' },
groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' },
})
}}
className="gap-4"
>
<Code className="shrink-0" size={16} strokeWidth={1.5} />
<div>
<span className="text-foreground">Via Editor</span>
<p>Write and deploy in the browser</p>
</div>
</DropdownMenuItem>
<Dialog>
<DialogTrigger asChild>
<DropdownMenuItem
@@ -17,7 +17,6 @@ import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { useOrgOptedIntoAi } from 'hooks/misc/useOrgOptedIntoAi'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { useSelectedProject } from 'hooks/misc/useSelectedProject'
import { useFlag } from 'hooks/ui/useFlag'
import { BASE_PATH, IS_PLATFORM } from 'lib/constants'
import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state'
import {
@@ -104,7 +103,6 @@ const NewFunctionPage = () => {
const isOptedInToAI = useOrgOptedIntoAi()
const includeSchemaMetadata = isOptedInToAI || !IS_PLATFORM
const snap = useAiAssistantStateSnapshot()
const edgeFunctionCreate = useFlag('edgeFunctionCreate')
const { mutate: sendEvent } = useSendEventMutation()
const org = useSelectedOrganization()
@@ -231,13 +229,6 @@ const NewFunctionPage = () => {
form.handleSubmit(onSubmit)()
}
// TODO (Saxon): Remove this once the flag is fully launched
useEffect(() => {
if (!edgeFunctionCreate) {
router.push(`/project/${ref}/functions`)
}
}, [edgeFunctionCreate, ref, router])
useEffect(() => {
if (template) {
const templateMeta = EDGE_FUNCTION_TEMPLATES.find((x) => x.value === template)
+4 -2
View File
@@ -188,10 +188,12 @@ const Home: NextPageWithLayout = () => {
<div className="space-y-8">
<h4 className="text-lg">Example projects</h4>
<div className="flex justify-center">
<Tabs_Shadcn_ defaultValue="app">
<Tabs_Shadcn_ defaultValue="app" className="w-full">
<TabsList_Shadcn_ className="flex gap-4 mb-8">
<TabsTrigger_Shadcn_ value="app">App Frameworks</TabsTrigger_Shadcn_>
<TabsTrigger_Shadcn_ value="mobile">Mobile Framework</TabsTrigger_Shadcn_>
<TabsTrigger_Shadcn_ value="mobile">
Mobile Frameworks
</TabsTrigger_Shadcn_>
</TabsList_Shadcn_>
<TabsContent_Shadcn_ value="app">
<div className="grid gap-2 md:gap-8 md:grid-cols-2 lg:grid-cols-3">
+3 -2
View File
@@ -11,7 +11,7 @@ import { SQLEditorMenu } from 'components/layouts/SQLEditorLayout/SQLEditorMenu'
import { useContentIdQuery } from 'data/content/content-id-query'
import { useAppStateSnapshot } from 'state/app-state'
import { SnippetWithContent, useSnippets, useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2'
import { addTab, createTabId } from 'state/tabs'
import { createTabId, useTabsStateSnapshot } from 'state/tabs'
import type { NextPageWithLayout } from 'types'
const SqlEditor: NextPageWithLayout = () => {
@@ -20,6 +20,7 @@ const SqlEditor: NextPageWithLayout = () => {
const appSnap = useAppStateSnapshot()
const snapV2 = useSqlEditorV2StateSnapshot()
const tabs = useTabsStateSnapshot()
const allSnippets = useSnippets(ref!)
@@ -65,7 +66,7 @@ const SqlEditor: NextPageWithLayout = () => {
const tabId = createTabId('sql', { id })
const snippet = allSnippets.find((x) => x.id === id)
addTab(ref, {
tabs.addTab({
id: tabId,
type: 'sql',
label: snippet?.name || 'Untitled Query',
@@ -8,13 +8,13 @@ import { NewTab } from 'components/layouts/Tabs/NewTab'
import { useRouter } from 'next/router'
import { useEffect } from 'react'
import { useAppStateSnapshot } from 'state/app-state'
import { getTabsStore } from 'state/tabs'
import { useTabsStateSnapshot } from 'state/tabs'
import type { NextPageWithLayout } from 'types'
const TableEditorPage: NextPageWithLayout = () => {
const router = useRouter()
const { ref: projectRef } = useParams()
const store = getTabsStore(projectRef)
const store = useTabsStateSnapshot()
const appSnap = useAppStateSnapshot()
const isSqlEditorTabsEnabled = useIsSQLEditorTabsEnabled()
@@ -8,12 +8,13 @@ import DefaultLayout from 'components/layouts/DefaultLayout'
import { EditorBaseLayout } from 'components/layouts/editors/EditorBaseLayout'
import SQLEditorLayout from 'components/layouts/SQLEditorLayout/SQLEditorLayout'
import { SQLEditorMenu } from 'components/layouts/SQLEditorLayout/SQLEditorMenu'
import { addTab, createTabId } from 'state/tabs'
import { createTabId, useTabsStateSnapshot } from 'state/tabs'
import type { NextPageWithLayout } from 'types'
const SqlQuickstarts: NextPageWithLayout = () => {
const router = useRouter()
const { ref } = useParams<{ ref: string }>()
const tabs = useTabsStateSnapshot()
const isSqlEditorTabsEnabled = useIsSQLEditorTabsEnabled()
@@ -23,7 +24,7 @@ const SqlQuickstarts: NextPageWithLayout = () => {
const tabId = createTabId('sql', { id: 'quickstarts' })
addTab(ref, {
tabs.addTab({
id: tabId,
type: 'sql',
label: 'Quickstarts',
@@ -8,12 +8,13 @@ import DefaultLayout from 'components/layouts/DefaultLayout'
import { EditorBaseLayout } from 'components/layouts/editors/EditorBaseLayout'
import SQLEditorLayout from 'components/layouts/SQLEditorLayout/SQLEditorLayout'
import { SQLEditorMenu } from 'components/layouts/SQLEditorLayout/SQLEditorMenu'
import { addTab, createTabId } from 'state/tabs'
import { createTabId, useTabsStateSnapshot } from 'state/tabs'
import type { NextPageWithLayout } from 'types'
const SqlTemplates: NextPageWithLayout = () => {
const router = useRouter()
const { ref } = useParams<{ ref: string }>()
const tabs = useTabsStateSnapshot()
const isSqlEditorTabsEnabled = useIsSQLEditorTabsEnabled()
@@ -23,7 +24,7 @@ const SqlTemplates: NextPageWithLayout = () => {
const tabId = createTabId('sql', { id: 'templates' })
addTab(ref, {
tabs.addTab({
id: tabId,
type: 'sql',
label: 'Templates',
@@ -1,10 +1,16 @@
import { AlertTriangleIcon, Boxes } from 'lucide-react'
import { NextPage } from 'next'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { Fragment } from 'react'
import { IS_PLATFORM } from 'common'
import { ProjectList } from 'components/interfaces/Home/ProjectList'
import { useOrganizationsQuery } from 'data/organizations/organizations-query'
import { withAuth } from 'hooks/misc/withAuth'
import { BASE_PATH } from 'lib/constants'
import { Alert_Shadcn_, AlertDescription_Shadcn_, AlertTitle_Shadcn_ } from 'ui'
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
const Header = () => {
return (
@@ -31,6 +37,14 @@ const GenericProjectPage: NextPage = () => {
const router = useRouter()
const { routeSlug, ...queryParams } = router.query
const {
data: organizations,
isLoading: isLoadingOrganizations,
isError: isErrorOrganizations,
} = useOrganizationsQuery({
enabled: IS_PLATFORM,
})
const query = Object.keys(queryParams).length
? `?${new URLSearchParams(queryParams as Record<string, string>)}`
: undefined
@@ -57,11 +71,49 @@ const GenericProjectPage: NextPage = () => {
className="flex-grow py-6 space-y-8 overflow-y-auto"
style={{ maxHeight: 'calc(100vh - 49px - 64px)' }}
>
<ProjectList rewriteHref={urlRewriterFactory(routeSlug)} search="" />
{isLoadingOrganizations ? (
<OrganizationLoadingState />
) : isErrorOrganizations ? (
<OrganizationErrorState />
) : (
organizations.map((organization) => (
<Fragment key={organization.id}>
<h2 className="flex items-center gap-2">
<Boxes size={14} strokeWidth={1.5} className="text-foreground-lighter" />
{organization.name}
</h2>
<ProjectList
forOrganization={organization}
rewriteHref={urlRewriterFactory(routeSlug)}
search=""
/>
</Fragment>
))
)}
</div>
</div>
</>
)
}
function OrganizationLoadingState() {
return (
<>
<ShimmeringLoader className="w-3/4" />
<ShimmeringLoader className="w-1/2" />
<ShimmeringLoader className="w-1/4" />
</>
)
}
function OrganizationErrorState() {
return (
<Alert_Shadcn_ variant="warning">
<AlertTriangleIcon />
<AlertTitle_Shadcn_>Failed to load your Supabase organizations</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>Try refreshing the page</AlertDescription_Shadcn_>
</Alert_Shadcn_>
)
}
export default withAuth(GenericProjectPage)
-160
View File
@@ -1,160 +0,0 @@
import { partition } from 'lodash'
import { proxy, subscribe } from 'valtio'
import { editorEntityTypes, Tab, TabType } from './tabs'
/**
* [TODO REFACTOR] Joshen: JFYI this differs from the way that we usually write our stores
* where we have contexts and such - ideally we need to refactor this for consistency
* and the main benefit from our usual way of writing stores is that we do not need to pass
* in the project ref to all the functions, as the stores are written to be scoped to the project
*
* So do not use this as reference for writing new valtio stores - refer to database-selector instead
*/
// Define the key for storing recent items in localStorage
const RECENT_ITEMS_KEY = 'supabase_recent_items'
// Define the maximum number of recent items to store
const MAX_RECENT_ITEMS = 8
const getStorageKey = (ref: string) => `${RECENT_ITEMS_KEY}_${ref}`
// Define the structure of a recent item
export interface RecentItem {
id: string // Unique identifier for the item
type: TabType // Type of the tab (e.g., sql, table)
label: string // Label for the item
timestamp: number // Timestamp of when the item was added
metadata?: {
// Optional metadata associated with the item
schema?: string // Schema name (if applicable)
name?: string // Name of the item (if applicable)
tableId?: number // Table ID (if applicable)
sqlId?: string // SQL ID (if applicable)
}
}
// Define the structure of the recent items state
interface RecentItems {
items: RecentItem[] // Each project just has an array of items
}
interface RecentItemsStore {
[key: string]: RecentItems // Removed | undefined since we always initialize it
}
const defaultState: RecentItems = {
items: [],
}
export const recentItemsStore = proxy<RecentItemsStore>({})
export const getRecentItemsStore = (ref: string | undefined): RecentItems => {
if (!ref) return proxy(defaultState)
if (!recentItemsStore[ref]) {
const stored = localStorage.getItem(getStorageKey(ref))
console.debug('[Recent Items] Loading stored items:', stored)
try {
const parsed = stored ? JSON.parse(stored) : defaultState
// Validate the shape of the data
if (!parsed.items || !Array.isArray(parsed.items)) {
console.warn('[Recent Items] Invalid stored data, using default')
recentItemsStore[ref] = proxy({ ...defaultState })
} else {
recentItemsStore[ref] = proxy(parsed)
}
} catch (error) {
console.error('[Recent Items] Failed to parse stored items:', error)
recentItemsStore[ref] = proxy({ ...defaultState })
}
}
return recentItemsStore[ref]
}
// Subscribe to changes for each ref and save to localStorage
if (typeof window !== 'undefined') {
subscribe(recentItemsStore, () => {
Object.entries(recentItemsStore).forEach(([ref, state]) => {
try {
localStorage.setItem(getStorageKey(ref), JSON.stringify(state))
} catch (error) {
console.error('Failed to save recent items to localStorage:', error)
}
})
})
}
// Function to add a recent item
export const addRecentItem = (ref: string | undefined, tab: Tab) => {
if (!ref) return
const store = getRecentItemsStore(ref)
// Check if an item with the same ID already exists
const existingItem = store.items.find((item) => item.id === tab.id)
if (existingItem) {
// If it exists, update its timestamp
existingItem.timestamp = Date.now()
return // Exit the function
}
// If it doesn't exist, create and add a new item
const recentItem: RecentItem = {
id: tab.id, // Set the ID
type: tab.type, // Set the type
label: tab.label || 'Untitled', // Set the label or default to 'Untitled'
timestamp: Date.now(), // Set the current timestamp
metadata: tab.metadata, // Set the metadata
}
// Add the new recent item to the beginning of the list
store.items.unshift(recentItem)
// Ensure that there's only up to max of MAX_RECENT_ITEMS items per tab type
const [itemsOfSameType, itemsOfDifferentType] = partition(store.items, (item) => {
if (editorEntityTypes.table.includes(item.type)) return item
})
store.items = [...itemsOfSameType.slice(0, MAX_RECENT_ITEMS), ...itemsOfDifferentType]
}
// Function to clear all recent items
export const clearRecentItems = (ref: string | undefined) => {
if (!ref) return
const store = getRecentItemsStore(ref)
store.items = [] // Set items to an empty array
}
// Function to remove a specific recent item by its ID
export const removeRecentItem = (ref: string | undefined, itemId: string) => {
if (!ref) return
const store = getRecentItemsStore(ref)
store.items = store.items.filter((item) => item.id !== itemId)
}
// Function to remove multiple recent items by their IDs
export const removeRecentItems = (ref: string | undefined, itemIds: string[]) => {
if (!ref) return
const store = getRecentItemsStore(ref)
store.items = store.items.filter((item) => !itemIds.includes(item.id))
}
// Function to remove recent items by their type
export const removeRecentItemsByType = (ref: string | undefined, type: TabType) => {
if (!ref) return
const store = getRecentItemsStore(ref)
store.items = store.items.filter((item) => item.type !== type)
}
// Function to get all recent items
export const getRecentItems = (ref: string | undefined) => {
if (!ref) return
const store = getRecentItemsStore(ref)
return store.items
}
// Function to get recent items by their type
export const getRecentItemsByType = (ref: string | undefined, type: TabType) => {
if (!ref) return
const store = getRecentItemsStore(ref)
return store.items.filter((item) => item.type === type)
}
+3 -2
View File
@@ -406,7 +406,7 @@ function createStorageExplorerState({
)
// Add items to column
const formattedItems = formatFolderItems(data)
const formattedItems = formatFolderItems(data, prefix)
state.columns = state.columns.map((col, idx) => {
if (idx === index) {
return {
@@ -475,7 +475,8 @@ function createStorageExplorerState({
)
const formattedFolders = foldersItems.map((folderItems, idx) => {
const formattedItems = formatFolderItems(folderItems)
const prefix = paths.slice(0, idx).join('/')
const formattedItems = formatFolderItems(folderItems, prefix)
return {
id: null,
status: STORAGE_ROW_STATUS.READY,
-404
View File
@@ -1,404 +0,0 @@
import { ENTITY_TYPE } from 'data/entity-types/entity-type-constants'
import { NextRouter } from 'next/router'
import { ReactNode } from 'react'
import { proxy, subscribe, useSnapshot } from 'valtio'
import { addRecentItem } from './recent-items'
/**
* [TODO REFACTOR] Joshen: JFYI this differs from the way that we usually write our stores
* where we have contexts and such - ideally we need to refactor this for consistency
* and the main benefit from our usual way of writing stores is that we do not need to pass
* in the project ref to all the functions, as the stores are written to be scoped to the project
*
* So do not use this as reference for writing new valtio stores - refer to database-selector instead
*/
// Object mapping editor types to their corresponding tab types
export const editorEntityTypes = {
table: ['r', 'v', 'm', 'f', 'p'],
sql: ['sql'],
}
// Define the type of tabs available in the application
export type TabType = ENTITY_TYPE | 'sql'
type CreateTabIdParams = {
r: { id: number }
v: { id: number }
m: { id: number }
f: { id: number }
p: { id: number }
sql: { id: string }
schema: { schema: string }
view: never
function: never
new: never
}
export interface Tab {
id: string // Unique identifier for the tab
type: TabType // Type of the tab, which can be an entity type or specific string values
label?: string // Optional label for the tab, displayed to the user
icon?: ReactNode // Optional icon associated with the tab
metadata?: {
// Optional metadata related to the tab
schema?: string // Optional schema name associated with the tab
name?: string // Optional name of the entity represented by the tab
tableId?: number // Optional ID of the table associated with the tab
sqlId?: string // Optional ID of the SQL query associated with the tab
scrollTop?: number // Optional scroll top of the tab (Currently just for SQL query)
}
isPreview?: boolean // Optional flag indicating if the tab is in preview mode
createdAt?: Date // Optional timestamp for when the tab was created
updatedAt?: Date // Optional timestamp for when the tab was last updated
}
// Interface representing the state of tabs
interface TabsState {
activeTab: string | null
openTabs: string[]
tabsMap: { [key: string]: Tab }
previewTabId?: string
}
// Map of tab states for different references
interface TabsStateMap {
[ref: string]: TabsState
}
const STORAGE_KEY = 'supabase_studio_tabs'
const getStorageKey = (ref: string) => `${STORAGE_KEY}_${ref}`
const defaultState: TabsState = {
openTabs: [],
activeTab: null,
tabsMap: {},
}
// Store now holds states for multiple refs
export const tabsStore = proxy<TabsStateMap>({})
// Helper to get/create state for a specific ref
export const getTabsStore = (ref?: string): TabsState => {
if (!ref) return proxy(defaultState)
if (!tabsStore[ref]) {
const stored = localStorage.getItem(getStorageKey(ref))
console.debug('[Tabs] Loading stored tabs:', stored)
try {
const parsed = stored ? JSON.parse(stored) : defaultState
if (
!parsed.openTabs ||
!Array.isArray(parsed.openTabs) ||
!parsed.tabsMap ||
typeof parsed.tabsMap !== 'object'
) {
console.warn('[Tabs] Invalid stored data, using default')
tabsStore[ref] = proxy({ ...defaultState })
} else {
tabsStore[ref] = proxy(parsed)
}
} catch (error) {
console.error('[Tabs] Failed to parse stored tabs:', error)
tabsStore[ref] = proxy({ ...defaultState })
}
}
return tabsStore[ref]
}
export const useTabsStore = (ref?: string) => {
const store = getTabsStore(ref)
return useSnapshot(store)
}
// Subscribe to changes for each ref and save to localStorage
if (typeof window !== 'undefined') {
subscribe(tabsStore, () => {
Object.entries(tabsStore).forEach(([ref, state]) => {
try {
localStorage.setItem(getStorageKey(ref), JSON.stringify(state))
} catch (error) {
console.error('Failed to save tabs to localStorage:', error)
}
})
})
}
// Function to add a new tab to the store
export const addTab = (ref: string | undefined, tab: Tab) => {
if (!ref) return
const store = getTabsStore(ref)
// If tab exists and is active, don't do anything
if (store.tabsMap[tab.id] && store.activeTab === tab.id) {
return
}
// If tab exists but isn't active, just make it active
if (store.tabsMap[tab.id]) {
store.activeTab = tab.id
if (!tab.isPreview) addRecentItem(ref, tab)
return
}
// If this tab should be permanent, add it normally
if (tab.isPreview === false) {
store.openTabs = [...store.openTabs, tab.id]
store.tabsMap[tab.id] = tab
store.activeTab = tab.id
// Add to recent items when creating permanent tab
addRecentItem(ref, tab)
return
}
// Remove any existing preview tab
if (store.previewTabId) {
store.openTabs = store.openTabs.filter((id) => id !== store.previewTabId)
delete store.tabsMap[store.previewTabId]
}
// Add new preview tab
store.tabsMap[tab.id] = { ...tab, isPreview: true }
store.openTabs = [...store.openTabs, tab.id]
store.previewTabId = tab.id
store.activeTab = tab.id
}
// Function to remove a tab from the store
// this is used for removing tabs from the localstorage state
// for handling a manual tab removal with a close action, use handleTabClose()
export const removeTab = (ref: string | undefined, id: string) => {
const store = getTabsStore(ref)
const idx = store.openTabs.indexOf(id)
store.openTabs = store.openTabs.filter((tabId) => tabId !== id)
delete store.tabsMap[id]
// Update active tab if the removed tab was active
if (id === store.activeTab) {
store.activeTab = store.openTabs[idx - 1] || store.openTabs[idx + 1] || null
}
}
// Function to remove multiple tabs from the store
// this is used for removing tabs from the localstorage state
// for handling a manual tab removal with a close action, use handleTabClose()
export const removeTabs = (ref: string | undefined, ids: string[]) => {
if (!ref) return
if (!ids.length) return
ids.forEach((id) => removeTab(ref, id))
}
export const updateTab = (
ref: string,
id: string,
updates: { label?: string; scrollTop?: number }
) => {
const store = getTabsStore(ref)
if (!!store.tabsMap[id]) {
if ('label' in updates) {
store.tabsMap[id].label = updates.label
}
if ('scrollTop' in updates && store.tabsMap[id].metadata) {
store.tabsMap[id].metadata.scrollTop = updates.scrollTop
}
}
}
// Function to reorder tabs in the store
export const reorderTabs = (ref: string | undefined, oldIndex: number, newIndex: number) => {
if (!ref) return
const store = getTabsStore(ref)
const newOpenTabs = [...store.openTabs]
const [removedTab] = newOpenTabs.splice(oldIndex, 1)
newOpenTabs.splice(newIndex, 0, removedTab)
store.openTabs = newOpenTabs
}
// Function to make a tab permanent
export const makeTabPermanent = (ref: string | undefined, tabId: string) => {
if (!ref) return
const store = getTabsStore(ref)
const tab = store.tabsMap[tabId]
if (tab?.isPreview) {
tab.isPreview = false
store.previewTabId = undefined
// Add to recent items when preview tab becomes permanent
addRecentItem(ref, tab)
}
}
// make the active tab permanent if it is a preview
export const makeActiveTabPermanent = (ref?: string) => {
if (!ref) return false
const store = getTabsStore(ref)
if (store.activeTab && store.tabsMap[store.activeTab]?.isPreview) {
makeTabPermanent(ref, store.activeTab)
return true
}
return false
}
// handle navigation to a specific tab
export const handleTabNavigation = (ref: string | undefined, id: string, router: NextRouter) => {
if (!ref) return
const store = getTabsStore(ref)
const tab = store.tabsMap[id]
if (!tab) return
store.activeTab = id
// Add to recent items when navigating to a non-preview, non-new tab
if (!tab.isPreview) addRecentItem(ref, tab)
switch (tab.type) {
case 'sql':
const schema = (router.query.schema as string) || 'public'
router.push(`/project/${router.query.ref}/sql/${tab.metadata?.sqlId}?schema=${schema}`)
break
case 'r':
case 'v':
case 'm':
case 'f':
case 'p':
router.push(
`/project/${router.query.ref}/editor/${tab.metadata?.tableId}?schema=${tab.metadata?.schema}`
)
break
}
}
// Function to handle closing a tab
export const handleTabClose = ({
ref,
id,
router,
editor,
onClose,
onClearDashboardHistory,
}: {
ref: string | undefined
id: string
router: NextRouter
editor?: 'sql' | 'table'
onClose?: (id: string) => void
onClearDashboardHistory: () => void
}) => {
if (!ref) return
const store = getTabsStore(ref)
const tabBeingClosed = store.tabsMap[id]
const tabsAfterClosing = Object.values(store.tabsMap).filter((tab) => tab.id !== id)
const nextTabId = !editor
? undefined
: tabsAfterClosing.filter((tab) => {
return editorEntityTypes[editor]?.includes(tab.type)
})[0]?.id
const { [id]: value, ...otherTabs } = store.tabsMap
store.tabsMap = otherTabs
if (tabBeingClosed) {
const updatedOpenTabs = [...store.openTabs].filter((x) => x !== id)
store.openTabs = updatedOpenTabs
}
// Remove the preview tab if it matches the tab being closed
if (store.previewTabId === id) {
store.previewTabId = undefined
}
// [Joshen] Only navigate away if we're closing the tab that's currently in focus
if (store.activeTab === id || id === 'new') {
if (nextTabId) {
store.activeTab = nextTabId
handleTabNavigation(ref, nextTabId, router)
} else {
onClearDashboardHistory()
// If no tabs of same type, go to the home of the current section
switch (tabBeingClosed?.type) {
case 'sql':
router.push(`/project/${router.query.ref}/sql`)
break
case 'r':
case 'v':
case 'm':
case 'f':
case 'p':
router.push(`/project/${router.query.ref}/editor`)
break
default:
router.push(`/project/${router.query.ref}/${editor}`)
}
}
}
onClose?.(id)
}
// Function to handle the end of a tab drag event
export const handleTabDragEnd = (
ref: string | undefined,
oldIndex: number,
newIndex: number,
tabId: string,
router: any
) => {
if (!ref) return
const store = getTabsStore(ref)
// Make permanent if needed
const draggedTab = store.tabsMap[tabId]
if (draggedTab?.isPreview) {
makeTabPermanent(ref, tabId)
}
// Reorder tabs
const newOpenTabs = [...store.openTabs]
newOpenTabs.splice(oldIndex, 1)
newOpenTabs.splice(newIndex, 0, tabId)
store.openTabs = newOpenTabs
store.activeTab = tabId
// Handle navigation
handleTabNavigation(ref, tabId, router)
}
// Function to create a unique tab ID based on type and parameters
export function createTabId<T extends TabType>(type: T, params: CreateTabIdParams[T]): string {
switch (type) {
case 'r':
return `r-${(params as CreateTabIdParams['r']).id}`
case 'v':
return `v-${(params as CreateTabIdParams['v']).id}`
case 'm':
return `m-${(params as CreateTabIdParams['m']).id}`
case 'f':
return `f-${(params as CreateTabIdParams['f']).id}`
case 'p':
return `p-${(params as CreateTabIdParams['p']).id}`
case 'sql':
return `sql-${(params as CreateTabIdParams['sql']).id}`
default:
return ''
}
}
// Function to remove tabs based on their editor type
export function removeTabsByEditor(ref: string | undefined, type: 'table' | 'sql') {
if (!ref) return
const store = getTabsStore(ref)
// Get tab IDs to remove
const tabIdsToRemove = store.openTabs.filter((id) => {
return editorEntityTypes[type].some((entityType) => id.startsWith(`${entityType}-`))
})
removeTabs(ref, tabIdsToRemove)
}
+471
View File
@@ -0,0 +1,471 @@
import { useConstant } from 'common'
import { ENTITY_TYPE } from 'data/entity-types/entity-type-constants'
import { partition } from 'lodash'
import { NextRouter } from 'next/router'
import { createContext, PropsWithChildren, ReactNode, useContext, useEffect } from 'react'
import { proxy, subscribe, useSnapshot } from 'valtio'
export const editorEntityTypes = {
table: ['r', 'v', 'm', 'f', 'p'],
sql: ['sql'],
}
export type TabType = ENTITY_TYPE | 'sql'
type CreateTabIdParams = {
r: { id: number }
v: { id: number }
m: { id: number }
f: { id: number }
p: { id: number }
sql: { id: string }
schema: { schema: string }
view: never
function: never
new: never
}
export interface Tab {
id: string
type: TabType
label?: string
icon?: ReactNode
metadata?: {
schema?: string
name?: string
tableId?: number
sqlId?: string
scrollTop?: number
}
isPreview?: boolean
createdAt?: Date
updatedAt?: Date
}
const MAX_RECENT_ITEMS = 8
export interface RecentItem {
id: string
type: TabType
label: string
timestamp: number
metadata?: {
schema?: string
name?: string
tableId?: number
sqlId?: string
}
}
const RECENT_ITEMS_STORAGE_KEY = 'supabase_recent_items'
const getRecentItemsStorageKey = (ref: string) => `${RECENT_ITEMS_STORAGE_KEY}_${ref}`
function getSavedRecentItems(ref: string): RecentItem[] {
if (typeof window === 'undefined' || !ref) return []
const stored = localStorage.getItem(getRecentItemsStorageKey(ref))
try {
return JSON.parse(stored ?? '{"items": []}').items
} catch (error) {
return []
}
}
const DEFAULT_TABS_STATE = {
activeTab: null as string | null,
openTabs: [] as string[],
tabsMap: {} as { [key: string]: Tab },
previewTabId: undefined as string | undefined,
}
const TABS_STORAGE_KEY = 'supabase_studio_tabs'
const getTabsStorageKey = (ref: string) => `${TABS_STORAGE_KEY}_${ref}`
function getSavedTabs(ref: string) {
if (typeof window === 'undefined' || !ref) return DEFAULT_TABS_STATE
const stored = localStorage.getItem(getTabsStorageKey(ref))
if (!stored) return DEFAULT_TABS_STATE
try {
const parsed = JSON.parse(
stored ?? JSON.stringify(DEFAULT_TABS_STATE)
) as typeof DEFAULT_TABS_STATE
if (
!parsed.openTabs ||
!Array.isArray(parsed.openTabs) ||
!parsed.tabsMap ||
typeof parsed.tabsMap !== 'object'
) {
return DEFAULT_TABS_STATE
}
return parsed
} catch (error) {
return DEFAULT_TABS_STATE
}
}
function createTabsState(projectRef: string) {
const recentItems = getSavedRecentItems(projectRef)
const { openTabs, activeTab, tabsMap, previewTabId } = getSavedTabs(projectRef)
const store = proxy({
// RECENT ITEMS
recentItems,
addRecentItem: (tab: Tab) => {
// Check if an item with the same ID already exists
const existingItem = store.recentItems.find((item) => item.id === tab.id)
if (existingItem) {
// If it exists, update its timestamp
existingItem.timestamp = Date.now()
return // Exit the function
}
// If it doesn't exist, create and add a new item
const recentItem: RecentItem = {
id: tab.id, // Set the ID
type: tab.type, // Set the type
label: tab.label || 'Untitled', // Set the label or default to 'Untitled'
timestamp: Date.now(), // Set the current timestamp
metadata: tab.metadata, // Set the metadata
}
// Add the new recent item to the beginning of the list
store.recentItems.unshift(recentItem)
// Ensure that there's only up to max of MAX_RECENT_ITEMS items per tab type
const [itemsOfSameType, itemsOfDifferentType] = partition(store.recentItems, (item) => {
if (editorEntityTypes.table.includes(item.type)) return item
})
store.recentItems = [...itemsOfSameType.slice(0, MAX_RECENT_ITEMS), ...itemsOfDifferentType]
},
clearRecentItems: () => {
store.recentItems = []
},
removeRecentItem: (itemId: string) => {
store.recentItems = store.recentItems.filter((item) => item.id !== itemId)
},
removeRecentItems: (itemIds: string[]) => {
store.recentItems = store.recentItems.filter((item) => !itemIds.includes(item.id))
},
removeRecentItemsByType: (type: TabType) => {
store.recentItems = store.recentItems.filter((item) => item.type !== type)
},
getRecentItemsByType: (type: TabType) => {
return store.recentItems.filter((item) => item.type === type)
},
// TABS
activeTab,
openTabs,
tabsMap,
previewTabId,
addTab: (tab: Tab) => {
// If tab exists and is active, don't do anything
if (store.tabsMap[tab.id] && store.activeTab === tab.id) {
return
}
// If tab exists but isn't active, just make it active
if (store.tabsMap[tab.id]) {
store.activeTab = tab.id
if (!tab.isPreview) store.addRecentItem(tab)
return
}
// If this tab should be permanent, add it normally
if (tab.isPreview === false) {
store.openTabs = [...store.openTabs, tab.id]
store.tabsMap[tab.id] = tab
store.activeTab = tab.id
// Add to recent items when creating permanent tab
store.addRecentItem(tab)
return
}
// Remove any existing preview tab
if (store.previewTabId) {
store.openTabs = store.openTabs.filter((id) => id !== store.previewTabId)
delete store.tabsMap[store.previewTabId]
}
// Add new preview tab
store.tabsMap[tab.id] = { ...tab, isPreview: true }
store.openTabs = [...store.openTabs, tab.id]
store.previewTabId = tab.id
store.activeTab = tab.id
},
updateTab: (id: string, updates: { label?: string; scrollTop?: number }) => {
if (!!store.tabsMap[id]) {
if ('label' in updates) {
store.tabsMap[id].label = updates.label
}
if ('scrollTop' in updates && store.tabsMap[id].metadata) {
store.tabsMap[id].metadata.scrollTop = updates.scrollTop
}
}
},
// Function to remove a tab from the store
// this is used for removing tabs from the localstorage state
// for handling a manual tab removal with a close action, use handleTabClose()
removeTab: (id: string) => {
const idx = store.openTabs.indexOf(id)
store.openTabs = store.openTabs.filter((tabId) => tabId !== id)
delete store.tabsMap[id]
// Update active tab if the removed tab was active
if (id === store.activeTab) {
store.activeTab = store.openTabs[idx - 1] || store.openTabs[idx + 1] || null
}
},
// Function to remove multiple tabs from the store
// this is used for removing tabs from the localstorage state
// for handling a manual tab removal with a close action, use handleTabClose()
removeTabs: (ids: string[]) => {
if (!ids.length) return
ids.forEach((id) => store.removeTab(id))
},
reorderTabs: (oldIndex: number, newIndex: number) => {
const newOpenTabs = [...store.openTabs]
const [removedTab] = newOpenTabs.splice(oldIndex, 1)
newOpenTabs.splice(newIndex, 0, removedTab)
store.openTabs = newOpenTabs
},
makeTabActive: (tabId: string) => {
const tab = store.tabsMap[tabId]
if (!tab) return
store.activeTab = tab.id
},
makeTabPermanent: (tabId: string) => {
const tab = store.tabsMap[tabId]
if (tab?.isPreview) {
tab.isPreview = false
store.previewTabId = undefined
// Add to recent items when preview tab becomes permanent
store.addRecentItem(tab)
}
},
makeActiveTabPermanent: () => {
if (store.activeTab && store.tabsMap[store.activeTab]?.isPreview) {
store.makeTabPermanent(store.activeTab)
return true
}
return false
},
// TABS HANDLERS
handleTabNavigation: (id: string, router: NextRouter) => {
const tab = store.tabsMap[id]
if (!tab) return
store.activeTab = id
// Add to recent items when navigating to a non-preview, non-new tab
if (!tab.isPreview) store.addRecentItem(tab)
switch (tab.type) {
case 'sql':
const schema = (router.query.schema as string) || 'public'
router.push(`/project/${router.query.ref}/sql/${tab.metadata?.sqlId}?schema=${schema}`)
break
case 'r':
case 'v':
case 'm':
case 'f':
case 'p':
router.push(
`/project/${router.query.ref}/editor/${tab.metadata?.tableId}?schema=${tab.metadata?.schema}`
)
break
}
},
handleTabClose: ({
id,
router,
editor,
onClose,
onClearDashboardHistory,
}: {
id: string
router: NextRouter
editor?: 'sql' | 'table'
onClose?: (id: string) => void
onClearDashboardHistory: () => void
}) => {
const tabBeingClosed = store.tabsMap[id]
const tabsAfterClosing = Object.values(store.tabsMap).filter((tab) => tab.id !== id)
const nextTabId = !editor
? undefined
: tabsAfterClosing.filter((tab) => {
return editorEntityTypes[editor]?.includes(tab.type)
})[0]?.id
const { [id]: value, ...otherTabs } = store.tabsMap
store.tabsMap = otherTabs
if (tabBeingClosed) {
const updatedOpenTabs = [...store.openTabs].filter((x) => x !== id)
store.openTabs = updatedOpenTabs
}
// Remove the preview tab if it matches the tab being closed
if (store.previewTabId === id) {
store.previewTabId = undefined
}
// [Joshen] Only navigate away if we're closing the tab that's currently in focus
if (store.activeTab === id || id === 'new') {
if (nextTabId) {
store.activeTab = nextTabId
store.handleTabNavigation(nextTabId, router)
} else {
onClearDashboardHistory()
// If no tabs of same type, go to the home of the current section
switch (tabBeingClosed?.type) {
case 'sql':
router.push(`/project/${router.query.ref}/sql`)
break
case 'r':
case 'v':
case 'm':
case 'f':
case 'p':
router.push(`/project/${router.query.ref}/editor`)
break
default:
router.push(`/project/${router.query.ref}/${editor}`)
}
}
}
onClose?.(id)
},
handleTabDragEnd: (oldIndex: number, newIndex: number, tabId: string, router: any) => {
// Make permanent if needed
const draggedTab = store.tabsMap[tabId]
if (draggedTab?.isPreview) {
store.makeTabPermanent(tabId)
}
// Reorder tabs
const newOpenTabs = [...store.openTabs]
newOpenTabs.splice(oldIndex, 1)
newOpenTabs.splice(newIndex, 0, tabId)
store.openTabs = newOpenTabs
store.activeTab = tabId
// Handle navigation
store.handleTabNavigation(tabId, router)
},
})
return store
}
export type TabsState = ReturnType<typeof createTabsState>
export const TabsStateContext = createContext<TabsState>(createTabsState(''))
export const TabsStateContextProvider = ({
projectRef,
children,
}: PropsWithChildren<{ projectRef?: string }>) => {
const state = useConstant(() => createTabsState(projectRef ?? ''))
useEffect(() => {
if (typeof window !== 'undefined' && projectRef) {
return subscribe(state, () => {
localStorage.setItem(
getTabsStorageKey(projectRef),
JSON.stringify({
activeTab: state.activeTab,
openTabs: state.openTabs,
tabsMap: state.tabsMap,
previewTabId: state.previewTabId,
})
)
localStorage.setItem(
getRecentItemsStorageKey(projectRef),
JSON.stringify({
items: state.recentItems,
})
)
})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return <TabsStateContext.Provider value={state}>{children}</TabsStateContext.Provider>
}
export const useTabsStateSnapshot = (options?: Parameters<typeof useSnapshot>[1]) => {
const state = useContext(TabsStateContext)
return useSnapshot(state, options)
}
export function createTabId<T extends TabType>(type: T, params: CreateTabIdParams[T]): string {
switch (type) {
case 'r':
return `r-${(params as CreateTabIdParams['r']).id}`
case 'v':
return `v-${(params as CreateTabIdParams['v']).id}`
case 'm':
return `m-${(params as CreateTabIdParams['m']).id}`
case 'f':
return `f-${(params as CreateTabIdParams['f']).id}`
case 'p':
return `p-${(params as CreateTabIdParams['p']).id}`
case 'sql':
return `sql-${(params as CreateTabIdParams['sql']).id}`
default:
return ''
}
}
// Remove from local storage when feature flag is disabled
export function removeTabsByEditor(ref: string, editor: 'table' | 'sql') {
// Recent items
const recentItems = getSavedRecentItems(ref)
const filteredRecentItems = recentItems.filter((item) =>
editor === 'sql' ? item.type !== 'sql' : item.type === 'sql'
)
localStorage.setItem(
getRecentItemsStorageKey(ref),
JSON.stringify({ items: filteredRecentItems })
)
// Tabs
const tabs = getSavedTabs(ref)
const filteredTabsMap = Object.fromEntries(
Object.entries(tabs.tabsMap).filter(([, tab]) =>
editor === 'sql' ? tab.type !== 'sql' : tab.type === 'sql'
)
)
const filteredOpenTabs = tabs.openTabs.filter((tabId) => filteredTabsMap[tabId])
localStorage.setItem(
getTabsStorageKey(ref),
JSON.stringify({
activeTab: filteredOpenTabs.includes(tabs.activeTab ?? '')
? tabs.activeTab
: filteredOpenTabs[0] ?? null,
openTabs: filteredOpenTabs,
tabsMap: filteredTabsMap,
previewTabId: tabs.previewTabId,
})
)
}
@@ -16,9 +16,9 @@ tags:
date: '2025-05-08'
---
Event triggers in Postgres are powerful, but only a superuser can create them. In cloud environments, granting superuser access isn't an option.
Event Triggers in Postgres are powerful, but only a superuser can create them. In cloud environments, granting superuser access isn't an option.
But thanks to Postgres' extensibility, we can allow regular users to create event triggers, in a safe way.
But thanks to Postgres' extensibility, we can allow regular users to create Event Triggers, in a safe way.
In this blog post, we’ll explain how we do this in the `supautils` extension, using a combination of the Utility Hook and the Function Manager Hook.
@@ -36,12 +36,12 @@ Creating an event trigger like this is not safe though, as it would allow privil
## The privilege escalation problem
Here, a problem arises. Once an event trigger is created:
Here, a problem arises. Once an Event Trigger is created:
- It targets every role.
- It runs using the target role privileges [^2].
This means that a malicious user can create an event trigger like:
This means that a malicious user can create an Event Trigger like:
```sql
create or replace function become_super()
@@ -63,17 +63,17 @@ And once a superuser trips on the event trigger, it will fire with its privilege
![fmgr hook](/images/blog/2025-05-08-event-triggers-wo-superuser/fmgr-hook.png)
A solution would be skipping user event triggers for superusers.
A solution would be skipping user Event Triggers for superusers.
The Function Manager hook (`fmgr_hook`) allows us to intercept and modify functions’ execution.
We can intercept the event trigger function and replace it with a “noop”. Postgres doesn’t provide a noop function, but we can use the existing `version()` function for the same purpose.
We can intercept the Event Trigger function and replace it with a “noop”. Postgres doesn’t provide a noop function, but we can use the existing `version()` function for the same purpose.
Besides superusers, we also want to skip user event triggers for “reserved roles” [^3]. These are used for managed services (like `pgbouncer`).
## User Event Triggers
This now allows users to safely create event triggers, without superuser access:
This now allows users to safely create Event Triggers, without superuser access:
```sql
-- use the privileged role, which is configured to be "postgres"
@@ -108,15 +108,17 @@ NOTICE: the event trigger is executed for myrole
We would also like to allow regular user event triggers in Postgres core. To this end, we’ve submitted [some patches](https://www.postgresql.org/message-id/flat/CAGRrpzbtYDkg7_xwfzrqByYgCJQbbL38tADyuN%2B6tAkbA-Pnkg%40mail.gmail.com) which are already generating fruitful discussion.
Note that user event triggers in Postgres core will likely be more restricted than the `supautils` version.
Note that user Event Triggers in Postgres core will likely be more restricted than the `supautils` version.
## Try it out
User event triggers are now available for new projects on the Supabase platform.
User Event Triggers are now available for new projects on the Supabase platform.
You can also git clone the [supautils repo](https://github.com/supabase/supautils/) and `make install` it in your own deployment.
Finally, we want to give a special shout out to the [Zero Sync](https://zero.rocicorp.dev) guys, which kept pushing us to release this feature.
Finally, we want to give a special shout out to the [Zero Sync](https://zero.rocicorp.dev) team, who pushed us to release this feature.
---
[^1]: This is so the event trigger can be altered or dropped by end users.
[^2]: This is not true if you mark the event trigger function as `security definer`, then it will run with the privileges of the function owner. But this is not a usual practice on event triggers, as they usually want to preserve the context of the current user.
+158 -81
View File
@@ -187,7 +187,10 @@ const typeIdent = (type: string) => {
}
function update(
id: string,
old: Pick<
PGColumn,
'name' | 'schema' | 'table' | 'table_id' | 'ordinal_position' | 'is_identity' | 'is_unique'
>,
{
name,
type,
@@ -214,102 +217,176 @@ function update(
check?: string | null
}
): { sql: string } {
if (is_identity) {
if (default_value !== undefined) {
throw new Error('Columns cannot both be identity and have a default value')
}
const nameSql =
name === undefined || name === old.name
? ''
: `ALTER TABLE ${ident(old.schema)}.${ident(old.table)} RENAME COLUMN ${ident(
old.name
)} TO ${ident(name)};`
// We use USING to allow implicit conversion of incompatible types (e.g. int4 -> text).
const typeSql =
type === undefined
? ''
: `ALTER TABLE ${ident(old.schema)}.${ident(old.table)} ALTER COLUMN ${ident(
old.name
)} SET DATA TYPE ${typeIdent(type)} USING ${ident(old.name)}::${typeIdent(type)};`
let defaultValueSql: string
if (drop_default) {
defaultValueSql = `ALTER TABLE ${ident(old.schema)}.${ident(
old.table
)} ALTER COLUMN ${ident(old.name)} DROP DEFAULT;`
} else if (default_value === undefined) {
defaultValueSql = ''
} else {
const defaultValue =
default_value_format === 'expression' ? default_value : literal(default_value)
defaultValueSql = `ALTER TABLE ${ident(old.schema)}.${ident(
old.table
)} ALTER COLUMN ${ident(old.name)} SET DEFAULT ${defaultValue};`
}
const sql = `
// What identitySql does vary depending on the old and new values of
// is_identity and identity_generation.
//
// | is_identity: old \ new | undefined | true | false |
// |------------------------+--------------------+--------------------+----------------|
// | true | maybe set identity | maybe set identity | drop if exists |
// |------------------------+--------------------+--------------------+----------------|
// | false | - | add identity | drop if exists |
let identitySql = `ALTER TABLE ${ident(old.schema)}.${ident(old.table)} ALTER COLUMN ${ident(
old.name
)}`
if (is_identity === false) {
identitySql += ' DROP IDENTITY IF EXISTS;'
} else if (old.is_identity === true) {
if (identity_generation === undefined) {
identitySql = ''
} else {
identitySql += ` SET GENERATED ${identity_generation};`
}
} else if (is_identity === undefined) {
identitySql = ''
} else {
identitySql += ` ADD GENERATED ${identity_generation} AS IDENTITY;`
}
let isNullableSql: string
if (is_nullable === undefined) {
isNullableSql = ''
} else {
isNullableSql = is_nullable
? `ALTER TABLE ${ident(old.schema)}.${ident(old.table)} ALTER COLUMN ${ident(
old.name
)} DROP NOT NULL;`
: `ALTER TABLE ${ident(old.schema)}.${ident(old.table)} ALTER COLUMN ${ident(
old.name
)} SET NOT NULL;`
}
let isUniqueSql = ''
if (old.is_unique === true && is_unique === false) {
isUniqueSql = `
DO $$
DECLARE
v_schema name;
v_table name;
v_column name;
v_attnum int2;
r record;
BEGIN
WITH RECURSIVE column_info AS (
${COLUMNS_SQL}
)
SELECT
schema,
${ident('table')},
name,
ordinal_position::int2
INTO v_schema, v_table, v_column, v_attnum
FROM column_info
WHERE ${getIdentifierWhereClause({ id })};
FOR r IN
SELECT conname FROM pg_constraint WHERE
contype = 'u'
AND cardinality(conkey) = 1
AND conrelid = ${literal(old.table_id)}
AND conkey[1] = ${literal(old.ordinal_position)}
LOOP
EXECUTE ${literal(
`ALTER TABLE ${ident(old.schema)}.${ident(old.table)} DROP CONSTRAINT `
)} || quote_ident(r.conname);
END LOOP;
END
$$;
`
} else if (old.is_unique === false && is_unique === true) {
isUniqueSql = `ALTER TABLE ${ident(old.schema)}.${ident(old.table)} ADD UNIQUE (${ident(
old.name
)});`
}
const commentSql =
comment === undefined
? ''
: `COMMENT ON COLUMN ${ident(old.schema)}.${ident(old.table)}.${ident(
old.name
)} IS ${literal(comment)};`
IF v_schema IS NULL THEN
RAISE EXCEPTION 'Column with id % not found', ${literal(id)};
const checkSql =
check === undefined
? ''
: `
DO $$
DECLARE
v_conname name;
v_conkey int2[];
BEGIN
SELECT conname into v_conname FROM pg_constraint WHERE
contype = 'c'
AND cardinality(conkey) = 1
AND conrelid = ${literal(old.table_id)}
AND conkey[1] = ${literal(old.ordinal_position)}
ORDER BY oid asc
LIMIT 1;
IF v_conname IS NOT NULL THEN
EXECUTE format('ALTER TABLE ${ident(old.schema)}.${ident(
old.table
)} DROP CONSTRAINT %s', v_conname);
END IF;
${is_nullable !== undefined ? `execute(format('ALTER TABLE %I.%I ALTER COLUMN %I ${is_nullable ? 'DROP NOT NULL' : 'SET NOT NULL'}', v_schema, v_table, v_column));` : ''}
${type ? `execute(format('ALTER TABLE %I.%I ALTER COLUMN %I TYPE %I USING %I::%I', v_schema, v_table, v_column, ${literal(type)}, v_column, ${literal(type)}));` : ''}
${drop_default ? `execute(format('ALTER TABLE %I.%I ALTER COLUMN %I DROP DEFAULT', v_schema, v_table, v_column));` : ''}
${default_value !== undefined ? `execute(format('ALTER TABLE %I.%I ALTER COLUMN %I SET DEFAULT %s', v_schema, v_table, v_column, ${default_value_format === 'expression' ? default_value : literal(default_value)}));` : ''}
${is_identity !== undefined ? `execute(format('ALTER TABLE %I.%I ALTER COLUMN %I ${is_identity ? `ADD GENERATED ${identity_generation} AS IDENTITY` : 'DROP IDENTITY IF EXISTS'}', v_schema, v_table, v_column));` : ''}
${is_unique !== undefined ? `execute(format('ALTER TABLE %I.%I ${is_unique ? 'ADD UNIQUE' : 'DROP CONSTRAINT IF EXISTS %I_key'}', v_schema, v_table, ${is_unique ? `(${literal(name || 'v_column')})` : 'v_column'}));` : ''}
${comment !== undefined ? `execute(format('COMMENT ON COLUMN %I.%I.%I IS %L', v_schema, v_table, v_column, ${literal(comment)}));` : ''}
${
check !== undefined
check !== null
? `
-- Drop existing check constraint if any
FOR r IN (
SELECT conname
FROM pg_constraint
WHERE conrelid = format('%I.%I', v_schema, v_table)::regclass
AND contype = 'c'
AND conkey = ARRAY[v_attnum]
) LOOP
EXECUTE format('ALTER TABLE %I.%I DROP CONSTRAINT %I', v_schema, v_table, r.conname);
END LOOP;
-- Create the new constraints if mentionned
${check !== null ? `execute(format('ALTER TABLE %I.%I ADD CONSTRAINT %I CHECK (%s)', v_schema, v_table, v_column || '_check', ${literal(check)}));` : ''}
`
ALTER TABLE ${ident(old.schema)}.${ident(old.table)} ADD CONSTRAINT ${ident(
`${old.table}_${old.name}_check`
)} CHECK (${check});
SELECT conkey into v_conkey FROM pg_constraint WHERE conname = ${literal(
`${old.table}_${old.name}_check`
)};
ASSERT v_conkey IS NOT NULL, 'error creating column constraint: check condition must refer to this column';
ASSERT cardinality(v_conkey) = 1, 'error creating column constraint: check condition cannot refer to multiple columns';
ASSERT v_conkey[1] = ${literal(
old.ordinal_position
)}, 'error creating column constraint: check condition cannot refer to other columns';
`
: ''
}
${name ? `execute(format('ALTER TABLE %I.%I RENAME COLUMN %I TO %I', v_schema, v_table, v_column, ${literal(name)}));` : ''}
END $$;`
END
$$;
`
// TODO: Can't set default if column is previously identity even if
// is_identity: false. Must do two separate PATCHes (once to drop identity
// and another to set default).
// NOTE: nameSql must be last. defaultValueSql must be after typeSql.
// identitySql must be after isNullableSql.
const sql = `
BEGIN;
${isNullableSql}
${typeSql}
${defaultValueSql}
${identitySql}
${isUniqueSql}
${commentSql}
${checkSql}
${nameSql}
COMMIT;`
return { sql }
}
function remove(id: string, { cascade = false } = {}): { sql: string } {
const sql = `
DO $$
DECLARE
v_schema name;
v_table name;
v_column name;
BEGIN
WITH RECURSIVE column_info AS (
${COLUMNS_SQL}
)
SELECT schema, ${ident('table')}, name
INTO v_schema, v_table, v_column
FROM column_info
WHERE id = ${literal(id)};
IF v_schema IS NULL THEN
RAISE EXCEPTION 'Column with id % not found', ${literal(id)};
END IF;
EXECUTE format(
'ALTER TABLE %I.%I DROP COLUMN %I ${cascade ? 'CASCADE' : 'RESTRICT'}',
v_schema,
v_table,
v_column
);
END $$;`
function remove(
column: Pick<PGColumn, 'name' | 'schema' | 'table'>,
{ cascade = false } = {}
): { sql: string } {
const sql = `ALTER TABLE ${ident(column.schema)}.${ident(column.table)} DROP COLUMN ${ident(
column.name
)} ${cascade ? 'CASCADE' : 'RESTRICT'};`
return { sql }
}
+5 -5
View File
@@ -226,7 +226,7 @@ withTestDatabase('retrieve, create, update, delete column', async ({ executeQuer
)
// Update column
const { sql: updateSql } = await pgMeta.columns.update(column!.id, {
const { sql: updateSql } = await pgMeta.columns.update(column!, {
name: 'c1',
type: 'int4',
drop_default: true,
@@ -274,7 +274,7 @@ withTestDatabase('retrieve, create, update, delete column', async ({ executeQuer
)
// Remove column
const { sql: removeSql } = await pgMeta.columns.remove(column!.id)
const { sql: removeSql } = await pgMeta.columns.remove(column!)
await executeQuery(removeSql)
// Verify column was removed
@@ -591,7 +591,7 @@ withTestDatabase('alter column to type with uppercase', async ({ executeQuery })
const column = retrieveZod.parse((await executeQuery(retrieveSql))[0])
// Update column
const { sql: updateSql } = await pgMeta.columns.update(column!.id, { type: 'T' })
const { sql: updateSql } = await pgMeta.columns.update(column!, { type: 'T' })
await executeQuery(updateSql)
// Verify updated column
@@ -704,7 +704,7 @@ withTestDatabase('drop with cascade', async ({ executeQuery }) => {
const column = retrieveZod.parse((await executeQuery(retrieveSql))[0])
// Remove column with cascade
const { sql: removeSql } = await pgMeta.columns.remove(column!.id, { cascade: true })
const { sql: removeSql } = await pgMeta.columns.remove(column!, { cascade: true })
await executeQuery(removeSql)
// Verify original column was removed
@@ -811,7 +811,7 @@ withTestDatabase('dropping column checks', async ({ executeQuery }) => {
const column = retrieveZod.parse((await executeQuery(retrieveSql))[0])
// Update column to remove check
const { sql: updateSql } = await pgMeta.columns.update(column!.id, { check: null })
const { sql: updateSql } = await pgMeta.columns.update(column!, { check: null })
await executeQuery(updateSql)
// Verify updated column