CreateVectorTableSheet.tsx407 lines · main
| 1 | import { zodResolver } from '@hookform/resolvers/zod' |
| 2 | import { literal, safeSql } from '@supabase/pg-meta' |
| 3 | import { PermissionAction } from '@supabase/shared-types/out/constants' |
| 4 | import { Plus, Trash2 } from 'lucide-react' |
| 5 | import { parseAsBoolean, useQueryState } from 'nuqs' |
| 6 | import { useEffect } from 'react' |
| 7 | import { SubmitHandler, useFieldArray, useForm } from 'react-hook-form' |
| 8 | import { toast } from 'sonner' |
| 9 | import { |
| 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' |
| 26 | import { Admonition } from 'ui-patterns' |
| 27 | import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' |
| 28 | import z from 'zod' |
| 29 | |
| 30 | import { inverseValidBucketNameRegex } from '../CreateBucketModal.utils' |
| 31 | import { useS3VectorsWrapperInstance } from './useS3VectorsWrapperInstance' |
| 32 | import { ButtonTooltip } from '@/components/ui/ButtonTooltip' |
| 33 | import { DocsButton } from '@/components/ui/DocsButton' |
| 34 | import { useFDWImportForeignSchemaMutation } from '@/data/fdw/fdw-import-foreign-schema-mutation' |
| 35 | import { useVectorBucketIndexCreateMutation } from '@/data/storage/vector-bucket-index-create-mutation' |
| 36 | import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' |
| 37 | import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' |
| 38 | import { DOCS_URL } from '@/lib/constants' |
| 39 | |
| 40 | const isStagingLocal = process.env.NEXT_PUBLIC_ENVIRONMENT !== 'prod' |
| 41 | |
| 42 | const BUCKET_INDEX_NAME_REGEX = /^[a-z0-9](?:[a-z0-9.-]{1,61})?[a-z0-9]$/ |
| 43 | |
| 44 | const 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 | |
| 58 | const 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 | |
| 97 | const formId = 'create-vector-table-form' |
| 98 | |
| 99 | export type CreateVectorTableForm = z.infer<typeof FormSchema> |
| 100 | |
| 101 | interface CreateVectorTableSheetProps { |
| 102 | bucketName?: string |
| 103 | } |
| 104 | |
| 105 | export 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 | } |