mirror of
https://github.com/supabase/supabase.git
synced 2026-10-15 06:09:08 +03:00
Merge branch 'master' into qiao/table
This commit is contained in:
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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
+4
-2
@@ -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 > 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>
|
||||
|
||||
+2
-4
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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">
|
||||
|
||||
+4
-3
@@ -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 })
|
||||
}
|
||||
|
||||
+16
-15
@@ -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]
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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' },
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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' },
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user