CreateVectorTableSheet.tsx407 lines · main
1import { zodResolver } from '@hookform/resolvers/zod'
2import { literal, safeSql } from '@supabase/pg-meta'
3import { PermissionAction } from '@supabase/shared-types/out/constants'
4import { Plus, Trash2 } from 'lucide-react'
5import { parseAsBoolean, useQueryState } from 'nuqs'
6import { useEffect } from 'react'
7import { SubmitHandler, useFieldArray, useForm } from 'react-hook-form'
8import { toast } from 'sonner'
9import {
10 Button,
11 Form,
12 FormControl,
13 FormField,
14 Input,
15 RadioGroupStacked,
16 RadioGroupStackedItem,
17 Separator,
18 Sheet,
19 SheetContent,
20 SheetFooter,
21 SheetHeader,
22 SheetSection,
23 SheetTitle,
24 SheetTrigger,
25} from 'ui'
26import { Admonition } from 'ui-patterns'
27import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
28import z from 'zod'
29
30import { inverseValidBucketNameRegex } from '../CreateBucketModal.utils'
31import { useS3VectorsWrapperInstance } from './useS3VectorsWrapperInstance'
32import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
33import { DocsButton } from '@/components/ui/DocsButton'
34import { useFDWImportForeignSchemaMutation } from '@/data/fdw/fdw-import-foreign-schema-mutation'
35import { useVectorBucketIndexCreateMutation } from '@/data/storage/vector-bucket-index-create-mutation'
36import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
37import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
38import { DOCS_URL } from '@/lib/constants'
39
40const isStagingLocal = process.env.NEXT_PUBLIC_ENVIRONMENT !== 'prod'
41
42const BUCKET_INDEX_NAME_REGEX = /^[a-z0-9](?:[a-z0-9.-]{1,61})?[a-z0-9]$/
43
44const DISTANCE_METRICS = [
45 {
46 value: 'cosine',
47 label: 'Cosine',
48 description: 'Measures similarity between two vectors, based on directions, not magnitude.',
49 },
50 {
51 value: 'euclidean',
52 label: 'Euclidean',
53 description:
54 'Measures straight-line distance between two vectors, using both directions and magnitudes.',
55 },
56] as const
57
58const FormSchema = z.object({
59 name: z
60 .string()
61 .trim()
62 .min(3, 'Name must be at least 3 characters')
63 .max(63, 'Name must be below 63 characters')
64 .refine(
65 (value) => value !== 'public',
66 '"public" is a reserved name. Please choose another name'
67 )
68 .superRefine((name, ctx) => {
69 if (!BUCKET_INDEX_NAME_REGEX.test(name)) {
70 const [match] = name.match(inverseValidBucketNameRegex) ?? []
71 ctx.addIssue({
72 path: [],
73 code: z.ZodIssueCode.custom,
74 message: !!match
75 ? `Bucket name cannot contain the "${match}" character`
76 : 'Bucket name contains an invalid special character',
77 })
78 }
79 }),
80 dimension: z
81 .number()
82 .int('Dimension must be an integer')
83 .min(1, 'Dimension must be at least 1')
84 .max(4096, 'Dimension must be at most 4096'),
85 distanceMetric: z.enum(['cosine', 'euclidean'], {
86 required_error: 'Please select a distance metric',
87 }),
88 metadataKeys: z
89 .array(
90 z.object({
91 value: z.string().min(1, 'The metadata key needs to be at least 1 character long'),
92 })
93 )
94 .default([]),
95})
96
97const formId = 'create-vector-table-form'
98
99export type CreateVectorTableForm = z.infer<typeof FormSchema>
100
101interface CreateVectorTableSheetProps {
102 bucketName?: string
103}
104
105export const CreateVectorTableSheet = ({ bucketName }: CreateVectorTableSheetProps) => {
106 const { data: project } = useSelectedProjectQuery()
107
108 const [visible, setVisible] = useQueryState(
109 'newTable',
110 parseAsBoolean.withDefault(false).withOptions({ history: 'push', clearOnDefault: true })
111 )
112 const { can: canCreateBuckets } = useAsyncCheckPermissions(PermissionAction.STORAGE_WRITE, '*')
113
114 const { data: wrapperInstance } = useS3VectorsWrapperInstance({ bucketId: bucketName })
115 const schema = (wrapperInstance?.server_options ?? [])
116 .find((x) => x.startsWith('briven_target_schema'))
117 ?.split('briven_target_schema=')[1]
118
119 // [Joshen] Can remove this once this restriction is removed
120 const showIndexCreationNotice = isStagingLocal && !!project && project?.region !== 'us-east-1'
121
122 const defaultValues = {
123 name: '',
124 dimension: undefined,
125 distanceMetric: 'cosine' as 'cosine' | 'euclidean',
126 metadataKeys: [],
127 }
128 const form = useForm<CreateVectorTableForm>({
129 resolver: zodResolver(FormSchema as any),
130 defaultValues,
131 values: defaultValues as any,
132 })
133
134 const { fields, append, remove } = useFieldArray({
135 control: form.control,
136 name: 'metadataKeys',
137 })
138
139 const { mutateAsync: createVectorBucketTable, isPending: isCreatingVectorBucketTable } =
140 useVectorBucketIndexCreateMutation()
141
142 const { mutateAsync: importForeignSchema, isPending: isImportingForeignSchema } =
143 useFDWImportForeignSchemaMutation({
144 onError: () => {},
145 })
146 const isCreating = isCreatingVectorBucketTable || isImportingForeignSchema
147
148 const onSubmit: SubmitHandler<CreateVectorTableForm> = async (values) => {
149 if (!project?.ref) return console.error('Project ref is required')
150 if (!bucketName) return console.error('Bucket name is required')
151
152 try {
153 await createVectorBucketTable({
154 projectRef: project.ref,
155 bucketName: bucketName,
156 indexName: values.name,
157 dataType: 'float32',
158 dimension: values.dimension!,
159 distanceMetric: values.distanceMetric,
160 metadataKeys: values.metadataKeys.map((key) => key.value),
161 })
162 } catch (error: any) {
163 toast.error(`Failed to create vector table: ${error.message}`)
164 return
165 }
166
167 try {
168 if (wrapperInstance && !!schema) {
169 await importForeignSchema({
170 projectRef: project.ref,
171 connectionString: project?.connectionString,
172 serverName: wrapperInstance.server_name,
173 sourceSchema: schema,
174 targetSchema: schema,
175 schemaOptions: [safeSql`bucket_name ${literal(bucketName)}`],
176 })
177 }
178 } catch (error: any) {
179 toast.warning(`Failed to connect vector table to the database: ${error.message}`)
180 }
181
182 toast.success(`Successfully created vector table “${values.name}”`)
183 form.reset()
184
185 setVisible(false)
186 }
187
188 useEffect(() => {
189 if (!visible) {
190 form.reset()
191 }
192 }, [visible])
193
194 return (
195 <Sheet open={visible} onOpenChange={setVisible}>
196 <SheetTrigger asChild>
197 <ButtonTooltip
198 block
199 size="tiny"
200 type="primary"
201 className="w-fit"
202 icon={<Plus size={14} />}
203 disabled={!canCreateBuckets}
204 onClick={() => setVisible(true)}
205 tooltip={{
206 content: {
207 side: 'bottom',
208 text: !canCreateBuckets
209 ? 'You need additional permissions to create buckets'
210 : undefined,
211 },
212 }}
213 >
214 Create table
215 </ButtonTooltip>
216 </SheetTrigger>
217
218 <SheetContent size="default" className="flex flex-col gap-0 p-0">
219 <SheetHeader>
220 <SheetTitle>Create vector table</SheetTitle>
221 </SheetHeader>
222
223 {showIndexCreationNotice && (
224 <Admonition
225 type="warning"
226 className="border-x-0 border-t-0 rounded-none"
227 title="Vector table creation is currently only supported for projects in us-east-1"
228 description={`This is only applicable to projects on local/staging (Project is currently in ${project.region})`}
229 />
230 )}
231
232 <Form {...form}>
233 <form
234 id={formId}
235 onSubmit={form.handleSubmit(onSubmit)}
236 className="overflow-auto grow px-0"
237 >
238 <SheetSection className="flex flex-col gap-y-4">
239 <FormField
240 key="name"
241 name="name"
242 control={form.control}
243 render={({ field }) => (
244 <FormItemLayout
245 name="name"
246 label="Name"
247 description="Must be between 3–63 characters. Valid characters are a-z, 0-9, hyphens, and periods."
248 layout="horizontal"
249 >
250 <FormControl>
251 <Input
252 id="name"
253 data-1p-ignore
254 data-lpignore="true"
255 data-form-type="other"
256 data-bwignore
257 {...field}
258 placeholder="Enter a table name"
259 />
260 </FormControl>
261 </FormItemLayout>
262 )}
263 />
264 </SheetSection>
265 <Separator />
266 <SheetSection className="flex flex-col gap-y-4">
267 <FormField
268 key="dimension"
269 name="dimension"
270 control={form.control}
271 render={({ field }) => (
272 <FormItemLayout
273 name="dimension"
274 label="Dimension"
275 description="Must be an integer between 1–4096."
276 layout="horizontal"
277 >
278 <FormControl>
279 <Input
280 id="dimension"
281 type="number"
282 placeholder="Enter a numeric value"
283 {...field}
284 onChange={(e) => {
285 const value = e.target.value
286 field.onChange(value === '' ? undefined : Number(value))
287 }}
288 value={field.value ?? ''}
289 />
290 </FormControl>
291 </FormItemLayout>
292 )}
293 />
294
295 <FormField
296 key="distanceMetric"
297 name="distanceMetric"
298 control={form.control}
299 render={({ field }) => (
300 <FormItemLayout
301 name="distanceMetric"
302 label="Distance metric"
303 layout="horizontal"
304 className="gap-1"
305 >
306 <FormControl>
307 <RadioGroupStacked
308 id="distance_metric"
309 name="distance_metric"
310 value={field.value}
311 disabled={field.disabled}
312 onValueChange={field.onChange}
313 >
314 {DISTANCE_METRICS.map((metric) => (
315 <RadioGroupStackedItem
316 key={metric.value}
317 id={metric.value}
318 value={metric.value}
319 label={metric.label}
320 description={metric.description}
321 showIndicator={true}
322 ></RadioGroupStackedItem>
323 ))}
324 </RadioGroupStacked>
325 </FormControl>
326 </FormItemLayout>
327 )}
328 />
329 </SheetSection>
330 <Separator />
331 <SheetSection className="space-y-4">
332 <div className="flex items-center justify-between">
333 <label className="text-sm text-foreground">Metadata keys</label>
334 <DocsButton
335 href={`${DOCS_URL}/guides/storage/vector/storing-vectors#metadata-best-practices`}
336 />
337 </div>
338 <div className="space-y-2">
339 {fields.map((field, index) => (
340 <div key={field.id} className="flex items-start gap-2">
341 <div className="flex-1">
342 <FormField
343 control={form.control}
344 name={`metadataKeys.${index}.value`}
345 render={({ field }) => (
346 <FormItemLayout
347 name={`metadataKeys.${index}.value`}
348 description={
349 index === fields.length - 1
350 ? 'Must be between 1–63 characters and unique within this table.'
351 : undefined
352 }
353 layout="vertical"
354 >
355 <FormControl>
356 <Input
357 {...field}
358 value={field.value}
359 size="small"
360 className="w-full"
361 placeholder="Enter a metadata key name"
362 data-1p-ignore
363 data-lpignore="true"
364 data-form-type="other"
365 data-bwignore
366 />
367 </FormControl>
368 </FormItemLayout>
369 )}
370 />
371 </div>
372 <Button
373 type="text"
374 className="w-[34px] h-[34px]" // Match the height of the input
375 size="tiny"
376 icon={<Trash2 size={12} />}
377 onClick={() => remove(index)}
378 />
379 </div>
380 ))}
381 </div>
382 <div className="flex items-center justify-center rounded-sm border border-strong border-dashed py-3">
383 <Button type="default" size="tiny" onClick={() => append({ value: '' })}>
384 Add metadata key
385 </Button>
386 </div>
387 </SheetSection>
388 </form>
389 </Form>
390
391 <SheetFooter>
392 <Button type="default" disabled={isCreating} onClick={() => setVisible(false)}>
393 Cancel
394 </Button>
395 <Button
396 form={formId}
397 htmlType="submit"
398 loading={isCreating}
399 disabled={isCreating || !bucketName}
400 >
401 Create
402 </Button>
403 </SheetFooter>
404 </SheetContent>
405 </Sheet>
406 )
407}