Merge branch 'master' into dnywh/chore/key-value-field-array

# Conflicts:
#	apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointSheet.test.tsx
This commit is contained in:
Danny White committed 2026-03-19 13:45:37 +11:00
commit 474ea1a3cb
441 files changed
+15543 -18544

No files matched your search

+3 -3
View File
@@ -14,9 +14,9 @@ reviewing PRs that touch tracking or when implementing new tracking.
**Format:** `[object]_[verb]` in snake_case
**Approved verbs only:**
opened, clicked, submitted, created, removed, updated, retrieved, intended, evaluated, added,
enabled, disabled, copied, exposed, failed, converted
**Approved verbs only** (canonical list — derived from `packages/common/telemetry-constants.ts`):
opened, clicked, submitted, created, removed, updated, intended, evaluated, added,
enabled, disabled, copied, exposed, failed, converted, closed, completed, applied, sent, moved
**Flag these:**
- Unapproved verbs (saved, viewed, seen, pressed, etc.)
+26
View File
@@ -0,0 +1,26 @@
# Copilot Code Review Instructions
## Repo Context
This is a TypeScript/Next.js/React monorepo:
- `apps/studio/` — Supabase Dashboard (primary review target)
- `apps/www/` — Marketing site
- `apps/docs/` — Documentation
- `packages/common/` — Shared code including telemetry definitions
## Topic-Specific Guidelines
Detailed review rules are in path-specific instruction files under `.github/instructions/`:
- **Telemetry**: `studio-telemetry.instructions.md` — event naming, property conventions, feature flag measurement
- **Testing**: `studio-testing.instructions.md` — test strategy, extraction patterns, coverage expectations
These files are scoped to `apps/studio/` and applied automatically by Copilot during reviews.
## References
For the full, authoritative versions of these standards:
- Telemetry: `.claude/skills/telemetry-standards/SKILL.md`
- Testing: `.claude/skills/studio-testing/SKILL.md`
@@ -0,0 +1,59 @@
---
applyTo: "apps/studio/**,packages/common/telemetry*"
---
# Studio Telemetry Review Rules
All comments are **advisory** — suggest, do not request changes.
## When to Flag Missing Telemetry
Use judgment — not every PR needs telemetry. But **always flag** when:
1. **Changes to `packages/common/telemetry-constants.ts`** — validate event naming, property conventions, and JSDoc accuracy.
2. **PostHog feature flags without measurement.** If a PR uses `usePHFlag` or PostHog-backed hooks like `useDataApiGrantTogglesEnabled` to gate behavior, the flag state should be captured in a telemetry event so the rollout can be measured. Flag if the flag value isn't included in a relevant `track()` call. (Note: `useFlag` from `common` reads ConfigCat flags, not PostHog — different system, different guidance.)
3. **Feature-flagged rollouts without outcome tracking.** If a flag gates new behavior, there should be telemetry on both the flag state *and* how users respond to the new behavior (e.g., toggle clicks, opt-in actions).
4. **Growth-oriented components adding user interactions without tracking** — onboarding flows, setup wizards, upgrade CTAs, A/B experiment variants.
When tracking is missing, comment: _"This adds a user interaction (or feature flag) that may benefit from tracking."_ Then propose an event name and `useTrack()` call.
## Feature Flag Telemetry Pattern
When capturing a PostHog flag value for telemetry, read the raw flag via `usePHFlag('flagName')` — **not** through wrapper hooks that coerce `undefined` to `false`. Use conditional spread so the property is omitted (not false) when the flag store hasn't loaded:
```typescript
const flagValue = usePHFlag<boolean>('myBooleanFlag') // for boolean flags
track('event_name', {
...(flagValue !== undefined && { myFlagEnabled: flagValue }),
})
```
For string-valued flags (e.g., experiment variants), use `usePHFlag<string>('flagName')` instead.
## Event Naming
Format: `[object]_[verb]` in snake_case.
Prefer verbs already in use in `packages/common/telemetry-constants.ts`: `opened`, `clicked`, `submitted`, `created`, `removed`, `updated`, `intended`, `evaluated`, `added`, `enabled`, `disabled`, `copied`, `exposed`, `failed`, `converted`, `closed`, `completed`, `applied`, `sent`, `moved`.
Flag: unapproved verbs (`saved`, `viewed`, `pressed`), wrong order (`click_product_card`), wrong casing (`productCardClicked`), passive view tracking on page load (exception: `_exposed` events for A/B experiments).
## Event Properties
- **camelCase** for new events; match existing convention when extending
- Self-explanatory names — flag generic (`label`, `value`, `name`, `data`)
- Check `telemetry-constants.ts` for consistency with similar events
- Never track PII
## Event Implementation
- Use `useTrack` from `lib/telemetry/track` — avoid introducing new `useSendEventMutation` usage
- New events need a TypeScript interface in `telemetry-constants.ts` with `@group Events` and `@source` JSDoc tags (add `@page` when applicable for page-specific events), added to the `TelemetryEvent` union
```typescript
import { useTrack } from 'lib/telemetry/track'
const track = useTrack()
track('product_card_clicked', { productType: 'database', planTier: 'pro' })
```
Canonical standards: `.claude/skills/telemetry-standards/SKILL.md`
@@ -0,0 +1,29 @@
---
applyTo: "apps/studio/**"
---
# Studio Testing Review Rules
All comments are **advisory**.
## Core Principle
Push logic out of React components into pure `.utils.ts` functions, then test those functions exhaustively. Only use component tests for complex UI interactions.
## When to Comment
- PR adds **business logic inline in a component** that could be extracted to a `ComponentName.utils.ts` file next to the component and unit tested at `tests/components/.../ComponentName.utils.test.ts`
- PR adds a **utility function without test coverage**
- PR uses **component tests for pure logic** that should be a unit test on a pure function
- PR adds a **feature used in both self-hosted and platform** without E2E test consideration
## Which Test Type to Suggest
- **Pure transformation** (parse, format, validate, compute) → extract to `.utils.ts` + unit test with vitest
- **Complex UI interaction** → component test with `customRender` (or E2E if shared with self-hosted)
- **E2E tests** should cover both click interactions AND keyboard shortcuts
- **No tests at all** for non-trivial changes → nudge to add coverage
## Reference
See `.claude/skills/studio-testing/SKILL.md` for the full testing standard.
+3 -54
View File
@@ -22,8 +22,10 @@ jobs:
with:
sparse-checkout: |
apps
packages
blocks
examples
i18n
packages
patches
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
name: Install pnpm
@@ -40,56 +42,3 @@ jobs:
- name: Run prettier
run: |-
pnpm run test:prettier
# i18n is not a node package, so we handle that one separately
format-i18n:
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Check out repo
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
sparse-checkout: |
i18n
patches
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
name: Install pnpm
with:
run_install: false
- name: Setup node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: '.nvmrc'
cache: 'pnpm'
- name: Download dependencies
run: |
pnpm install --frozen-lockfile
- name: Run prettier
run: |-
pnpm exec prettier -c 'i18n/**/*.{js,jsx,ts,tsx,css,md,mdx,json}'
format-sql:
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Check out repo
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
sparse-checkout: |
apps/docs/pages
apps/docs/content
patches
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
name: Install pnpm
with:
run_install: false
- name: Setup node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: '.nvmrc'
cache: 'pnpm'
- name: Download dependencies
run: |
pnpm install --frozen-lockfile
- name: Run prettier
run: |-
# Check mdx files which contain sql code blocks
grep -lr '```sql' apps/docs/{pages,content}/**/*.mdx | xargs pnpm exec prettier -c
+1 -1
View File
@@ -25,4 +25,4 @@ jobs:
docker/
- name: Run docker-compose up
# Ensure all services can be started and healthy with default config
run: cd docker && cp .env.example .env && docker compose up --wait
run: cd docker && cp .env.example .env && docker compose up --wait --wait-timeout 180
@@ -96,6 +96,7 @@ Implementation checklist:
- Render a separate discard confirmation dialog when dirty.
- Keep `Cancel` non-destructive; use `Discard`/`Discard changes` for one-click destructive exits.
- Guard controlled close attempts only; do not try to block route changes or arbitrary unmounts.
- If dismissal is route-driven or tied to page unload, use a navigation guard that composes the same discard-confirmation UI instead of extending the dialog/sheet close guard.
Studio implementation (preferred in Studio code):
@@ -1,5 +1,5 @@
import { IS_PLATFORM } from 'common'
import { NextApiRequest, NextApiResponse } from 'next'
import { NextResponse } from 'next/server'
import { InternalServerError } from '@/lib/api/apiHelpers'
import { getActiveIncidents, type IncidentCache } from '@/lib/api/incident-status'
@@ -42,22 +42,26 @@ async function fetchIncidentCache(incidentIds: Array<string>): Promise<Map<strin
return cacheMap
}
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!IS_PLATFORM) {
return res.status(404).end()
}
export async function OPTIONS() {
if (!IS_PLATFORM) return new Response(null, { status: 404 })
return new Response(null, {
status: 204,
headers: {
Allow: 'GET, HEAD, OPTIONS',
},
})
}
const { method } = req
export async function HEAD() {
if (!IS_PLATFORM) return new Response(null, { status: 404 })
return new Response(null, {
status: 200,
headers: { 'Cache-Control': CACHE_CONTROL_SETTINGS },
})
}
if (method === 'HEAD') {
res.setHeader('Cache-Control', CACHE_CONTROL_SETTINGS)
return res.status(200).end()
}
if (method !== 'GET') {
res.setHeader('Allow', ['GET', 'HEAD'])
return res.status(405).json({ error: `Method ${method} Not Allowed` })
}
export async function GET() {
if (!IS_PLATFORM) return new Response(null, { status: 404 })
try {
const allIncidents = await getActiveIncidents()
@@ -75,17 +79,18 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
cache: cacheMap.get(incident.id) ?? null,
}))
res.setHeader('Cache-Control', CACHE_CONTROL_SETTINGS)
return res.status(200).json(enrichedIncidents)
return NextResponse.json(enrichedIncidents, {
headers: { 'Cache-Control': CACHE_CONTROL_SETTINGS },
})
} catch (error) {
let errorCode = 500
const headers = new Headers()
if (error instanceof InternalServerError) {
if (typeof error.details?.status === 'number') errorCode = error.details.status
if (errorCode === 420) errorCode = 429
if (errorCode === 429 && typeof error.details?.retryAfter === 'string') {
res.setHeader('Retry-After', error.details.retryAfter)
headers.set('Retry-After', error.details.retryAfter)
}
console.error('Failed to fetch active StatusPage incidents: %O', {
message: error.message,
@@ -95,8 +100,9 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
console.error('Unexpected error fetching active StatusPage incidents: %O', error)
}
return res.status(errorCode).json({ error: 'Unable to fetch incidents at this time' })
return NextResponse.json(
{ error: 'Unable to fetch incidents at this time' },
{ status: errorCode, headers }
)
}
}
export default handler
@@ -242,8 +242,8 @@ export function useSyncTableEditorStateFromLocalStorageWithUrl({
// Use nextjs useSearchParams to get the latest URL params
const searchParams = useSearchParams()
const urlParams = useMemo(() => {
const sort = searchParams.getAll('sort')
const filter = searchParams.getAll('filter')
const sort = searchParams?.getAll('sort') ?? []
const filter = searchParams?.getAll('filter') ?? []
return { sort, filter }
}, [searchParams])
@@ -1,12 +1,12 @@
import { FOREIGN_KEY_CASCADE_ACTION } from '@supabase/pg-meta'
import { getForeignKeyCascadeAction } from 'components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.utils'
import type { XYCoord } from 'dnd-core'
import { ArrowRight, Key, Lightbulb, Link, Lock } from 'lucide-react'
import { useEffect, useRef } from 'react'
import { useDrag, useDrop } from 'react-dnd'
import { getForeignKeyCascadeAction } from 'components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.utils'
import { FOREIGN_KEY_CASCADE_ACTION } from 'data/database/database-query-constants'
import { useTableEditorTableStateSnapshot } from 'state/table-editor-table'
import { Tooltip, TooltipContent, TooltipTrigger } from 'ui'
import {
useColumnHasIndexSuggestion,
useTableIndexAdvisor,
@@ -1,7 +1,5 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { useQueryClient } from '@tanstack/react-query'
import { Dispatch, SetStateAction, useEffect, useState } from 'react'
import { toast } from 'sonner'
import { LOCAL_STORAGE_KEYS } from 'common'
import InformationBox from 'components/ui/InformationBox'
import { organizationKeys } from 'data/organizations/keys'
@@ -9,9 +7,16 @@ import { useMfaChallengeAndVerifyMutation } from 'data/profile/mfa-challenge-and
import { useMfaEnrollMutation } from 'data/profile/mfa-enroll-mutation'
import { useMfaUnenrollMutation } from 'data/profile/mfa-unenroll-mutation'
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
import { Input } from 'ui'
import { useEffect, useState } from 'react'
import { useForm, type SubmitHandler } from 'react-hook-form'
import { toast } from 'sonner'
import { Form_Shadcn_, FormControl_Shadcn_, FormField_Shadcn_, Input, Input_Shadcn_ } from 'ui'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import { z } from 'zod'
type TOTP = { qr_code: string; secret: string; uri: string }
interface AddNewFactorModalProps {
visible: boolean
@@ -19,32 +24,24 @@ interface AddNewFactorModalProps {
}
export const AddNewFactorModal = ({ visible, onClose }: AddNewFactorModalProps) => {
// Generate a name with a number between 0 and 1000
const [name, setName] = useState(`App ${Math.floor(Math.random() * 1000)}`)
const { data, mutate: enroll, isPending: isEnrolling, reset } = useMfaEnrollMutation()
useEffect(() => {
// reset has to be called because the state is kept between if the process is canceled during
// the second step.
if (!visible) {
setName(`App ${Math.floor(Math.random() * 1000)}`)
reset()
}
if (!visible) reset()
}, [reset, visible])
return (
<>
<FirstStep
visible={visible && !Boolean(data)}
name={name}
setName={setName}
enroll={enroll}
isEnrolling={isEnrolling}
enroll={enroll}
reset={reset}
onClose={onClose}
/>
<SecondStep
visible={visible && Boolean(data)}
factorName={name}
factorName={data?.friendly_name ?? ''}
factor={data as Extract<typeof data, { type: 'totp' }>}
isLoading={isEnrolling}
onClose={onClose}
@@ -55,14 +52,33 @@ export const AddNewFactorModal = ({ visible, onClose }: AddNewFactorModalProps)
interface FirstStepProps {
visible: boolean
name: string
setName: Dispatch<SetStateAction<string>>
enroll: (params: { factorType: 'totp'; friendlyName?: string }) => void
isEnrolling: boolean
reset: () => void
enroll: (params: { factorType: 'totp'; friendlyName?: string }) => void
onClose: () => void
}
const FirstStep = ({ visible, name, enroll, setName, isEnrolling, onClose }: FirstStepProps) => {
const FirstStep = ({ visible, isEnrolling, reset, enroll, onClose }: FirstStepProps) => {
const FormSchema = z.object({
name: z.string().min(1, 'Please provide a name to identify this app'),
})
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
defaultValues: { name: '' },
mode: 'onChange',
})
const onSubmit: SubmitHandler<z.infer<typeof FormSchema>> = async (values) => {
enroll({ factorType: 'totp', friendlyName: values.name })
}
useEffect(() => {
if (!visible) {
// Generate a name with a number between 0 and 1000
form.reset({ name: `App ${Math.floor(Math.random() * 1000)}` })
}
}, [form, visible])
return (
<ConfirmationModal
size="medium"
@@ -70,22 +86,34 @@ const FirstStep = ({ visible, name, enroll, setName, isEnrolling, onClose }: Fir
title="Add a new authenticator app as a factor"
confirmLabel="Generate QR"
confirmLabelLoading="Generating QR"
disabled={name.length === 0}
loading={isEnrolling}
onCancel={onClose}
onConfirm={() => {
enroll({
factorType: 'totp',
friendlyName: name,
})
}}
onConfirm={form.handleSubmit(onSubmit)}
>
<Input
label="Provide a name to identify this app"
descriptionText="A string will be randomly generated if a name is not provided"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<Form_Shadcn_ {...form}>
<form
id="verify-otp-form"
className="flex flex-col gap-4"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField_Shadcn_
key="name"
name="name"
control={form.control}
render={({ field }) => (
<FormItemLayout
name="name"
label="Provide a name to identify this app"
description="A string will be randomly generated if a name is not provided"
>
<FormControl_Shadcn_>
<Input_Shadcn_ id="name" {...field} />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</form>
</Form_Shadcn_>
</ConfirmationModal>
)
}
@@ -96,11 +124,7 @@ interface SecondStepProps {
factor?: {
id: string
type: 'totp'
totp: {
qr_code: string
secret: string
uri: string
}
totp: TOTP
}
isLoading: boolean
onClose: () => void
@@ -113,14 +137,23 @@ const SecondStep = ({
isLoading,
onClose,
}: SecondStepProps) => {
const [code, setCode] = useState('')
const queryClient = useQueryClient()
const [lastVisitedOrganization] = useLocalStorageQuery(
LOCAL_STORAGE_KEYS.LAST_VISITED_ORGANIZATION,
''
)
const FormSchema = z.object({
code: z.string().min(1, 'Please provide a code from your authenticator app'),
})
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
defaultValues: { code: '' },
mode: 'onChange',
})
const [factor, setFactor] = useState<{ id: string; type: 'totp'; totp: TOTP } | null>(null)
const { mutate: unenroll } = useMfaUnenrollMutation({ onSuccess: () => onClose() })
const { mutate: challengeAndVerify, isPending: isVerifying } = useMfaChallengeAndVerifyMutation({
onError: (error) => {
@@ -137,15 +170,10 @@ const SecondStep = ({
},
})
const [factor, setFactor] = useState<{
id: string
type: 'totp'
totp: {
qr_code: string
secret: string
uri: string
}
} | null>(null)
const onSubmit: SubmitHandler<z.infer<typeof FormSchema>> = async (values) => {
if (!factor) return toast.error('Factor required')
challengeAndVerify({ factorId: factor.id, code: values.code })
}
// this useEffect is to keep the factor until a new one comes. This is a fix to an issue which
// happens when closing the modal, the outer factor is reset to null too soon and the modal
@@ -153,6 +181,7 @@ const SecondStep = ({
useEffect(() => {
if (outerFactor && factor?.id !== outerFactor.id) {
setFactor(outerFactor)
form.reset({ code: '' })
}
}, [outerFactor])
@@ -160,6 +189,7 @@ const SecondStep = ({
<ConfirmationModal
size="medium"
visible={visible}
className="py-5"
title={`Verify new factor ${factorName}`}
confirmLabel="Confirm"
confirmLabelLoading="Confirming"
@@ -170,50 +200,68 @@ const SecondStep = ({
// unenrolling.
if (factor) unenroll({ factorId: factor.id })
}}
onConfirm={() => factor && challengeAndVerify({ factorId: factor.id, code })}
onConfirm={form.handleSubmit(onSubmit)}
>
<div className="py-4 pb-0 text-sm">
<span>
Use an authenticator app to scan the following QR code, and provide the code from the app
to complete the enrolment.
</span>
</div>
<p className="text-sm">
Use an authenticator app to scan the following QR code, and provide the code from the app to
complete the enrolment.
</p>
{isLoading && (
<div className="pb-4 px-4">
<GenericSkeletonLoader />
</div>
)}
{factor && (
<>
<div className="flex flex-col gap-y-4">
<div className="flex justify-center py-6">
<div className="h-48 w-48 bg-white rounded">
<img width={190} height={190} src={factor.totp.qr_code} alt={factor.totp.uri} />
</div>
</div>
<div>
<InformationBox
title="Unable to scan?"
description={
<Input
copy
disabled
id="ref"
size="small"
label="You can also enter this secret key into your authenticator app"
value={factor.totp.secret}
/>
}
/>
</div>
<div className="pt-2 pb-4">
<Input
label="Authentication code"
value={code}
placeholder="XXXXXX"
onChange={(e) => setCode(e.target.value)}
/>
</div>
</>
<InformationBox
title="Unable to scan?"
description={
<Input
copy
disabled
id="ref"
size="small"
label="You can also enter this secret key into your authenticator app"
value={factor.totp.secret}
/>
}
/>
<Form_Shadcn_ {...form}>
<form
id="verify-otp-form"
className="flex flex-col gap-4"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField_Shadcn_
key="code"
name="code"
control={form.control}
render={({ field }) => (
<FormItemLayout name="code" label="Authentication code">
<FormControl_Shadcn_>
<Input_Shadcn_
id="code"
autoFocus
{...field}
placeholder="XXXXXX"
className="font-mono"
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</form>
</Form_Shadcn_>
</div>
)}
</ConfirmationModal>
)
@@ -65,10 +65,11 @@ export const FeaturePreviewModal = () => {
const featurePreviewContext = useFeaturePreviewContext()
const { mutate: sendEvent } = useSendEventMutation()
const [isDismissedTableFilterBar, setIsDismissedTableFilterBar] = useLocalStorageQuery(
LOCAL_STORAGE_KEYS.TABLE_EDITOR_NEW_FILTER_BANNER_DISMISSED(ref ?? ''),
false
)
const [isDismissedTableQueueOperations, setIsDismissedTableQueueOperations] =
useLocalStorageQuery(
LOCAL_STORAGE_KEYS.TABLE_EDITOR_QUEUE_OPERATIONS_BANNER_DISMISSED(ref ?? ''),
false
)
const { flags, onUpdateFlag } = featurePreviewContext
const allFeaturePreviews = (
@@ -90,10 +91,10 @@ export const FeaturePreviewModal = () => {
})
if (
selectedFeature.key === LOCAL_STORAGE_KEYS.UI_PREVIEW_TABLE_FILTER_BAR &&
!isDismissedTableFilterBar
selectedFeature.key === LOCAL_STORAGE_KEYS.UI_PREVIEW_QUEUE_OPERATIONS &&
!isDismissedTableQueueOperations
) {
setIsDismissedTableFilterBar(true)
setIsDismissedTableQueueOperations(true)
}
}
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest'
import { FormSchema } from './EditHookPanel.constants'
describe('EditHookPanel FormSchema', () => {
it('rejects incomplete http_request hostnames', () => {
const result = FormSchema.safeParse({
name: 'Test hook',
table_id: 'public.messages',
http_method: 'POST' as const,
timeout_ms: 1000,
events: ['INSERT'],
httpHeaders: [],
httpParameters: [],
function_type: 'http_request' as const,
http_url: 'https://webhook',
})
expect(result.success).toBe(false)
if (!result.success) {
expect(
result.error.issues.some((issue) => issue.message === 'Please provide a valid URL')
).toBe(true)
}
})
it('rejects http_request URLs without an explicit protocol', () => {
const result = FormSchema.safeParse({
name: 'Test hook',
table_id: 'public.messages',
http_method: 'POST' as const,
timeout_ms: 1000,
events: ['INSERT'],
httpHeaders: [],
httpParameters: [],
function_type: 'http_request' as const,
http_url: 'hooks.example.com/webhook',
})
expect(result.success).toBe(false)
if (!result.success) {
expect(
result.error.issues.some(
(issue) => issue.message === 'Please prefix your URL with http:// or https://'
)
).toBe(true)
}
})
})
@@ -1,14 +1,14 @@
import { z } from 'zod'
import { isValidHttpUrl } from '@/lib/helpers'
import { httpEndpointUrlSchema } from '@/lib/validation/http-url'
const httpRequestSchema = z.object({
function_type: z.literal('http_request'),
http_url: z
.string()
.min(1, 'Please provide a URL')
.refine((val) => val.startsWith('http'), 'Please prefix your URL with http or https')
.refine((val) => isValidHttpUrl(val), 'Please provide a valid URL'),
http_url: httpEndpointUrlSchema({
requiredMessage: 'Please provide a URL',
invalidMessage: 'Please provide a valid URL',
prefixMessage: 'Please prefix your URL with http:// or https://',
}),
})
const supabaseFunctionSchema = z.object({
@@ -1,7 +1,6 @@
import { useParams } from 'common'
import { LINTER_LEVELS } from 'components/interfaces/Linter/Linter.constants'
import { createLintSummaryPrompt, EntityTypeIcon } from 'components/interfaces/Linter/Linter.utils'
import { useQueryPerformanceQuery } from 'components/interfaces/Reports/Reports.queries'
import { SIDEBAR_KEYS } from 'components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
import { AiAssistantDropdown } from 'components/ui/AiAssistantDropdown'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
@@ -31,6 +30,8 @@ import {
} from 'ui'
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
import { useQueryPerformanceQuery } from '../QueryPerformance/useQueryPerformanceQuery'
interface SlowQuery {
rolname: string
mean_time: number
@@ -43,9 +44,7 @@ export const AdvisorWidget = () => {
const [selectedTab, setSelectedTab] = useState<'security' | 'performance'>('security')
const { data: lints, isPending: isLoadingLints } = useProjectLintsQuery({ projectRef })
const { data: slowestQueriesData, isLoading: isLoadingSlowestQueries } = useQueryPerformanceQuery(
{
preset: 'slowestExecutionTime',
}
{ preset: 'slowestExecutionTime' }
)
const snap = useAiAssistantStateSnapshot()
const { openSidebar } = useSidebarManagerSnapshot()
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import { FormSchema } from './CreateCronJobSheet.constants'
describe('CreateCronJobSheet FormSchema', () => {
it('rejects incomplete http_request hostnames', () => {
const result = FormSchema.safeParse({
name: 'Send webhook',
supportsSeconds: false,
schedule: '* * * * *',
values: {
type: 'http_request' as const,
method: 'POST' as const,
endpoint: 'https://webhook',
timeoutMs: 1000,
httpHeaders: [],
snippet: '',
},
})
expect(result.success).toBe(false)
if (!result.success) {
expect(
result.error.issues.some((issue) => issue.message === 'Please provide a valid URL')
).toBe(true)
}
})
it('rejects http_request URLs without an explicit protocol', () => {
const result = FormSchema.safeParse({
name: 'Send webhook',
supportsSeconds: false,
schedule: '* * * * *',
values: {
type: 'http_request' as const,
method: 'POST' as const,
endpoint: 'hooks.example.com/webhook',
timeoutMs: 1000,
httpHeaders: [],
snippet: '',
},
})
expect(result.success).toBe(false)
if (!result.success) {
expect(
result.error.issues.some(
(issue) => issue.message === 'Please prefix your URL with http:// or https://'
)
).toBe(true)
}
})
})
@@ -1,8 +1,8 @@
import { toString as CronToString } from 'cronstrue'
import z from 'zod'
import { urlRegex } from 'components/interfaces/Auth/Auth.constants'
import { cronPattern, secondsPattern } from '../CronJobs.constants'
import { httpEndpointUrlSchema } from '@/lib/validation/http-url'
const convertCronToString = (schedule: string) => {
// pg_cron can also use "30 seconds" format for schedule. Cronstrue doesn't understand that format so just use the
@@ -40,12 +40,11 @@ const edgeFunctionSchema = z.object({
const httpRequestSchema = z.object({
type: z.literal('http_request'),
method: z.enum(['GET', 'POST']),
endpoint: z
.string()
.trim()
.min(1, 'Please provide a URL')
.regex(urlRegex(), 'Please provide a valid URL')
.refine((value) => value.startsWith('http'), 'Please include HTTP/HTTPs to your URL'),
endpoint: httpEndpointUrlSchema({
requiredMessage: 'Please provide a URL',
invalidMessage: 'Please provide a valid URL',
prefixMessage: 'Please prefix your URL with http:// or https://',
}),
timeoutMs: z.coerce.number().int().gte(1000).lte(5000).default(1000),
httpHeaders: z.array(z.object({ name: z.string(), value: z.string() })),
httpBody: z
@@ -46,12 +46,21 @@ export type HTTPHeader = { name: string; value: string }
export type HTTPParameter = { name: string; value: string }
export const CRON_TABLE_COLUMNS = [
{ id: 'jobname', name: 'Name', minWidth: 0, width: 200 },
{ id: 'schedule', name: 'Schedule', width: 100 },
{ id: 'latest_run', name: 'Last run', width: 265 },
{ id: 'next_run', name: 'Next run', minWidth: 180 },
{ id: 'command', name: 'Command', minWidth: 320 },
{ id: 'active', name: 'Active', width: 70, minWidth: 70, maxWidth: 70 },
{ id: 'actions', name: '', minWidth: 75, width: 75 },
type CronTableColumn = {
id: string
name: string
width?: number
minWidth?: number
maxWidth?: number
resizable?: boolean
}
export const CRON_TABLE_COLUMNS: CronTableColumn[] = [
{ id: 'jobname', name: 'Name', minWidth: 160, width: 200, resizable: true },
{ id: 'schedule', name: 'Schedule', width: 100, resizable: true },
{ id: 'latest_run', name: 'Last run', width: 265, resizable: true },
{ id: 'next_run', name: 'Next run', minWidth: 180, resizable: true },
{ id: 'command', name: 'Command', minWidth: 320, resizable: true },
{ id: 'active', name: 'Active', width: 70, minWidth: 70, maxWidth: 70, resizable: false },
{ id: 'actions', name: '', minWidth: 75, width: 75, resizable: false },
]
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { cronPattern, secondsPattern } from './CronJobs.constants'
import { parseCronJobCommand } from './CronJobs.utils'
import { formatCronJobColumns, parseCronJobCommand } from './CronJobs.utils'
describe('parseCronJobCommand', () => {
it('should return a default object when the command is null', () => {
@@ -279,3 +279,23 @@ describe('parseCronJobCommand', () => {
})
})
})
describe('formatCronJobColumns', () => {
it('enables resizing for informational columns and keeps utility columns fixed', () => {
const columns = formatCronJobColumns({
onSelectEdit: () => undefined,
onSelectDelete: () => undefined,
})
const columnsByKey = Object.fromEntries(columns.map((column) => [String(column.key), column]))
expect(columnsByKey.jobname.resizable).toBe(true)
expect(columnsByKey.jobname.minWidth).toBeGreaterThan(0)
expect(columnsByKey.schedule.resizable).toBe(true)
expect(columnsByKey.latest_run.resizable).toBe(true)
expect(columnsByKey.next_run.resizable).toBe(true)
expect(columnsByKey.command.resizable).toBe(true)
expect(columnsByKey.active.resizable).toBe(false)
expect(columnsByKey.actions.resizable).toBe(false)
})
})
@@ -236,7 +236,7 @@ export const formatCronJobColumns = ({
minWidth: col.minWidth ?? 100,
maxWidth: col.maxWidth,
width: col.width,
resizable: false,
resizable: col.resizable ?? false,
sortable: false,
draggable: false,
headerCellClass: undefined,
@@ -5,13 +5,13 @@ import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { EMPTY_ARR } from 'lib/void'
import { useMemo } from 'react'
import { parseSchemaComment } from 'stripe-experiment-sync/supabase'
import { wrapperMetaComparator } from '../Wrappers/Wrappers.utils'
import { INTEGRATIONS } from './Integrations.constants'
import {
isInstalled as checkIsInstalled,
findStripeSchema,
parseStripeSchema,
} from '@/components/interfaces/Integrations/templates/StripeSyncEngine/stripe-sync-status'
export const useInstalledIntegrations = () => {
@@ -77,7 +77,7 @@ export const useInstalledIntegrations = () => {
}
if (integration.id === 'stripe_sync_engine') {
const stripeSchema = findStripeSchema(schemas)
const parsedSchema = parseStripeSchema(stripeSchema)
const parsedSchema = parseSchemaComment(stripeSchema?.comment)
return checkIsInstalled(parsedSchema.status)
}
if (integration.type === 'wrapper') {
@@ -1,37 +1,50 @@
import { useParams } from 'common'
import { Button } from 'ui'
import { Admonition } from 'ui-patterns'
import { ContactSupportButton } from '@/components/ui/AlertError'
import { useStripeSyncStatus } from './useStripeSyncStatus'
import AlertError from '@/components/ui/AlertError'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
export const InstallationError = ({
error,
handleUninstall,
handleOpenInstallSheet,
isUpgrade,
installing,
uninstalling,
}: {
error: 'install' | 'uninstall'
handleUninstall: () => void
handleOpenInstallSheet: () => void
isUpgrade?: boolean
installing?: boolean
uninstalling?: boolean
}) => {
const { ref } = useParams()
const { data: project } = useSelectedProjectQuery()
const {
schemaComment: { errorMessage },
} = useStripeSyncStatus({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
if (error === 'uninstall') {
return (
<Admonition
type="warning"
layout="responsive"
title="Failed to uninstall Stripe Sync Engine"
<AlertError
layout="horizontal"
subject="Failed to uninstall Stripe Sync Engine"
error={errorMessage ? { message: errorMessage } : undefined}
description="There was an error during the uninstallation of the Stripe Sync Engine, please try again. If the problem persists, contact support."
actions={
<div className="flex items-center gap-x-2">
<Button type="default" onClick={handleUninstall}>
Retry uninstall
</Button>
<ContactSupportButton
projectRef={ref}
subject="Failed to uninstall Stripe Sync Engine"
/>
</div>
additionalActions={
<Button
type="default"
onClick={handleUninstall}
disabled={uninstalling}
loading={uninstalling}
>
Retry uninstallation
</Button>
}
/>
)
@@ -39,18 +52,27 @@ export const InstallationError = ({
if (error === 'install') {
return (
<Admonition
type="warning"
layout="responsive"
title="Failed to install Stripe Sync Engine"
description="There was an error during the installation of the Stripe Sync Engine, please try reinstalling the integration. If the problem persists, contact support."
actions={
<div className="flex items-center gap-x-2">
<Button type="default" onClick={handleOpenInstallSheet}>
Retry install
</Button>
<ContactSupportButton projectRef={ref} subject="Failed to install Stripe Sync Engine" />
</div>
<AlertError
subject={
isUpgrade
? 'Failed to upgrade Stripe Sync Engine'
: 'Failed to install Stripe Sync Engine'
}
error={errorMessage ? { message: errorMessage } : undefined}
description={
isUpgrade
? 'There was an error during the upgrade of the Stripe Sync Engine, please try again. If the problem persists, contact support.'
: 'There was an error during the installation of the Stripe Sync Engine, please try reinstalling the integration. If the problem persists, contact support.'
}
additionalActions={
<Button
type="default"
onClick={handleOpenInstallSheet}
disabled={installing}
loading={installing}
>
{isUpgrade ? 'Retry upgrade' : 'Retry installation'}
</Button>
}
/>
)
@@ -45,8 +45,6 @@ import {
} from './stripe-sync-status'
import { StripeSyncChangesCard } from './StripeSyncChangesCard'
import { useStripeSyncStatus } from '@/components/interfaces/Integrations/templates/StripeSyncEngine/useStripeSyncStatus'
import { InlineLink } from '@/components/ui/InlineLink'
import { useSSLEnforcementQuery } from '@/data/ssl-enforcement/ssl-enforcement-query'
const installFormSchema = z.object({
stripeSecretKey: z.string().min(1, 'Stripe API key is required'),
@@ -71,7 +69,10 @@ export const StripeSyncInstallationPage = () => {
})
const {
parsedSchema: { status: installationStatus },
schemaComment,
schemaComment: { status: installationStatus },
latestAvailableVersion,
timedOut,
} = useStripeSyncStatus({
projectRef: project?.ref,
connectionString: project?.connectionString,
@@ -91,6 +92,22 @@ export const StripeSyncInstallationPage = () => {
const installDone = isInstallDone(installationStatus)
const uninstallDone = isUninstallDone(installationStatus)
// Detect if this is an upgrade (both old and new versions present)
let oldVersion
let newVersion
if (installed) {
// when installed we compare the installed version against the latest available
oldVersion = schemaComment?.newVersion
newVersion = latestAvailableVersion
} else {
// otherwise compare the old and new versions from the schema
oldVersion = schemaComment?.oldVersion
newVersion = schemaComment?.newVersion
}
const upgradeAvailable = !!(oldVersion && newVersion && oldVersion !== newVersion)
const upgradeDone = latestAvailableVersion == schemaComment?.newVersion
const {
mutate: installStripeSync,
isPending: isInstallRequested,
@@ -98,7 +115,9 @@ export const StripeSyncInstallationPage = () => {
reset: resetInstallError,
} = useStripeSyncInstallMutation({
onSuccess: () => {
toast.success('Stripe Sync installation started')
toast.success(
upgradeAvailable ? 'Stripe Sync upgrade started' : 'Stripe Sync installation started'
)
setShouldShowInstallSheet(false)
form.reset()
setIsInstallInitiated(true)
@@ -115,11 +134,12 @@ export const StripeSyncInstallationPage = () => {
})
// Combine schema status with mutation/initiated states for UI
const installing = installInProgress || isInstallRequested || isInstallInitiated
const uninstalling = uninstallInProgress || isUninstallRequested || isUninstallInitiated
const installing = (installInProgress || isInstallRequested || isInstallInitiated) && !timedOut
const uninstalling =
(uninstallInProgress || isUninstallRequested || isUninstallInitiated) && !timedOut
const canInstall = checkCanInstall(installationStatus) && !installed && !installing
const hasError = (uninstallError || installError) && !uninstalling && !installing
const hasError = (uninstallError || installError) && ((!uninstalling && !installing) || timedOut)
// Poll for schema changes during transitions
useSchemasQuery(
@@ -127,19 +147,12 @@ export const StripeSyncInstallationPage = () => {
{ refetchInterval: installing || uninstalling ? 5000 : false }
)
const { data: sslEnforcementConfiguration, isSuccess: isSuccessSslEnforcement } =
useSSLEnforcementQuery({
projectRef: project?.ref,
})
const isSSLEnforced =
sslEnforcementConfiguration?.appliedSuccessfully &&
sslEnforcementConfiguration?.currentConfig.database
const handleUninstall = useCallback(() => {
if (!project?.ref) return
uninstallStripeSync({
projectRef: project.ref,
startTime: Date.now(),
})
}, [project?.ref, uninstallStripeSync])
@@ -173,12 +186,13 @@ export const StripeSyncInstallationPage = () => {
}
}, [installError, track])
// Clear install initiated flag once schema reflects completion or error
// Clear install initiated flag once schema reflects successful completion
// For errors, the flag is cleared when user manually retries (handleOpenInstallSheet)
useEffect(() => {
if (isInstallInitiated && installDone) {
if (isInstallInitiated && installDone && upgradeDone && !installError) {
setIsInstallInitiated(false)
}
}, [isInstallInitiated, installDone])
}, [isInstallInitiated, installDone, upgradeDone, installError])
// Clear uninstall initiated flag once schema is removed or error
useEffect(() => {
@@ -195,20 +209,30 @@ export const StripeSyncInstallationPage = () => {
error={uninstallError ? 'uninstall' : 'install'}
handleUninstall={handleUninstall}
handleOpenInstallSheet={handleOpenInstallSheet}
isUpgrade={upgradeAvailable}
installing={installing}
uninstalling={uninstalling}
/>
) : null
}
status={
<StatusDisplay
status={installationStatus}
isInstallRequested={isInstallRequested || isInstallInitiated}
isUninstallRequested={isUninstallRequested || isUninstallInitiated}
isInstallRequested={isInstallRequested}
isInstallInitiated={isInstallInitiated}
isUninstallRequested={isUninstallRequested}
isUninstallInitiated={isUninstallInitiated}
isUpgrade={upgradeAvailable}
timedOut={timedOut}
/>
}
actions={
!installed && !uninstalling && !uninstallError ? (
<>
<StripeSyncChangesCard installationStatus={installationStatus} />
<StripeSyncChangesCard
installationStatus={installationStatus}
isUpgrade={upgradeAvailable}
/>
<div className="flex gap-x-2 justify-end mt-4">
<ButtonTooltip
type="primary"
@@ -225,7 +249,13 @@ export const StripeSyncInstallationPage = () => {
},
}}
>
{installError ? 'Retry installation' : 'Install integration'}
{installError
? upgradeAvailable
? 'Retry upgrade'
: 'Retry installation'
: upgradeAvailable
? 'Upgrade integration'
: 'Install integration'}
</ButtonTooltip>
{installError && (
<Button type="default" loading={isUninstallRequested} onClick={handleUninstall}>
@@ -236,12 +266,32 @@ export const StripeSyncInstallationPage = () => {
</>
) : installed || uninstalling || uninstallError ? (
<>
<StripeSyncChangesCard installationStatus={installationStatus} />
<div className="flex justify-end mt-4">
<StripeSyncChangesCard
installationStatus={installationStatus}
isUpgrade={upgradeAvailable}
/>
<div className="flex gap-x-2 justify-end mt-4">
{upgradeAvailable && !uninstallError && !uninstalling && (
<ButtonTooltip
type="primary"
onClick={() => setShouldShowInstallSheet(true)}
disabled={installing || !canManageSecrets}
loading={installing}
tooltip={{
content: {
text: !canManageSecrets
? 'You need additional permissions to upgrade the Stripe Sync Engine.'
: undefined,
},
}}
>
Upgrade integration
</ButtonTooltip>
)}
<ButtonTooltip
type="default"
onClick={() => setShowUninstallModal(true)}
disabled={!canManageSecrets}
disabled={installing || uninstalling || !canManageSecrets}
loading={uninstalling}
tooltip={{
content: {
@@ -251,7 +301,7 @@ export const StripeSyncInstallationPage = () => {
},
}}
>
Uninstall integration
{uninstallError ? 'Retry uninstallation' : 'Uninstall integration'}
</ButtonTooltip>
</div>
</>
@@ -265,33 +315,24 @@ export const StripeSyncInstallationPage = () => {
id={formId}
onSubmit={form.handleSubmit(({ stripeSecretKey }) => {
if (!project?.ref) return
installStripeSync({ projectRef: project.ref, stripeSecretKey })
installStripeSync({
projectRef: project.ref,
stripeSecretKey,
startTime: Date.now(),
})
})}
className="overflow-auto flex-grow px-0 flex flex-col"
>
<SheetHeader>
<SheetTitle>Install Stripe Sync Engine</SheetTitle>
<SheetTitle>
{upgradeAvailable ? 'Upgrade' : 'Install'} Stripe Sync Engine
</SheetTitle>
</SheetHeader>
<SheetSection className="flex-1 flex flex-col gap-y-6">
<StripeSyncChangesCard installationStatus={installationStatus} />
{isSuccessSslEnforcement && isSSLEnforced && (
<Admonition type="warning">
<h5 className="mb-0.5">
This integration currently requires{' '}
<InlineLink
href={`/project/${project?.ref ?? '_'}/database/settings#ssl-configuration`}
>
SSL Enforcement
</InlineLink>{' '}
to be disabled during initial setup.
</h5>
<p className="text-foreground-light">
Support for SSL Enforcement will be added in a future update. Once installed,
all webhook and sync operations use HTTPS/SSL.
</p>
</Admonition>
)}
<StripeSyncChangesCard
installationStatus={installationStatus}
isUpgrade={upgradeAvailable}
/>
<h3 className="heading-default">Configuration</h3>
@@ -368,7 +409,13 @@ export const StripeSyncInstallationPage = () => {
loading={isInstallRequested}
disabled={!form.formState.isValid || isInstallRequested}
>
{isInstallRequested ? 'Installing' : 'Install'}
{isInstallRequested
? upgradeAvailable
? 'Upgrading'
: 'Installing'
: upgradeAvailable
? 'Upgrade integration'
: 'Install integration'}
</Button>
</SheetFooter>
</form>
@@ -1,4 +1,5 @@
import { AlertCircle, Check, RefreshCwIcon } from 'lucide-react'
import { SchemaInstallationStatus } from 'stripe-experiment-sync/supabase'
import {
hasInstallError,
@@ -6,17 +7,24 @@ import {
isInstalled,
isInstalling,
isUninstalling,
StripeInstallationStatus,
} from './stripe-sync-status'
export const StatusDisplay = ({
status,
isInstallRequested,
isInstallInitiated,
isUninstallRequested,
isUninstallInitiated,
isUpgrade,
timedOut,
}: {
status: StripeInstallationStatus
status: SchemaInstallationStatus
isInstallRequested: boolean
isInstallInitiated: boolean
isUninstallRequested: boolean
isUninstallInitiated: boolean
isUpgrade?: boolean
timedOut: boolean
}) => {
const installed = isInstalled(status)
const installError = hasInstallError(status)
@@ -24,17 +32,10 @@ export const StatusDisplay = ({
const installInProgress = isInstalling(status)
const uninstallInProgress = isUninstalling(status)
const installing = installInProgress || isInstallRequested
const uninstalling = uninstallInProgress || isUninstallRequested
const installing = (installInProgress || isInstallRequested || isInstallInitiated) && !timedOut
const uninstalling =
(uninstallInProgress || isUninstallRequested || isUninstallInitiated) && !timedOut
if (uninstallError) {
return (
<span className="flex items-center gap-2 text-foreground-light text-sm">
<AlertCircle size={14} className="text-destructive" />
Uninstallation error
</span>
)
}
if (uninstalling) {
return (
<span className="flex items-center gap-2 text-foreground-light text-sm">
@@ -43,11 +44,11 @@ export const StatusDisplay = ({
</span>
)
}
if (installError) {
if (uninstallError) {
return (
<span className="flex items-center gap-2 text-foreground-light text-sm">
<AlertCircle size={14} className="text-destructive" />
Installation error
Uninstallation error
</span>
)
}
@@ -55,7 +56,15 @@ export const StatusDisplay = ({
return (
<span className="flex items-center gap-2 text-foreground-light text-sm">
<RefreshCwIcon size={14} className="animate-spin text-foreground-lighter" />
Installing...
{isUpgrade ? 'Upgrading...' : 'Installing...'}
</span>
)
}
if (installError) {
return (
<span className="flex items-center gap-2 text-foreground-light text-sm">
<AlertCircle size={14} className="text-destructive" />
{isUpgrade ? 'Upgrade error' : 'Installation error'}
</span>
)
}
@@ -1,5 +1,6 @@
import { EdgeFunctions } from 'icons'
import { Layers, Table } from 'lucide-react'
import { Layers, Loader2, Table } from 'lucide-react'
import { SchemaInstallationStatus } from 'stripe-experiment-sync/supabase'
import { Card, CardContent, cn } from 'ui'
import {
@@ -10,12 +11,12 @@ import {
isInstalling,
isUninstallDone,
isUninstalling,
StripeInstallationStatus,
} from './stripe-sync-status'
type StripeSyncChangesCardProps = {
installationStatus: StripeInstallationStatus
installationStatus: SchemaInstallationStatus
className?: string
isUpgrade?: boolean
}
const ListItemClassName = 'flex items-center gap-x-3 py-2 px-3 border-b'
@@ -23,6 +24,7 @@ const ListItemClassName = 'flex items-center gap-x-3 py-2 px-3 border-b'
export const StripeSyncChangesCard = ({
installationStatus,
className,
isUpgrade,
}: StripeSyncChangesCardProps) => {
const installed = isInstalled(installationStatus)
const installError = hasInstallError(installationStatus)
@@ -32,53 +34,70 @@ export const StripeSyncChangesCard = ({
const installDone = isInstallDone(installationStatus)
const uninstallDone = isUninstallDone(installationStatus)
const title =
uninstallDone || installError
// Special case: installed integration with upgrade available (shown in sheet context)
const isInProgress = installInProgress || uninstallInProgress
const isInstalledWithUpgrade = installed && isUpgrade
const title = isInstalledWithUpgrade
? 'This integration will upgrade your Supabase project:'
: uninstallDone || installError
? 'This integration will modify your Supabase project:'
: installInProgress || uninstallInProgress
: isInProgress
? 'This integration is modifying your Supabase project:'
: installDone || installed || uninstallError
? 'This integration has modified your Supabase project:'
: ''
const dbLine =
uninstallDone || installError
? 'Creates a new database schema named '
const dbLine = isInstalledWithUpgrade
? 'Upgrades the database schema named'
: uninstallDone || installError
? 'Creates a new database schema named'
: installInProgress
? 'Creating a new database schema named '
? isUpgrade
? 'Upgrading database schema named'
: 'Creating a new database schema named'
: installDone || installed || uninstallError
? 'Created a new database schema named '
? 'Created a new database schema named'
: uninstallInProgress
? 'Dropping database schema named '
? 'Dropping database schema named'
: ''
const tableAndViewLine =
uninstallDone || installError
? 'Creates tables and views in the '
const tableAndViewLine = isInstalledWithUpgrade
? 'Upgrades tables and views in the'
: uninstallDone || installError
? 'Creates tables and views in the'
: installInProgress
? 'Creating tables and views in the '
? isUpgrade
? 'Upgrading tables and views in the'
: 'Creating tables and views in the'
: installDone || installed || uninstallError
? 'Created tables and views in the '
? 'Created tables and views in the'
: uninstallInProgress
? 'Dropping tables and views in the '
? 'Dropping tables and views in the'
: ''
const edgeFunctionsLine =
uninstallDone || installError
const edgeFunctionsLine = isInstalledWithUpgrade
? 'Upgrades Edge Functions to handle incoming webhooks from Stripe'
: uninstallDone || installError
? 'Deploys Edge Functions to handle incoming webhooks from Stripe'
: installInProgress
? 'Deploying Edge Functions to handle incoming webhooks from Stripe'
? isUpgrade
? 'Upgrading Edge Functions to handle incoming webhooks from Stripe'
: 'Deploying Edge Functions to handle incoming webhooks from Stripe'
: installDone || installed || uninstallError
? 'Deployed Edge Functions to handle incoming webhooks from Stripe'
: uninstallInProgress
? 'Undeploying Edge Functions to handle incoming webhooks from Stripe'
: ''
const scheduleLine =
uninstallDone || installError
const scheduleLine = isInstalledWithUpgrade
? 'Upgrades automatic Stripe data syncs using Supabase Queues'
: uninstallDone || installError
? 'Schedules automatic Stripe data syncs using Supabase Queues'
: installInProgress
? 'Scheduling automatic Stripe data syncs using Supabase Queues'
? isUpgrade
? 'Upgrading automatic Stripe data syncs using Supabase Queues'
: 'Scheduling automatic Stripe data syncs using Supabase Queues'
: installDone || installed || uninstallError
? 'Scheduled automatic Stripe data syncs using Supabase Queues'
: uninstallInProgress
@@ -92,28 +111,44 @@ export const StripeSyncChangesCard = ({
<CardContent className="p-0">
<ul className="text-foreground-light text-sm">
<li className={ListItemClassName}>
<Table size={16} strokeWidth={1.5} className="text-foreground-lighter shrink-0" />
{isInProgress ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Table size={16} strokeWidth={1.5} className="text-foreground-lighter shrink-0" />
)}
<span>
{dbLine} <code className="text-code-inline">stripe</code>
</span>
</li>
<li className={ListItemClassName}>
<Table size={16} strokeWidth={1.5} className="text-foreground-lighter shrink-0" />
{isInProgress ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Table size={16} strokeWidth={1.5} className="text-foreground-lighter shrink-0" />
)}
<span>
{tableAndViewLine} <code className="text-code-inline">stripe</code> schema for
synced Stripe data
</span>
</li>
<li className={ListItemClassName}>
<EdgeFunctions
size={16}
strokeWidth={1.5}
className="text-foreground-lighter shrink-0"
/>
{isInProgress ? (
<Loader2 size={16} className="animate-spin" />
) : (
<EdgeFunctions
size={16}
strokeWidth={1.5}
className="text-foreground-lighter shrink-0"
/>
)}
<span>{edgeFunctionsLine}</span>
</li>
<li className="flex items-center gap-x-3 py-2 px-3">
<Layers size={16} strokeWidth={1.5} className="text-foreground-lighter shrink-0" />
{isInProgress ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Layers size={16} strokeWidth={1.5} className="text-foreground-lighter shrink-0" />
)}
<span>{scheduleLine}</span>
</li>
</ul>
@@ -1,10 +1,9 @@
import { formatRelative } from 'date-fns'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { BadgeCheck, RefreshCwIcon, Table2 } from 'lucide-react'
import { BadgeCheck, RefreshCwIcon } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { Button, Card, CardContent } from 'ui'
import { Admonition } from 'ui-patterns'
import { Button, Card, CardContent, CardHeader, CardTitle } from 'ui'
import { Admonition, ShimmeringLoader, TimestampInfo } from 'ui-patterns'
import { PageContainer } from 'ui-patterns/PageContainer'
import {
PageSection,
@@ -19,13 +18,11 @@ import { isInstalled, isSyncRunning, isUninstalling } from './stripe-sync-status
import { useStripeSyncStatus } from '@/components/interfaces/Integrations/templates/StripeSyncEngine/useStripeSyncStatus'
export const StripeSyncSettingsPage = () => {
const router = useRouter()
const { data: project } = useSelectedProjectQuery()
const {
parsedSchema: { status: installationStatus },
schemaComment: { status: installationStatus },
syncState,
isLoading: isLoadingInstallationStatus,
} = useStripeSyncStatus({
projectRef: project?.ref,
connectionString: project?.connectionString,
@@ -33,79 +30,92 @@ export const StripeSyncSettingsPage = () => {
const installed = isInstalled(installationStatus)
const isSyncing = isSyncRunning(syncState)
const uninstalling = isUninstalling(installationStatus)
const tableEditorUrl = `/project/${project?.ref}/editor?schema=stripe`
if (!installed || uninstalling) {
return (
<PageContainer className="mx-0">
<PageSection>
<Admonition type="default" title="Stripe Sync Engine is not installed" />
</PageSection>
</PageContainer>
)
}
return (
<PageContainer className="mx-0">
{syncState && installed && !uninstalling && (
<PageSection id="sync-status">
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>Sync Status</PageSectionTitle>
</PageSectionSummary>
</PageSectionMeta>
<PageSectionContent>
<Admonition type="default" showIcon={false}>
<div className="flex items-center justify-between gap-2">
{isSyncing ? (
<>
<div className="flex items-center gap-2">
<RefreshCwIcon size={14} className="animate-spin" />
<div>Sync in progress...</div>
</div>
<div className="text-foreground-light text-sm">
Started{' '}
{syncState.started_at
? formatRelative(new Date(syncState.started_at), new Date())
: 'recently'}
</div>
</>
) : (
<>
<div className="flex items-center gap-2">
<BadgeCheck size={14} className="text-brand" />
<div>All up to date</div>
<Button asChild type="text">
<Link href={tableEditorUrl}>View data</Link>
</Button>
</div>
<div className="text-foreground-light text-sm">
Last synced{' '}
{syncState.closed_at
? formatRelative(new Date(syncState.closed_at), new Date())
: 'recently'}
</div>
</>
)}
</div>
</Admonition>
</PageSectionContent>
</PageSection>
)}
<PageSection id="stripe-schema">
<PageSection>
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>Stripe Schema</PageSectionTitle>
<PageSectionTitle>Manage Stripe data</PageSectionTitle>
<PageSectionDescription>
Access and manage the synced Stripe data in your database.
</PageSectionDescription>
</PageSectionSummary>
</PageSectionMeta>
<PageSectionContent>
<Card>
<Card className="max-w-4xl">
<CardHeader>
<CardTitle className="text-foreground-lighter">
{!syncState ? (
<ShimmeringLoader className="py-2" />
) : (
<div className="flex items-center justify-between gap-2">
{isSyncing ? (
<>
<div className="flex items-center gap-x-3 text-foreground-light">
<RefreshCwIcon size={14} className="animate-spin" />
<p>Sync in progress</p>
</div>
{syncState.started_at && (
<p className="text-foreground-light">
Started{' '}
<TimestampInfo
utcTimestamp={syncState.started_at}
label={
syncState.started_at
? formatRelative(new Date(syncState.started_at), new Date())
: 'recently'
}
/>
</p>
)}
</>
) : (
<>
<div className="flex items-center gap-x-3 text-foreground-light">
<BadgeCheck size={14} />
<p>All up to date</p>
</div>
{syncState.closed_at && (
<p className="text-foreground-light">
Last synced{' '}
<TimestampInfo
utcTimestamp={syncState.closed_at}
label={
syncState.closed_at
? formatRelative(new Date(syncState.closed_at), new Date())
: 'recently'
}
/>
</p>
)}
</>
)}
</div>
)}
</CardTitle>
</CardHeader>
<CardContent className="@container">
<div className="flex flex-col items-start justify-between gap-4 @md:flex-row @md:items-center">
<div className="flex gap-x-4">
<Table2 className="w-5 h-5 shrink-0" />
<div className="flex flex-col gap-1">
<h5 className="text-sm mb-1">Open Stripe schema in Table Editor</h5>
<p className="text-sm text-foreground-light text-balance">
The Stripe Sync Engine stores all synced data in the{' '}
<code className="text-code-inline !break-keep">stripe</code> schema. You can
view and query this data directly in the Table Editor.
</p>
</div>
<div className="flex flex-col gap-1">
<h5 className="text-sm">View Stripe data in Table Editor</h5>
<p className="text-sm text-foreground-light text-balance">
The Stripe Sync Engine stores all synced data in the{' '}
<code className="text-code-inline !break-keep">stripe</code> schema. You can
view and query this data directly in the Table Editor.
</p>
</div>
<Button asChild type="default" className="ml-8 @md:ml-0">
<Link href={`/project/${project?.ref}/editor?schema=stripe`}>
Open Table Editor
@@ -3,177 +3,73 @@ import type {
StripeSyncStateData,
} from 'data/database-integrations/stripe/sync-state-query'
import type { Schema } from 'data/database/schemas-query'
import {
INSTALLATION_ERROR_SUFFIX,
INSTALLATION_INSTALLED_SUFFIX,
INSTALLATION_STARTED_SUFFIX,
STRIPE_SCHEMA_COMMENT_PREFIX,
} from 'stripe-experiment-sync/supabase'
/**
* All possible Stripe Sync installation states.
*/
export type StripeInstallationStatus =
| 'installing'
| 'installed'
| 'install_error'
| 'uninstalling'
| 'uninstalled'
| 'uninstall_error'
/**
* Parsed Stripe schema status including version and error message
*/
export interface ParsedStripeSchema {
/** The installation status */
status: StripeInstallationStatus
/** The version from the schema comment (e.g., 'v1.2.3') */
version?: string
/** Error message if status is install_error or uninstall_error */
errorMessage?: string
}
import { SchemaInstallationStatus, StripeSchemaComment } from 'stripe-experiment-sync/supabase'
/**
* Complete Stripe Sync status including schema, installation state, and sync state
*/
export interface StripeSyncStatusResult {
/** The parsed schema with status, version and error */
parsedSchema: ParsedStripeSchema
schemaComment: StripeSchemaComment
/** Current sync run state (only available when installationStatus is installed) */
syncState: StripeSyncState | undefined
/** Whether the status is still being determined (schemas query is loading) */
isLoading: boolean
}
// TODO: The current version of the package 'stripe-experiment-sync' doesn't export
// these constants, but we plan to export them in future version. For now we
// declare the same constants here to deploy this code without waiting for
// a new version. We'll import them when we bump 'stripe-experiment-sync' package
// version.
const UNINSTALLATION_STARTED_SUFFIX = 'uninstallation:started'
const UNINSTALLATION_ERROR_SUFFIX = 'uninstallation:error'
/** Latest version of stripe sync engine available for installation */
latestAvailableVersion: string
/** True when installation or uninstallation has timed out */
timedOut: boolean
}
export function findStripeSchema(schemas: Schema[] | undefined): Schema | undefined {
return schemas?.find((s) => s.name === 'stripe')
}
function isStripeSyncSchema(schema: Schema | undefined): boolean {
return !!schema?.comment?.startsWith(STRIPE_SCHEMA_COMMENT_PREFIX)
}
/**
* Parse the installation status from a stripe schema.
*
* Schema comment format: {PREFIX} {version} {SUFFIX}[ - {error message}]
* Example: "stripe_sync v1.2.3 installation:error - Could not apply migration"
*
* @param stripeSchema - The stripe schema from the database, if it exists
* @returns The parsed status including version and optional error message
*/
export function parseStripeSchema(stripeSchema: Schema | undefined): ParsedStripeSchema {
if (!isStripeSyncSchema(stripeSchema)) {
return { status: 'uninstalled' }
}
const comment = stripeSchema!.comment!
// Remove prefix and leading space
const afterPrefix = comment.slice(STRIPE_SCHEMA_COMMENT_PREFIX.length).trimStart()
// Split at first space to get version and trailing segment
const firstSpaceIndex = afterPrefix.indexOf(' ')
if (firstSpaceIndex === -1) {
return { status: 'uninstalled' }
}
const version = afterPrefix.slice(0, firstSpaceIndex)
const trailing = afterPrefix.slice(firstSpaceIndex + 1)
// Helper to extract error message if present after ' - ' separator
const extractError = (afterSuffix: string): string | undefined => {
if (afterSuffix.startsWith(' - ')) {
return afterSuffix.slice(3) // Remove ' - ' (3 characters)
}
return afterSuffix
}
// Check status in priority order
if (trailing.startsWith(UNINSTALLATION_ERROR_SUFFIX)) {
return {
status: 'uninstall_error',
version,
errorMessage: extractError(trailing.slice(UNINSTALLATION_ERROR_SUFFIX.length)),
}
}
if (trailing.startsWith(INSTALLATION_ERROR_SUFFIX)) {
return {
status: 'install_error',
version,
errorMessage: extractError(trailing.slice(INSTALLATION_ERROR_SUFFIX.length)),
}
}
if (trailing.startsWith(UNINSTALLATION_STARTED_SUFFIX)) {
return { status: 'uninstalling', version }
}
if (trailing.startsWith(INSTALLATION_STARTED_SUFFIX)) {
return { status: 'installing', version }
}
if (trailing.startsWith(INSTALLATION_INSTALLED_SUFFIX)) {
return { status: 'installed', version }
}
return { status: 'uninstalled' }
}
export function isInstalled(status: StripeInstallationStatus): boolean {
export function isInstalled(status: SchemaInstallationStatus): boolean {
return status === 'installed'
}
export function isUninstalled(status: StripeInstallationStatus): boolean {
export function isUninstalled(status: SchemaInstallationStatus): boolean {
return status === 'uninstalled'
}
export function hasInstallError(status: StripeInstallationStatus): boolean {
return status === 'install_error'
export function hasInstallError(status: SchemaInstallationStatus): boolean {
return status === 'install error'
}
export function hasUninstallError(status: StripeInstallationStatus): boolean {
return status === 'uninstall_error'
export function hasUninstallError(status: SchemaInstallationStatus): boolean {
return status === 'uninstall error'
}
export function hasError(status: StripeInstallationStatus): boolean {
export function hasError(status: SchemaInstallationStatus): boolean {
return hasInstallError(status) || hasUninstallError(status)
}
export function isInstalling(status: StripeInstallationStatus): boolean {
export function isInstalling(status: SchemaInstallationStatus): boolean {
return status === 'installing'
}
export function isUninstalling(status: StripeInstallationStatus): boolean {
export function isUninstalling(status: SchemaInstallationStatus): boolean {
return status === 'uninstalling'
}
export function isInProgress(status: StripeInstallationStatus): boolean {
export function isInProgress(status: SchemaInstallationStatus): boolean {
return isInstalling(status) || isUninstalling(status)
}
export function isInstallDone(status: StripeInstallationStatus): boolean {
export function isInstallDone(status: SchemaInstallationStatus): boolean {
return isInstalled(status) || hasInstallError(status)
}
export function isUninstallDone(status: StripeInstallationStatus): boolean {
export function isUninstallDone(status: SchemaInstallationStatus): boolean {
return isUninstalled(status) || hasUninstallError(status)
}
export function canInstall(status: StripeInstallationStatus): boolean {
export function canInstall(status: SchemaInstallationStatus): boolean {
return isUninstalled(status) || hasError(status)
}
@@ -1,15 +1,19 @@
import { useStripeSyncingState } from 'data/database-integrations/stripe/sync-state-query'
import { SchemasVariables, useSchemasQuery } from 'data/database/schemas-query'
import { useEffect } from 'react'
import { checkDomainOfScale } from 'recharts/types/util/ChartUtils'
import { getCurrentVersion, parseSchemaComment } from 'stripe-experiment-sync/supabase'
import {
findStripeSchema,
isInProgress,
isInstalled,
parseStripeSchema,
type StripeSyncStatusResult,
} from '@/components/interfaces/Integrations/templates/StripeSyncEngine/stripe-sync-status'
// Maximum time allowed for installation or uninstallation operations before the UI times out
const OPERATION_TIME_OUT_MS: number = 5 * 60 * 1000 // 5 minutes
/**
* Unified hook for Stripe Sync installation status.
*
@@ -21,6 +25,8 @@ export function useStripeSyncStatus({
projectRef,
connectionString,
}: SchemasVariables): StripeSyncStatusResult {
const latestAvailableVersion = getCurrentVersion()
// Query schemas once
const {
data: schemas,
@@ -30,10 +36,42 @@ export function useStripeSyncStatus({
// Find and parse stripe schema status
const stripeSchema = findStripeSchema(schemas)
const parsedSchema = parseStripeSchema(stripeSchema)
const rawSchemaComment = parseSchemaComment(stripeSchema?.comment)
const installed = isInstalled(parsedSchema.status)
const inProgress = isInProgress(parsedSchema.status)
const now = Date.now()
const timedOut = rawSchemaComment.startTime
? now - rawSchemaComment.startTime > OPERATION_TIME_OUT_MS
: false
const status = timedOut
? rawSchemaComment.status === 'installing'
? 'install error'
: rawSchemaComment.status === 'uninstalling'
? 'uninstall error'
: rawSchemaComment.status
: rawSchemaComment.status
const errorMessage = timedOut
? rawSchemaComment.status === 'installing'
? 'Installation timed out'
: rawSchemaComment.status === 'uninstalling'
? 'Uninstallation timed out'
: rawSchemaComment.errorMessage
: rawSchemaComment.errorMessage
const schemaComment = { ...rawSchemaComment, status, errorMessage }
const installed = isInstalled(schemaComment.status)
const inProgress = isInProgress(schemaComment.status)
// Query sync state only when installed
const { data: syncState, isPending: isLoadingStripeSyncState } = useStripeSyncingState(
{ projectRef: projectRef!, connectionString },
{
refetchInterval: 4000,
enabled: !!projectRef && installed,
}
)
// Poll schemas during install/uninstall operations
useEffect(() => {
@@ -48,18 +86,11 @@ export function useStripeSyncStatus({
return () => clearInterval(interval)
}, [inProgress, refetch])
// Query sync state only when installed
const { data: syncState, isLoading: isSyncStateLoading } = useStripeSyncingState(
{ projectRef: projectRef!, connectionString },
{
refetchInterval: 4000,
enabled: !!projectRef && installed,
}
)
return {
parsedSchema,
schemaComment,
syncState: installed ? syncState : undefined,
isLoading: isSchemasLoading,
isLoading: isSchemasLoading || isLoadingStripeSyncState,
latestAvailableVersion,
timedOut,
}
}
@@ -39,7 +39,6 @@ import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { InfoTooltip } from 'ui-patterns/info-tooltip'
import { z } from 'zod'
import { urlRegex } from '../Auth/Auth.constants'
import {
DATADOG_REGIONS,
LAST9_REGIONS,
@@ -51,19 +50,18 @@ import {
getHeadersSectionDescription as getHeadersDescription,
validateNewHeader,
} from './LogDrains.utils'
import { httpEndpointUrlSchema } from '@/lib/validation/http-url'
const FORM_ID = 'log-drain-destination-form'
const formUnion = z.discriminatedUnion('type', [
z.object({
type: z.literal('webhook'),
url: z
.string()
.regex(urlRegex(), 'Endpoint URL is required and must be a valid URL')
.refine(
(url) => url.startsWith('http://') || url.startsWith('https://'),
'Endpoint URL must start with http:// or https://'
),
url: httpEndpointUrlSchema({
requiredMessage: 'Endpoint URL is required',
invalidMessage: 'Endpoint URL must be a valid URL',
prefixMessage: 'Endpoint URL must start with http:// or https://',
}),
http: z.enum(['http1', 'http2']),
gzip: z.boolean(),
headers: z.record(z.string(), z.string()).optional(),
@@ -75,13 +73,11 @@ const formUnion = z.discriminatedUnion('type', [
}),
z.object({
type: z.literal('loki'),
url: z
.string()
.min(1, { message: 'Loki URL is required' })
.refine(
(url) => url.startsWith('http://') || url.startsWith('https://'),
'Loki URL must start with http:// or https://'
),
url: httpEndpointUrlSchema({
requiredMessage: 'Loki URL is required',
invalidMessage: 'Loki URL must be a valid URL',
prefixMessage: 'Loki URL must start with http:// or https://',
}),
headers: z.record(z.string(), z.string()),
username: z.string().optional(),
password: z.string().optional(),
@@ -130,13 +126,11 @@ const formUnion = z.discriminatedUnion('type', [
}),
z.object({
type: z.literal('otlp'),
endpoint: z
.string()
.min(1, { message: 'OTLP endpoint is required' })
.refine(
(url) => url.startsWith('http://') || url.startsWith('https://'),
'OTLP endpoint must start with http:// or https://'
),
endpoint: httpEndpointUrlSchema({
requiredMessage: 'OTLP endpoint is required',
invalidMessage: 'OTLP endpoint must be a valid URL',
prefixMessage: 'OTLP endpoint must start with http:// or https://',
}),
protocol: z.string().optional().default('http/protobuf'),
gzip: z.boolean().optional().default(true),
headers: z.record(z.string(), z.string()).optional(),
@@ -223,6 +223,18 @@ describe('otlpConfigSchema', () => {
expect(result.success).toBe(false)
})
it('rejects endpoint with an incomplete hostname', () => {
const config = {
type: 'otlp' as const,
endpoint: 'https://webhook',
}
const result = otlpConfigSchema.safeParse(config)
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.issues[0].message).toContain('must be a valid URL')
}
})
it('rejects wrong type field', () => {
const config = {
type: 'webhook' as const,
@@ -6,6 +6,7 @@
import { z } from 'zod'
import { LogDrainType } from './LogDrains.constants'
import { httpEndpointUrlSchema } from '@/lib/validation/http-url'
/**
* Get the description text for the custom headers section based on log drain type
@@ -62,13 +63,11 @@ export function validateNewHeader(
*/
export const otlpConfigSchema = z.object({
type: z.literal('otlp'),
endpoint: z
.string()
.min(1, { message: 'OTLP endpoint is required' })
.refine(
(url) => url.startsWith('http://') || url.startsWith('https://'),
'OTLP endpoint must start with http:// or https://'
),
endpoint: httpEndpointUrlSchema({
requiredMessage: 'OTLP endpoint is required',
invalidMessage: 'OTLP endpoint must be a valid URL',
prefixMessage: 'OTLP endpoint must start with http:// or https://',
}),
protocol: z.string().optional().default('http/protobuf'),
gzip: z.boolean().optional().default(true),
headers: z.record(z.string(), z.string()).optional(),
@@ -55,6 +55,16 @@ const renderEndpointSheet = (
return { onClose, onSubmit }
}
const submitForm = () =>
fireEvent.submit(document.getElementById('platform-webhook-endpoint-form')!)
const getUrlInput = () => screen.getByPlaceholderText('https://api.example.com/webhooks/supabase')
const findEventTypeCheckbox = (eventType: string) =>
screen.findByRole('checkbox', {
name: new RegExp(eventType.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')),
})
describe('PlatformWebhooksEndpointSheet', () => {
afterEach(() => {
vi.clearAllMocks()
@@ -85,7 +95,7 @@ describe('PlatformWebhooksEndpointSheet', () => {
const nameInput = await screen.findByDisplayValue('Billing events')
await user.clear(nameInput)
fireEvent.submit(document.getElementById('platform-webhook-endpoint-form')!)
submitForm()
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
expect(onSubmit).toHaveBeenCalledWith(
@@ -108,7 +118,7 @@ describe('PlatformWebhooksEndpointSheet', () => {
await user.click(screen.getByRole('button', { name: 'Add header' }))
await user.type(screen.getByPlaceholderText('Header name'), 'X-Webhook-Secret')
await user.type(screen.getByPlaceholderText('Header value'), 'super-secret')
fireEvent.submit(document.getElementById('platform-webhook-endpoint-form')!)
submitForm()
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
expect(onSubmit).toHaveBeenCalledWith(
@@ -118,4 +128,110 @@ describe('PlatformWebhooksEndpointSheet', () => {
expect.anything()
)
})
it('blocks submit when the endpoint URL is empty', async () => {
const user = userEvent.setup()
const { onSubmit } = renderEndpointSheet()
await user.click(await findEventTypeCheckbox('project.updated'))
submitForm()
expect(await screen.findByText('Please provide a URL')).toBeInTheDocument()
expect(onSubmit).not.toHaveBeenCalled()
})
it('blocks submit when the endpoint URL is malformed', async () => {
const user = userEvent.setup()
const { onSubmit } = renderEndpointSheet()
await user.type(getUrlInput(), 'https://not a url')
await user.click(await findEventTypeCheckbox('project.updated'))
submitForm()
expect(await screen.findByText('Please provide a valid URL')).toBeInTheDocument()
expect(onSubmit).not.toHaveBeenCalled()
})
it('blocks submit when the endpoint URL uses an incomplete hostname', async () => {
const user = userEvent.setup()
const { onSubmit } = renderEndpointSheet()
await user.type(getUrlInput(), 'https://webhook')
await user.click(await findEventTypeCheckbox('project.updated'))
submitForm()
expect(await screen.findByText('Please provide a valid URL')).toBeInTheDocument()
expect(onSubmit).not.toHaveBeenCalled()
})
it('blocks submit when the endpoint URL does not include a protocol', async () => {
const user = userEvent.setup()
const { onSubmit } = renderEndpointSheet()
await user.type(getUrlInput(), 'hooks.example.com/billing')
await user.click(await findEventTypeCheckbox('project.updated'))
submitForm()
expect(
await screen.findByText('Please prefix your URL with http:// or https://')
).toBeInTheDocument()
expect(onSubmit).not.toHaveBeenCalled()
})
it('shows an error when no event types are selected', async () => {
const user = userEvent.setup()
const { onSubmit } = renderEndpointSheet()
await user.type(getUrlInput(), 'https://hooks.example.com/billing')
submitForm()
expect(await screen.findByText('Select at least one event type')).toBeInTheDocument()
expect(onSubmit).not.toHaveBeenCalled()
})
it('clears the event type error after selecting an event and allows submit', async () => {
const user = userEvent.setup()
const { onSubmit } = renderEndpointSheet()
await user.type(getUrlInput(), 'https://hooks.example.com/billing')
submitForm()
expect(await screen.findByText('Select at least one event type')).toBeInTheDocument()
await user.click(await findEventTypeCheckbox('project.updated'))
await waitFor(() => {
expect(screen.queryByText('Select at least one event type')).not.toBeInTheDocument()
})
submitForm()
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
url: 'https://hooks.example.com/billing',
eventTypes: ['project.updated'],
}),
expect.anything()
)
})
it('allows submit when subscribe all is enabled', async () => {
const user = userEvent.setup()
const { onSubmit } = renderEndpointSheet()
await user.type(getUrlInput(), 'https://hooks.example.com/billing')
await user.click(screen.getByRole('checkbox', { name: /subscribe to all events/i }))
submitForm()
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
subscribeAll: true,
eventTypes: PROJECT_EVENT_TYPES,
url: 'https://hooks.example.com/billing',
}),
expect.anything()
)
})
})
@@ -39,11 +39,16 @@ import type {
WebhookScope,
} from './PlatformWebhooks.types'
import { generateWebhookEndpointName } from './PlatformWebhooks.utils'
import { httpEndpointUrlSchema } from '@/lib/validation/http-url'
const endpointFormSchema = z
.object({
name: z.string().trim().max(64, 'Name cannot exceed 64 characters'),
url: z.string().trim().url('Please enter a valid URL'),
url: httpEndpointUrlSchema({
requiredMessage: 'Please provide a URL',
invalidMessage: 'Please provide a valid URL',
prefixMessage: 'Please prefix your URL with http:// or https://',
}),
description: z.string().trim().max(512, 'Description cannot exceed 512 characters'),
enabled: z.boolean().default(true),
subscribeAll: z.boolean().default(false),
@@ -341,188 +346,190 @@ export const PlatformWebhooksEndpointSheet = ({
<Separator />
<div className="px-5 space-y-3">
<FormItemLayout
label="Event types"
description={
scope === 'organization' ? (
<>
Project events are triggered when any project in this organization matches
the event type. Add a{' '}
<InlineLink href="/project/_/settings/webhooks">
project endpoint
</InlineLink>{' '}
to listen to events on an individual project only.
</>
) : (
<>
Project events are triggered for this project only. Add an{' '}
<InlineLink href={`/org/${orgSlug ?? '_'}/webhooks`}>
organization endpoint
</InlineLink>{' '}
to listen to events from any project in your organization.
</>
)
}
layout="vertical"
className="gap-3"
>
<FormField_Shadcn_
control={form.control}
name="subscribeAll"
render={({ field }) => {
const subscribeAllId = 'subscribe-all-events'
return (
<div className="rounded-md border bg-surface-100 overflow-hidden">
<Label
htmlFor={subscribeAllId}
className={cn(
'flex w-full cursor-pointer items-center gap-3 px-4 py-3',
field.value ? 'bg-surface-100' : 'bg-surface-200'
)}
<FormField_Shadcn_
control={form.control}
name="eventTypes"
render={({ field, fieldState }) => {
const selectedTypes = field.value ?? []
const hasEventTypeError = !!fieldState.error
return (
<FormItemLayout
label="Event types"
description={
scope === 'organization' ? (
<>
Project events are triggered when any project in this organization
matches the event type. Add a{' '}
<InlineLink href="/project/_/settings/webhooks">
project endpoint
</InlineLink>{' '}
to listen to events on an individual project only.
</>
) : (
<>
Project events are triggered for this project only. Add an{' '}
<InlineLink href={`/org/${orgSlug ?? '_'}/webhooks`}>
organization endpoint
</InlineLink>{' '}
to listen to events from any project in your organization.
</>
)
}
layout="vertical"
className="gap-2"
>
<FormField_Shadcn_
control={form.control}
name="subscribeAll"
render={({ field }) => {
const subscribeAllId = 'subscribe-all-events'
return (
<div className="rounded-md border bg-surface-100 overflow-hidden">
<Label
htmlFor={subscribeAllId}
className={cn(
'flex w-full cursor-pointer items-center gap-3 px-4 py-3',
field.value ? 'bg-surface-100' : 'bg-surface-200'
)}
>
<FormControl_Shadcn_>
<Checkbox
id={subscribeAllId}
checked={field.value}
onCheckedChange={(checked) => {
const nextValue = Boolean(checked)
field.onChange(nextValue)
if (nextValue) {
form.setValue('eventTypes', eventTypes, {
shouldDirty: true,
shouldValidate: true,
})
return
}
form.setValue('eventTypes', [], {
shouldDirty: true,
shouldValidate: true,
})
}}
/>
</FormControl_Shadcn_>
<span className="text-sm text-foreground">
Subscribe to all events{' '}
<code className="text-code-inline">(*)</code>
</span>
</Label>
</div>
)
}}
/>
<FormControl_Shadcn_>
<Accordion
type="multiple"
value={openEventGroups}
onValueChange={setOpenEventGroups}
className="mt-2 space-y-2"
>
<FormControl_Shadcn_>
<Checkbox
id={subscribeAllId}
checked={field.value}
onCheckedChange={(checked) => {
const nextValue = Boolean(checked)
field.onChange(nextValue)
{groupedEventTypes.map((group) => {
const selectedInGroup = group.eventTypes.filter((eventType) =>
selectedTypes.includes(eventType)
)
const allSelected = selectedInGroup.length === group.eventTypes.length
const isGroupOpen = openEventGroups.includes(group.id)
if (nextValue) {
form.setValue('eventTypes', eventTypes, {
shouldDirty: true,
shouldValidate: true,
})
return
}
form.setValue('eventTypes', [], {
shouldDirty: true,
shouldValidate: true,
})
}}
/>
</FormControl_Shadcn_>
<span className="text-sm text-foreground">
Subscribe to all events <code className="text-code-inline">(*)</code>
</span>
</Label>
</div>
)
}}
/>
<FormField_Shadcn_
control={form.control}
name="eventTypes"
render={({ field }) => {
const selectedTypes = field.value ?? []
return (
<>
<FormControl_Shadcn_>
<Accordion
type="multiple"
value={openEventGroups}
onValueChange={setOpenEventGroups}
className="mt-2 space-y-2"
>
{groupedEventTypes.map((group) => {
const selectedInGroup = group.eventTypes.filter((eventType) =>
selectedTypes.includes(eventType)
)
const allSelected =
selectedInGroup.length === group.eventTypes.length
const isGroupOpen = openEventGroups.includes(group.id)
return (
<AccordionItem
key={group.id}
value={group.id}
className="overflow-hidden rounded-md border"
return (
<AccordionItem
key={group.id}
value={group.id}
className={cn(
'overflow-hidden rounded-md border',
hasEventTypeError && 'border-destructive-400'
)}
>
<AccordionTrigger
hideIcon
className="group px-4 py-3 hover:no-underline"
>
<AccordionTrigger
hideIcon
className="group px-4 py-3 hover:no-underline"
>
<div className="flex w-full items-center justify-between gap-3">
<div className="flex items-center gap-2">
<p className="text-sm text-foreground-light">
{group.label}
</p>
{selectedInGroup.length > 0 && (
<span className="text-xs text-foreground-muted">
{selectedInGroup.length}
</span>
)}
</div>
<div className="flex items-center gap-3">
{isGroupOpen && group.eventTypes.length > 1 && (
<span
className="text-xs text-foreground-muted hover:text-foreground"
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
<div className="flex w-full items-center justify-between gap-3">
<div className="flex items-center gap-2">
<p className="text-sm text-foreground-light">
{group.label}
</p>
{selectedInGroup.length > 0 && (
<span className="text-xs text-foreground-muted">
{selectedInGroup.length}
</span>
)}
</div>
<div className="flex items-center gap-3">
{isGroupOpen && group.eventTypes.length > 1 && (
<span
className="text-xs text-foreground-muted hover:text-foreground"
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
field.onChange(
toggleEventTypeGroup(
selectedTypes,
group.eventTypes,
!allSelected
)
)
}}
>
{allSelected ? 'Clear all' : 'Select all'}
</span>
)}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200 group-data-[state=open]:rotate-180" />
</div>
</div>
</AccordionTrigger>
<AccordionContent className="pb-0 pt-0 [&>div]:pb-0 [&>div]:pt-0">
<div className="divide-y border-t">
{group.eventTypes.map((eventType) => {
const checked = selectedTypes.includes(eventType)
const eventTypeId = toControlId('event-type', eventType)
return (
<Label
key={eventType}
htmlFor={eventTypeId}
className={cn(
'flex w-full cursor-pointer items-center gap-3 px-4 py-3 transition-colors hover:bg-surface-200',
checked && 'bg-surface-100'
)}
>
<Checkbox
id={eventTypeId}
checked={checked}
onCheckedChange={(next) => {
field.onChange(
toggleEventTypeGroup(
toggleEventType(
selectedTypes,
group.eventTypes,
!allSelected
eventType,
Boolean(next)
)
)
}}
>
{allSelected ? 'Clear all' : 'Select all'}
</span>
)}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200 group-data-[state=open]:rotate-180" />
</div>
</div>
</AccordionTrigger>
<AccordionContent className="pb-0 pt-0 [&>div]:pb-0 [&>div]:pt-0">
<div className="divide-y border-t">
{group.eventTypes.map((eventType) => {
const checked = selectedTypes.includes(eventType)
const eventTypeId = toControlId('event-type', eventType)
return (
<Label
key={eventType}
htmlFor={eventTypeId}
className={cn(
'flex w-full cursor-pointer items-center gap-3 px-4 py-3 transition-colors hover:bg-surface-200',
checked && 'bg-surface-100'
)}
>
<Checkbox
id={eventTypeId}
checked={checked}
onCheckedChange={(next) => {
field.onChange(
toggleEventType(
selectedTypes,
eventType,
Boolean(next)
)
)
}}
/>
<code className="text-code-inline">{eventType}</code>
</Label>
)
})}
</div>
</AccordionContent>
</AccordionItem>
)
})}
</Accordion>
</FormControl_Shadcn_>
</>
)
}}
/>
</FormItemLayout>
/>
<code className="text-code-inline">{eventType}</code>
</Label>
)
})}
</div>
</AccordionContent>
</AccordionItem>
)
})}
</Accordion>
</FormControl_Shadcn_>
</FormItemLayout>
)
}}
/>
</div>
<Separator />
@@ -19,3 +19,40 @@ export interface QueryPerformanceRow {
_count?: number
first_seen?: string
}
export type QueryPerformancePreset =
| 'mostFrequentlyInvoked'
| 'mostTimeConsuming'
| 'slowestExecutionTime'
| 'queryHitRate'
| 'unified'
| 'slowQueriesCount'
| 'queryMetrics'
export type QueryPerformanceSort = {
column:
| 'query'
| 'rolname'
| 'total_time'
| 'prop_total_time'
| 'calls'
| 'avg_rows'
| 'max_time'
| 'mean_time'
| 'min_time'
order: 'asc' | 'desc'
}
export type QuerySource = 'dashboard' | 'non-dashboard'
export type QueryPerformanceSQLParams = {
preset: QueryPerformancePreset
orderBy?: QueryPerformanceSort
searchQuery?: string
roles?: string[]
sources?: QuerySource[]
minCalls?: number
minTotalTime?: number
runIndexAdvisor?: boolean
filterIndexAdvisor?: boolean
}
@@ -1,24 +1,28 @@
import { parseAsArrayOf, parseAsJson, parseAsString, useQueryStates } from 'nuqs'
import { ReactNode, useEffect, useState } from 'react'
import {
NumericFilter,
ReportsNumericFilter,
} from 'components/interfaces/Reports/v2/ReportsNumericFilter'
import { useDebouncedValue } from 'hooks/misc/useDebouncedValue'
import { parseAsArrayOf, parseAsJson, parseAsString, useQueryStates } from 'nuqs'
import { ReactNode, useEffect, useState } from 'react'
import { FilterInput } from './components/FilterInput'
import { FilterPill } from './components/FilterPill'
import { IndexAdvisorFilter } from './components/IndexAdvisorFilter'
import { RolesFilterDropdown } from './components/RolesFilterDropdown'
import { SortIndicator } from './components/SortIndicator'
import { SourceFilterDropdown } from './components/SourceFilterDropdown'
import { useIndexAdvisorStatus } from './hooks/useIsIndexAdvisorStatus'
import { useQueryPerformanceSort } from './hooks/useQueryPerformanceSort'
import { useDebouncedValue } from 'hooks/misc/useDebouncedValue'
export const QueryPerformanceFilterBar = ({
actions,
showRolesFilter = false,
showSourceFilter = false,
}: {
actions?: ReactNode
showRolesFilter?: boolean
showSourceFilter?: boolean
}) => {
const { sort, clearSort } = useQueryPerformanceSort()
const { isIndexAdvisorEnabled } = useIndexAdvisorStatus()
@@ -27,6 +31,7 @@ export const QueryPerformanceFilterBar = ({
{
search: searchQuery,
roles: defaultFilterRoles,
sources: defaultFilterSources,
callsFilter: callsFilterRaw,
totalTimeFilter: totalTimeFilterRaw,
indexAdvisor,
@@ -35,6 +40,7 @@ export const QueryPerformanceFilterBar = ({
] = useQueryStates({
search: parseAsString.withDefault(''),
roles: parseAsArrayOf(parseAsString).withDefault([]),
sources: parseAsArrayOf(parseAsString).withDefault([]),
callsFilter: parseAsJson<NumericFilter | null>((value) =>
value === null || value === undefined ? null : (value as NumericFilter)
),
@@ -47,8 +53,9 @@ export const QueryPerformanceFilterBar = ({
const callsFilter = callsFilterRaw ?? null
const totalTimeFilter = totalTimeFilterRaw ?? null
const [filters, setFilters] = useState<{ roles: string[] }>({
const [filters, setFilters] = useState<{ roles: string[]; sources: string[] }>({
roles: defaultFilterRoles,
sources: defaultFilterSources,
})
const [inputValue, setInputValue] = useState(searchQuery)
@@ -61,6 +68,11 @@ export const QueryPerformanceFilterBar = ({
setSearchParams({ roles })
}
const onFilterSourcesChange = (sources: string[]) => {
setFilters({ ...filters, sources })
setSearchParams({ sources })
}
const debouncedInputValue = useDebouncedValue(inputValue, 300)
const onIndexAdvisorToggle = () => {
@@ -139,8 +151,8 @@ export const QueryPerformanceFilterBar = ({
value={filters.roles.join(', ')}
onClear={(e) => {
e.stopPropagation()
setFilters({ roles: [] })
setSearchParams({ roles: [] })
setFilters({ ...filters, roles: [] })
setSearchParams({ ...filters, roles: [] })
}}
/>
) : (
@@ -150,6 +162,13 @@ export const QueryPerformanceFilterBar = ({
/>
))}
{showSourceFilter && (
<SourceFilterDropdown
activeOptions={filters.sources}
onSaveFilters={onFilterSourcesChange}
/>
)}
{isIndexAdvisorEnabled && (
<IndexAdvisorFilter
isActive={indexAdvisor === 'true'}
@@ -1,15 +1,13 @@
import React, { useMemo } from 'react'
import { parseAsJson, useQueryStates } from 'nuqs'
import { Info } from 'lucide-react'
import { Skeleton, cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
import { useQueryPerformanceQuery } from '../Reports/Reports.queries'
import { NumericFilter } from 'components/interfaces/Reports/v2/ReportsNumericFilter'
import { Info } from 'lucide-react'
import { parseAsJson, useQueryStates } from 'nuqs'
import React, { useMemo } from 'react'
import { cn, Skeleton, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
import { useQueryPerformanceQuery } from './useQueryPerformanceQuery'
export const QueryPerformanceMetrics = () => {
const { data: queryMetrics, isLoading } = useQueryPerformanceQuery({
preset: 'queryMetrics',
})
const { data: queryMetrics, isLoading } = useQueryPerformanceQuery({ preset: 'queryMetrics' })
const [, setSearchParams] = useQueryStates({
totalTimeFilter: parseAsJson<NumericFilter | null>((value) =>
@@ -1,31 +1,30 @@
import { LoadingLine, cn } from 'ui'
import { useState, useEffect, useMemo } from 'react'
import { Button } from 'ui'
import { X, RefreshCw, RotateCcw } from 'lucide-react'
import { Markdown } from '../../Markdown'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { Admonition } from 'ui-patterns'
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
import { LOCAL_STORAGE_KEYS, useParams } from 'common'
import { useDatabaseSelectorStateSnapshot } from 'state/database-selector'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { DOCS_URL, IS_PLATFORM } from 'lib/constants'
import { executeSql } from 'data/sql/execute-sql-query'
import { toast } from 'sonner'
import { PresetHookResult } from 'components/interfaces/Reports/Reports.utils'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { DownloadResultsButton } from 'components/ui/DownloadResultsButton'
import { useReadReplicasQuery } from 'data/read-replicas/replicas-query'
import { formatDatabaseID } from 'data/read-replicas/replicas.utils'
import { PresetHookResult } from 'components/interfaces/Reports/Reports.utils'
import { executeSql } from 'data/sql/execute-sql-query'
import { DbQueryHook } from 'hooks/analytics/useDbQuery'
import { QueryPerformanceMetrics } from '../QueryPerformanceMetrics'
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { DOCS_URL, IS_PLATFORM } from 'lib/constants'
import { getErrorMessage } from 'lib/get-error-message'
import { RefreshCw, RotateCcw, X } from 'lucide-react'
import { parseAsString, useQueryStates } from 'nuqs'
import { useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { useDatabaseSelectorStateSnapshot } from 'state/database-selector'
import { Button, cn, LoadingLine } from 'ui'
import { Admonition } from 'ui-patterns'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { Markdown } from '../../Markdown'
import { captureQueryPerformanceError } from '../QueryPerformance.utils'
import { QueryPerformanceFilterBar } from '../QueryPerformanceFilterBar'
import { QueryPerformanceGrid } from '../QueryPerformanceGrid'
import { QueryPerformanceMetrics } from '../QueryPerformanceMetrics'
import { transformStatementDataToRows } from './WithStatements.utils'
import { DownloadResultsButton } from 'components/ui/DownloadResultsButton'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { captureQueryPerformanceError } from '../QueryPerformance.utils'
import { getErrorMessage } from 'lib/get-error-message'
import { parseAsString, useQueryStates } from 'nuqs'
interface WithStatementsProps {
queryHitRate: PresetHookResult
@@ -155,7 +154,8 @@ export const WithStatements = ({
)}
<QueryPerformanceMetrics />
<QueryPerformanceFilterBar
showRolesFilter={true}
showRolesFilter
showSourceFilter
actions={
<>
<ButtonTooltip
@@ -0,0 +1,36 @@
import { FilterPopover } from 'components/ui/FilterPopover'
interface SourceFilterDropdownProps {
activeOptions: string[]
onSaveFilters: (options: string[]) => void
className?: string
}
const sources = [
{
name: 'dashboard',
displayName: 'Dashboard',
},
{
name: 'non-dashboard',
displayName: 'Non-dashboard',
},
]
export const SourceFilterDropdown = ({
activeOptions,
onSaveFilters,
className,
}: SourceFilterDropdownProps) => {
return (
<FilterPopover
name="Source"
options={sources}
valueKey="name"
labelKey="displayName"
activeOptions={activeOptions}
onSaveFilters={onSaveFilters}
className={className || 'w-60'}
/>
)
}
@@ -1,22 +1,19 @@
import { useQueryClient } from '@tanstack/react-query'
import { useTableIndexAdvisor } from 'components/grid/context/TableIndexAdvisorContext'
import { databaseIndexesKeys } from 'data/database-indexes/keys'
import { databaseKeys } from 'data/database/keys'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { useRouter } from 'next/router'
import { parseAsString, useQueryStates } from 'nuqs'
import { useCallback } from 'react'
import {
QueryPerformanceSort,
useQueryPerformanceQuery,
} from 'components/interfaces/Reports/Reports.queries'
import { databaseIndexesKeys } from 'data/database-indexes/keys'
import { databaseKeys } from 'data/database/keys'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import {
QUERY_PERFORMANCE_PRESET_MAP,
QUERY_PERFORMANCE_REPORT_TYPES,
} from '../QueryPerformance.constants'
import { type QueryPerformanceSort } from '../QueryPerformance.types'
import { useQueryPerformanceQuery } from '../useQueryPerformanceQuery'
import { useIndexAdvisorStatus } from './useIsIndexAdvisorStatus'
import { useTableEditorTableStateSnapshot } from 'state/table-editor-table'
import { useTableIndexAdvisor } from 'components/grid/context/TableIndexAdvisorContext'
export function useIndexInvalidation() {
const router = useRouter()
@@ -1,5 +1,6 @@
import { useQueryStates, parseAsString } from 'nuqs'
import { QueryPerformanceSort } from '../../Reports/Reports.queries'
import { parseAsString, useQueryStates } from 'nuqs'
import { type QueryPerformanceSort } from '../QueryPerformance.types'
export const useQueryPerformanceSort = () => {
const [{ sort, order }, setQueryStates] = useQueryStates({
@@ -0,0 +1,138 @@
import { describe, expect, it } from 'vitest'
import { generateQueryPerformanceSql } from './useQueryPerformanceQuery'
describe('generateQueryPerformanceSql', () => {
it('generates sql with no filters', () => {
const result = generateQueryPerformanceSql({ preset: 'mostFrequentlyInvoked' })
expect(result.sql).toBeDefined()
expect(result.whereSql).toBe('')
expect(result.orderBySql).toBeUndefined()
})
it('generates ORDER BY clause', () => {
const result = generateQueryPerformanceSql({
preset: 'mostFrequentlyInvoked',
orderBy: { column: 'calls', order: 'desc' },
})
expect(result.orderBySql).toBe('ORDER BY calls desc')
})
it('filters by roles', () => {
const result = generateQueryPerformanceSql({
preset: 'mostFrequentlyInvoked',
roles: ['postgres', 'anon'],
})
expect(result.whereSql).toContain("auth.rolname in ('postgres', 'anon')")
})
it('filters by search query', () => {
const result = generateQueryPerformanceSql({
preset: 'mostFrequentlyInvoked',
searchQuery: 'SELECT',
})
expect(result.whereSql).toContain("statements.query ~* 'SELECT'")
})
it('filters by dashboard source only', () => {
const result = generateQueryPerformanceSql({
preset: 'mostFrequentlyInvoked',
sources: ['dashboard'],
})
expect(result.whereSql).toContain("statements.query ~* 'source: dashboard'")
})
it('filters by non-dashboard source only', () => {
const result = generateQueryPerformanceSql({
preset: 'mostFrequentlyInvoked',
sources: ['non-dashboard'],
})
expect(result.whereSql).toContain("statements.query !~* 'source: dashboard'")
})
it('does not add source filter when both sources are selected', () => {
const result = generateQueryPerformanceSql({
preset: 'mostFrequentlyInvoked',
sources: ['dashboard', 'non-dashboard'],
})
expect(result.whereSql).not.toContain('source: dashboard')
})
it('filters by minimum calls', () => {
const result = generateQueryPerformanceSql({
preset: 'mostFrequentlyInvoked',
minCalls: 10,
})
expect(result.whereSql).toContain('statements.calls >= 10')
})
it('does not filter by minCalls when 0', () => {
const result = generateQueryPerformanceSql({
preset: 'mostFrequentlyInvoked',
minCalls: 0,
})
expect(result.whereSql).not.toContain('statements.calls')
})
it('filters by minimum total time', () => {
const result = generateQueryPerformanceSql({
preset: 'mostFrequentlyInvoked',
minTotalTime: 500,
})
expect(result.whereSql).toContain(
'(statements.total_exec_time + statements.total_plan_time) >= 500'
)
})
it('combines multiple WHERE conditions with AND', () => {
const result = generateQueryPerformanceSql({
preset: 'mostFrequentlyInvoked',
roles: ['postgres'],
searchQuery: 'SELECT',
minCalls: 5,
})
expect(result.whereSql).toContain("auth.rolname in ('postgres')")
expect(result.whereSql).toContain("statements.query ~* 'SELECT'")
expect(result.whereSql).toContain('statements.calls >= 5')
expect(result.whereSql.split(' AND ').length).toBe(3)
})
it('passes WHERE clause to base sql function', () => {
const result = generateQueryPerformanceSql({
preset: 'mostFrequentlyInvoked',
roles: ['postgres'],
})
expect(result.sql).toContain('WHERE')
})
it('does not include user-defined WHERE conditions when no filters set', () => {
const result = generateQueryPerformanceSql({ preset: 'mostFrequentlyInvoked' })
expect(result.whereSql).toBe('')
// The base SQL may still contain its own WHERE clause (e.g. statements.calls > 0)
// but the user-defined whereSql should be empty
})
it('passes runIndexAdvisor and filterIndexAdvisor flags', () => {
const resultWithAdvisor = generateQueryPerformanceSql({
preset: 'mostFrequentlyInvoked',
runIndexAdvisor: true,
filterIndexAdvisor: true,
})
expect(resultWithAdvisor.sql).toBeDefined()
})
it('works with different presets', () => {
const presets = [
'mostFrequentlyInvoked',
'mostTimeConsuming',
'slowestExecutionTime',
'queryHitRate',
'unified',
] as const
for (const preset of presets) {
const result = generateQueryPerformanceSql({ preset })
expect(result.sql).toBeDefined()
}
})
})
@@ -0,0 +1,61 @@
import useDbQuery from 'hooks/analytics/useDbQuery'
import { PRESET_CONFIG } from '../Reports/Reports.constants'
import { Presets } from '../Reports/Reports.types'
import { QueryPerformanceSQLParams } from './QueryPerformance.types'
export function generateQueryPerformanceSql({
preset,
orderBy,
searchQuery = '',
roles = [],
sources = [],
minCalls = 0,
minTotalTime = 0,
runIndexAdvisor = false,
filterIndexAdvisor = false,
}: QueryPerformanceSQLParams) {
const queryPerfQueries = PRESET_CONFIG[Presets.QUERY_PERFORMANCE]
const baseSQL = queryPerfQueries.queries[preset]
const orderBySql = orderBy && `ORDER BY ${orderBy.column} ${orderBy.order}`
const whereConditions = []
if (roles.length > 0) {
whereConditions.push(`auth.rolname in (${roles.map((r) => `'${r}'`).join(', ')})`)
}
if (searchQuery.length > 0) {
whereConditions.push(`statements.query ~* '${searchQuery}'`)
}
if (sources.includes('dashboard') && !sources.includes('non-dashboard')) {
whereConditions.push(`statements.query ~* 'source: dashboard'`)
}
if (sources.includes('non-dashboard') && !sources.includes('dashboard')) {
whereConditions.push(`statements.query !~* 'source: dashboard'`)
}
if (minCalls > 0) {
whereConditions.push(`statements.calls >= ${minCalls}`)
}
if (minTotalTime > 0) {
whereConditions.push(
`(statements.total_exec_time + statements.total_plan_time) >= ${minTotalTime}`
)
}
const whereSql = whereConditions.join(' AND ')
const sql = baseSQL.sql(
[],
whereSql.length > 0 ? `WHERE ${whereSql}` : undefined,
orderBySql,
runIndexAdvisor,
filterIndexAdvisor
)
return { sql, whereSql, orderBySql }
}
export const useQueryPerformanceQuery = (props: QueryPerformanceSQLParams) => {
const { sql, whereSql, orderBySql } = generateQueryPerformanceSql(props)
return useDbQuery({ sql, params: undefined, where: whereSql, orderBy: orderBySql })
}
@@ -1,77 +0,0 @@
import useDbQuery from 'hooks/analytics/useDbQuery'
import { PRESET_CONFIG } from './Reports.constants'
import { Presets } from './Reports.types'
export type QueryPerformanceSort = {
column:
| 'query'
| 'rolname'
| 'total_time'
| 'prop_total_time'
| 'calls'
| 'avg_rows'
| 'max_time'
| 'mean_time'
| 'min_time'
order: 'asc' | 'desc'
}
export type QueryPerformanceQueryOpts = {
preset:
| 'mostFrequentlyInvoked'
| 'mostTimeConsuming'
| 'slowestExecutionTime'
| 'queryHitRate'
| 'unified'
| 'slowQueriesCount'
| 'queryMetrics'
searchQuery?: string
orderBy?: QueryPerformanceSort
roles?: string[]
runIndexAdvisor?: boolean
minCalls?: number
minTotalTime?: number
filterIndexAdvisor?: boolean
}
export const useQueryPerformanceQuery = ({
preset,
orderBy,
searchQuery = '',
roles,
runIndexAdvisor = false,
minCalls,
minTotalTime,
filterIndexAdvisor = false,
}: QueryPerformanceQueryOpts) => {
const queryPerfQueries = PRESET_CONFIG[Presets.QUERY_PERFORMANCE]
const baseSQL = queryPerfQueries.queries[preset]
const whereSql = [
roles !== undefined && roles.length > 0
? `auth.rolname in (${roles.map((r) => `'${r}'`).join(', ')})`
: '',
searchQuery.length > 0 ? `statements.query ~* '${searchQuery}'` : '',
typeof minCalls === 'number' && minCalls > 0 ? `statements.calls >= ${minCalls}` : '',
typeof minTotalTime === 'number' && minTotalTime > 0
? `(statements.total_exec_time + statements.total_plan_time) >= ${minTotalTime}`
: '',
]
.filter((x) => x.length > 0)
.join(' AND ')
const orderBySql = orderBy && `ORDER BY ${orderBy.column} ${orderBy.order}`
const sql = baseSQL.sql(
[],
whereSql.length > 0 ? `WHERE ${whereSql}` : undefined,
orderBySql,
runIndexAdvisor,
filterIndexAdvisor
)
return useDbQuery({
sql,
params: undefined,
where: whereSql,
orderBy: orderBySql,
})
}
@@ -1,14 +1,14 @@
import dayjs from 'dayjs'
import {
isUnixMicro,
unixMicroToIsoTimestamp,
} from 'components/interfaces/Settings/Logs/Logs.utils'
import { REPORT_STATUS_CODE_COLORS } from 'data/reports/report.utils'
import dayjs from 'dayjs'
import useDbQuery, { DbQueryHook } from 'hooks/analytics/useDbQuery'
import useLogsQuery, { LogsQueryHook } from 'hooks/analytics/useLogsQuery'
import { getHttpStatusCodeInfo } from 'lib/http-status-codes'
import type { BaseQueries, PresetConfig, ReportQuery } from './Reports.types'
import { type BaseQueries, type PresetConfig, type ReportQuery } from './Reports.types'
/**
* Converts a query params string to an object
@@ -11,7 +11,7 @@ import { getReturnToPath } from 'lib/gotrue'
import { Lock } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { SubmitHandler, useForm } from 'react-hook-form'
import { Button, Form_Shadcn_, FormControl_Shadcn_, FormField_Shadcn_, Input_Shadcn_ } from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
@@ -34,12 +34,15 @@ export const SignInMfaForm = ({ context = 'sign-in' }: SignInMfaFormProps) => {
const router = useRouter()
const signOut = useSignOut()
const queryClient = useQueryClient()
const [selectedFactor, setSelectedFactor] = useState<Factor | null>(null)
const form = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema),
defaultValues: { code: '' },
})
const { code } = form.watch()
const {
data: factors,
error: factorsError,
@@ -89,6 +92,10 @@ export const SignInMfaForm = ({ context = 'sign-in' }: SignInMfaFormProps) => {
}
}, [factors?.totp, isSuccessFactors, router, queryClient])
useEffect(() => {
if (code.length === 6) form.handleSubmit(onSubmit)()
}, [code])
const error = useAuthError()
if (error) {
@@ -134,7 +141,7 @@ export const SignInMfaForm = ({ context = 'sign-in' }: SignInMfaFormProps) => {
</div>
<Input_Shadcn_
id="code"
className="pl-10"
className="pl-10 font-mono"
{...field}
autoFocus
autoComplete="off"
@@ -150,7 +157,7 @@ export const SignInMfaForm = ({ context = 'sign-in' }: SignInMfaFormProps) => {
)}
/>
<div className="flex items-center justify-between space-x-2">
<div className="flex items-center justify-between gap-x-2">
<Button
block
type="outline"
@@ -1,12 +1,12 @@
import { FOREIGN_KEY_CASCADE_ACTION } from '@supabase/pg-meta'
import type { PostgresColumn } from '@supabase/postgres-meta'
import { isNull } from 'lodash'
import type { Dictionary } from 'types'
import { FOREIGN_KEY_CASCADE_ACTION } from 'data/database/database-query-constants'
import type { ForeignKeyConstraint } from 'data/database/foreign-key-constraints-query'
import type { RetrievedTableColumn, RetrieveTableResult } from 'data/tables/table-retrieve-query'
import { uuidv4 } from 'lib/helpers'
import { isNull } from 'lodash'
import { toast } from 'sonner'
import type { Dictionary } from 'types'
import {
ColumnField,
CreateColumnPayload,
@@ -32,7 +32,9 @@ const isSQLExpression = (input: string) => {
return false
}
export const generateColumnField = (field: any = {}): ColumnField => {
export const generateColumnField = (
field: { name?: string; table?: string; schema?: string; format?: string } = {}
): ColumnField => {
const { name, table, schema, format } = field
return {
id: uuidv4(),
@@ -173,7 +175,7 @@ export const generateUpdateColumnPayload = (
}
export const validateFields = (field: ColumnField) => {
const errors = {} as Dictionary<any>
const errors = {} as Dictionary<string>
if (field.name.length === 0) {
errors['name'] = `Please assign a name for your column`
toast.error(errors['name'])
@@ -1,9 +1,29 @@
import { FOREIGN_KEY_CASCADE_ACTION } from 'data/database/database-query-constants'
import { FOREIGN_KEY_CASCADE_ACTION } from '@supabase/pg-meta'
export const FOREIGN_KEY_CASCADE_OPTIONS = [
{ key: 'no-action', label: 'No action', value: FOREIGN_KEY_CASCADE_ACTION.NO_ACTION },
{ key: 'cascade', label: 'Cascade', value: FOREIGN_KEY_CASCADE_ACTION.CASCADE },
{ key: 'restrict', label: 'Restrict', value: FOREIGN_KEY_CASCADE_ACTION.RESTRICT },
{ key: 'set-default', label: 'Set default', value: FOREIGN_KEY_CASCADE_ACTION.SET_DEFAULT },
{ key: 'set-null', label: 'Set NULL', value: FOREIGN_KEY_CASCADE_ACTION.SET_NULL },
{
key: 'no-action',
label: 'No action',
value: FOREIGN_KEY_CASCADE_ACTION.NO_ACTION,
},
{
key: 'cascade',
label: 'Cascade',
value: FOREIGN_KEY_CASCADE_ACTION.CASCADE,
},
{
key: 'restrict',
label: 'Restrict',
value: FOREIGN_KEY_CASCADE_ACTION.RESTRICT,
},
{
key: 'set-default',
label: 'Set default',
value: FOREIGN_KEY_CASCADE_ACTION.SET_DEFAULT,
},
{
key: 'set-null',
label: 'Set NULL',
value: FOREIGN_KEY_CASCADE_ACTION.SET_NULL,
},
]
@@ -1,32 +1,39 @@
import { FOREIGN_KEY_CASCADE_ACTION } from '@supabase/pg-meta'
import type { PostgresTable } from '@supabase/postgres-meta'
import { sortBy } from 'lodash'
import { ArrowRight, Database, HelpCircle, Loader2, Table, X } from 'lucide-react'
import { Fragment, useEffect, useState } from 'react'
import {
AlertDescription_Shadcn_,
AlertTitle_Shadcn_,
Alert_Shadcn_,
Button,
Listbox,
SidePanel,
} from 'ui'
import { DiscardChangesConfirmationDialog } from 'components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog'
import { DocsButton } from 'components/ui/DocsButton'
import InformationBox from 'components/ui/InformationBox'
import { FOREIGN_KEY_CASCADE_ACTION } from 'data/database/database-query-constants'
import { useSchemasQuery } from 'data/database/schemas-query'
import { useTableQuery } from 'data/tables/table-retrieve-query'
import { useTablesQuery } from 'data/tables/tables-query'
import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { useConfirmOnClose } from 'hooks/ui/useConfirmOnClose'
import { DOCS_URL } from 'lib/constants'
import { uuidv4 } from 'lib/helpers'
import { sortBy } from 'lodash'
import { ArrowRight, Database, HelpCircle, Loader2, Table, X } from 'lucide-react'
import { Fragment, useEffect, useState } from 'react'
import {
Alert_Shadcn_,
AlertDescription_Shadcn_,
AlertTitle_Shadcn_,
Button,
Listbox,
SidePanel,
} from 'ui'
import { ActionBar } from '../ActionBar'
import { NUMERICAL_TYPES, TEXT_TYPES } from '../SidePanelEditor.constants'
import type { ColumnField } from '../SidePanelEditor.types'
import { FOREIGN_KEY_CASCADE_OPTIONS } from './ForeignKeySelector.constants'
import type { ForeignKey, SelectorErrors, SelectorTypeError } from './ForeignKeySelector.types'
import { generateCascadeActionDescription } from './ForeignKeySelector.utils'
import {
generateCascadeActionDescription,
hasForeignKeySelectorChanges,
normalizeForeignKeyForDirtyCheck,
type ForeignKeyDirtyState,
} from './ForeignKeySelector.utils'
const EMPTY_STATE: ForeignKey = {
id: undefined,
@@ -63,6 +70,7 @@ export const ForeignKeySelector = ({
const [fk, setFk] = useState(EMPTY_STATE)
const [errors, setErrors] = useState<SelectorErrors>({})
const [initialSnapshot, setInitialSnapshot] = useState<ForeignKeyDirtyState>()
const hasTypeErrors = (errors.types ?? []).length > 0
const hasTypeNotices = (errors.typeNotice ?? []).length > 0
@@ -93,6 +101,11 @@ export const ForeignKeySelector = ({
const disableApply = isLoadingSelectedTable || selectedTable === undefined || hasTypeErrors
const { confirmOnClose, modalProps } = useConfirmOnClose({
checkIsDirty: () => hasForeignKeySelectorChanges(initialSnapshot, fk),
onClose,
})
const updateSelectedSchema = (schema: string) => {
const updatedFk = { ...EMPTY_STATE, id: fk.id, schema }
setFk(updatedFk)
@@ -203,8 +216,11 @@ export const ForeignKeySelector = ({
useEffect(() => {
if (visible) {
if (foreignKey !== undefined) setFk(foreignKey)
else setFk({ ...EMPTY_STATE, id: uuidv4() })
const initialFk = foreignKey !== undefined ? foreignKey : { ...EMPTY_STATE, id: uuidv4() }
setErrors({})
setFk(initialFk)
setInitialSnapshot(normalizeForeignKeyForDirtyCheck(initialFk))
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [visible])
@@ -215,137 +231,176 @@ export const ForeignKeySelector = ({
}, [fk, visible])
return (
<SidePanel
visible={visible}
onCancel={onClose}
className="max-w-[480px]"
header={`${foreignKey === undefined ? 'Add' : 'Manage'} foreign key relationship${foreignKey === undefined ? ' to' : 's for'} ${table.name.length > 0 ? table.name : 'new table'}`}
customFooter={
<ActionBar
backButtonLabel="Cancel"
disableApply={disableApply}
applyButtonLabel="Save"
closePanel={onClose}
applyFunction={(resolve) => validateSelection(resolve)}
/>
}
>
<SidePanel.Content>
<div className="py-6 space-y-6">
<InformationBox
icon={<HelpCircle size={20} strokeWidth={1.5} />}
title="What are foreign keys?"
description={`Foreign keys help maintain referential integrity of your data by ensuring that no
<>
<SidePanel
visible={visible}
onCancel={confirmOnClose}
className="max-w-[480px]"
header={`${foreignKey === undefined ? 'Add' : 'Manage'} foreign key relationship${foreignKey === undefined ? ' to' : 's for'} ${table.name.length > 0 ? table.name : 'new table'}`}
customFooter={
<ActionBar
backButtonLabel="Cancel"
disableApply={disableApply}
applyButtonLabel="Save"
closePanel={confirmOnClose}
applyFunction={(resolve) => validateSelection(resolve)}
/>
}
>
<SidePanel.Content>
<div className="py-6 space-y-6">
<InformationBox
icon={<HelpCircle size={20} strokeWidth={1.5} />}
title="What are foreign keys?"
description={`Foreign keys help maintain referential integrity of your data by ensuring that no
one can insert rows into the table that do not have a matching entry to another
table.`}
url="https://www.postgresql.org/docs/current/tutorial-fk.html"
urlLabel="Postgres Foreign Key Documentation"
/>
url="https://www.postgresql.org/docs/current/tutorial-fk.html"
urlLabel="Postgres Foreign Key Documentation"
/>
<Listbox
id="schema"
label="Select a schema"
value={fk.schema}
onChange={(value: string) => updateSelectedSchema(value)}
>
{sortedSchemas.map((schema) => {
return (
<Listbox.Option
key={schema.id}
value={schema.name}
label={schema.name}
className="min-w-96"
addOnBefore={() => <Database size={16} strokeWidth={1.5} />}
>
<div className="flex items-center gap-2">
{/* For aria searching to target the schema name instead of schema */}
<span className="hidden">{schema.name}</span>
<span className="text-foreground">{schema.name}</span>
</div>
</Listbox.Option>
)
})}
</Listbox>
<Listbox
id="table"
label="Select a table to reference to"
value={selectedTable?.id ?? 1}
onChange={(value: string) => updateSelectedTable(Number(value))}
disabled={isLoadingSelectedTable}
>
<Listbox.Option key="empty" className="min-w-96" value={1} label="---">
---
</Listbox.Option>
{sortBy(tables, ['schema']).map((table) => {
return (
<Listbox.Option
key={table.id}
value={table.id}
label={table.name}
className="min-w-96"
addOnBefore={() => <Table size={16} strokeWidth={1.5} />}
>
<div className="flex items-center gap-2">
{/* For aria searching to target the table name instead of schema */}
<span className="hidden">{table.name}</span>
<span className="text-foreground-lighter">{table.schema}</span>
<span className="text-foreground">{table.name}</span>
</div>
</Listbox.Option>
)
})}
</Listbox>
{fk.schema && fk.table && (
<>
{isLoadingSelectedTable ? (
<div className="flex py-6 flex-col items-center justify-center space-y-2">
<Loader2 size={14} className="animate-spin" />
<p className="text-sm text-foreground-light">Loading table columns</p>
</div>
) : (
<div className="flex flex-col gap-y-3">
<label className="text-foreground-light text-sm">
Select columns from{' '}
<code className="text-code-inline">
{fk.schema}.{fk.table}
</code>{' '}
to reference to
</label>
<div className="grid grid-cols-10 gap-y-2">
<div className="col-span-5 text-xs text-foreground-lighter">
{selectedSchema}.{table.name.length > 0 ? table.name : '[unnamed table]'}
<Listbox
id="schema"
label="Select a schema"
value={fk.schema}
onChange={(value: string) => updateSelectedSchema(value)}
>
{sortedSchemas.map((schema) => {
return (
<Listbox.Option
key={schema.id}
value={schema.name}
label={schema.name}
className="min-w-96"
addOnBefore={() => <Database size={16} strokeWidth={1.5} />}
>
<div className="flex items-center gap-2">
{/* For aria searching to target the schema name instead of schema */}
<span className="hidden">{schema.name}</span>
<span className="text-foreground">{schema.name}</span>
</div>
<div className="col-span-4 text-xs text-foreground-lighter text-right">
{fk.schema}.{fk.table}
</Listbox.Option>
)
})}
</Listbox>
<Listbox
id="table"
label="Select a table to reference to"
value={selectedTable?.id ?? 1}
onChange={(value: string) => updateSelectedTable(Number(value))}
disabled={isLoadingSelectedTable}
>
<Listbox.Option key="empty" className="min-w-96" value={1} label="---">
---
</Listbox.Option>
{sortBy(tables, ['schema']).map((table) => {
return (
<Listbox.Option
key={table.id}
value={table.id}
label={table.name}
className="min-w-96"
addOnBefore={() => <Table size={16} strokeWidth={1.5} />}
>
<div className="flex items-center gap-2">
{/* For aria searching to target the table name instead of schema */}
<span className="hidden">{table.name}</span>
<span className="text-foreground-lighter">{table.schema}</span>
<span className="text-foreground">{table.name}</span>
</div>
{fk.columns.length === 0 && (
<Alert_Shadcn_ className="col-span-10 py-2 px-3">
<AlertDescription_Shadcn_>
There are no foreign key relations between the tables
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
)}
{fk.columns.map((_, idx) => (
<Fragment key={`${fk.schema}-${fk.table}-${idx}`}>
<div className="col-span-4">
<Listbox
id="column"
value={fk.columns[idx].source}
onChange={(value: string) => updateSelectedColumn(idx, 'source', value)}
>
<Listbox.Option
key="empty"
value={''}
label="---"
className="!w-[170px]"
</Listbox.Option>
)
})}
</Listbox>
{fk.schema && fk.table && (
<>
{isLoadingSelectedTable ? (
<div className="flex py-6 flex-col items-center justify-center space-y-2">
<Loader2 size={14} className="animate-spin" />
<p className="text-sm text-foreground-light">Loading table columns</p>
</div>
) : (
<div className="flex flex-col gap-y-3">
<label className="text-foreground-light text-sm">
Select columns from{' '}
<code className="text-code-inline">
{fk.schema}.{fk.table}
</code>{' '}
to reference to
</label>
<div className="grid grid-cols-10 gap-y-2">
<div className="col-span-5 text-xs text-foreground-lighter">
{selectedSchema}.{table.name.length > 0 ? table.name : '[unnamed table]'}
</div>
<div className="col-span-4 text-xs text-foreground-lighter text-right">
{fk.schema}.{fk.table}
</div>
{fk.columns.length === 0 && (
<Alert_Shadcn_ className="col-span-10 py-2 px-3">
<AlertDescription_Shadcn_>
There are no foreign key relations between the tables
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
)}
{fk.columns.map((_, idx) => (
<Fragment key={`${fk.schema}-${fk.table}-${idx}`}>
<div className="col-span-4">
<Listbox
id="column"
value={fk.columns[idx].source}
onChange={(value: string) =>
updateSelectedColumn(idx, 'source', value)
}
>
---
</Listbox.Option>
{(table?.columns ?? [])
.filter((x) => x.name.length !== 0)
.map((column) => (
<Listbox.Option
key="empty"
value={''}
label="---"
className="!w-[170px]"
>
---
</Listbox.Option>
{(table?.columns ?? [])
.filter((x) => x.name.length !== 0)
.map((column) => (
<Listbox.Option
key={column.id}
value={column.name}
label={column.name}
className="!w-[170px]"
>
<div className="flex items-center gap-2">
<span className="text-foreground">{column.name}</span>
<span className="text-foreground-lighter">
{column.format === '' ? '-' : column.format}
</span>
</div>
</Listbox.Option>
))}
</Listbox>
</div>
<div className="col-span-1 flex justify-center items-center">
<ArrowRight />
</div>
<div className="col-span-4">
<Listbox
id="column"
value={fk.columns[idx].target}
onChange={(value: string) =>
updateSelectedColumn(idx, 'target', value)
}
>
<Listbox.Option
key="empty"
value={''}
label="---"
className="!w-[170px]"
>
---
</Listbox.Option>
{(selectedTable?.columns ?? []).map((column) => (
<Listbox.Option
key={column.id}
value={column.name}
@@ -354,208 +409,177 @@ export const ForeignKeySelector = ({
>
<div className="flex items-center gap-2">
<span className="text-foreground">{column.name}</span>
<span className="text-foreground-lighter">
{column.format === '' ? '-' : column.format}
</span>
<span className="text-foreground-lighter">{column.format}</span>
</div>
</Listbox.Option>
))}
</Listbox>
</div>
<div className="col-span-1 flex justify-center items-center">
<ArrowRight />
</div>
<div className="col-span-4">
<Listbox
id="column"
value={fk.columns[idx].target}
onChange={(value: string) => updateSelectedColumn(idx, 'target', value)}
>
<Listbox.Option
key="empty"
value={''}
label="---"
className="!w-[170px]"
>
---
</Listbox.Option>
{(selectedTable?.columns ?? []).map((column) => (
<Listbox.Option
key={column.id}
value={column.name}
label={column.name}
className="!w-[170px]"
>
<div className="flex items-center gap-2">
<span className="text-foreground">{column.name}</span>
<span className="text-foreground-lighter">{column.format}</span>
</div>
</Listbox.Option>
))}
</Listbox>
</div>
<div className="col-span-1 flex justify-end items-center">
<Button
type="default"
className="px-1"
icon={<X />}
disabled={fk.columns.length === 1}
onClick={() => onRemoveColumn(idx)}
/>
</div>
</Fragment>
))}
</Listbox>
</div>
<div className="col-span-1 flex justify-end items-center">
<Button
type="default"
className="px-1"
icon={<X />}
disabled={fk.columns.length === 1}
onClick={() => onRemoveColumn(idx)}
/>
</div>
</Fragment>
))}
</div>
<div className="space-y-2">
<Button type="default" onClick={addColumn}>
Add another column
</Button>
{errors.columns && <p className="text-red-900 text-sm">{errors.columns}</p>}
{hasTypeErrors && (
<Alert_Shadcn_ variant="warning">
<AlertTitle_Shadcn_>Column types do not match</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
The following columns cannot be referenced as they are not of the same
type:
</AlertDescription_Shadcn_>
<ul className="list-disc pl-5 mt-2 text-foreground-light">
{(errors?.types ?? []).map((x, idx: number) => {
if (x === undefined) return null
return (
<li key={`type-error-${idx}`}>
<code className="text-code-inline">{x.source}</code> (
{x.sourceType}) and{' '}
<code className="text-code-inline">{x.target}</code>(
{x.targetType})
</li>
)
})}
</ul>
</Alert_Shadcn_>
)}
{hasTypeNotices && (
<Alert_Shadcn_>
<AlertTitle_Shadcn_>Column types will be updated</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
The following columns will have their types updated to match their
referenced column
</AlertDescription_Shadcn_>
<ul className="list-disc pl-5 mt-2 text-foreground-light">
{(errors?.typeNotice ?? []).map((x, idx: number) => {
if (x === undefined) return null
return (
<li key={`type-error-${idx}`}>
<div className="flex items-center gap-x-1">
<code className="text-code-inline">{x.source}</code>{' '}
<ArrowRight size={14} /> {x.targetType}
</div>
</li>
)
})}
</ul>
</Alert_Shadcn_>
)}
</div>
</div>
<div className="space-y-2">
<Button type="default" onClick={addColumn}>
Add another column
</Button>
{errors.columns && <p className="text-red-900 text-sm">{errors.columns}</p>}
{hasTypeErrors && (
<Alert_Shadcn_ variant="warning">
<AlertTitle_Shadcn_>Column types do not match</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
The following columns cannot be referenced as they are not of the same
type:
</AlertDescription_Shadcn_>
<ul className="list-disc pl-5 mt-2 text-foreground-light">
{(errors?.types ?? []).map((x, idx: number) => {
if (x === undefined) return null
return (
<li key={`type-error-${idx}`}>
<code className="text-code-inline">{x.source}</code> ({x.sourceType}
) and <code className="text-code-inline">{x.target}</code>(
{x.targetType})
</li>
)
})}
</ul>
</Alert_Shadcn_>
)}
{hasTypeNotices && (
<Alert_Shadcn_>
<AlertTitle_Shadcn_>Column types will be updated</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
The following columns will have their types updated to match their
referenced column
</AlertDescription_Shadcn_>
<ul className="list-disc pl-5 mt-2 text-foreground-light">
{(errors?.typeNotice ?? []).map((x, idx: number) => {
if (x === undefined) return null
return (
<li key={`type-error-${idx}`}>
<div className="flex items-center gap-x-1">
<code className="text-code-inline">{x.source}</code>{' '}
<ArrowRight size={14} /> {x.targetType}
</div>
</li>
)
})}
</ul>
</Alert_Shadcn_>
)}
</div>
</div>
)}
)}
{!isLoadingSelectedTable && (
<>
<SidePanel.Separator />
{!isLoadingSelectedTable && (
<>
<SidePanel.Separator />
<InformationBox
icon={<HelpCircle size="20" strokeWidth={1.5} />}
title="Which action is most appropriate?"
description={
<>
<p>
The choice of the action depends on what kinds of objects the related
tables represent:
</p>
<ul className="mt-2 list-disc pl-4 space-y-1">
<li>
<code className="text-code-inline">Cascade</code>: if the referencing
table represents something that is a component of what is represented by
the referenced table and cannot exist independently
</li>
<li>
<code className="text-code-inline">Restrict</code> or{' '}
<code className="text-code-inline">No action</code>: if the two tables
represent independent objects
</li>
<li>
<code className="text-code-inline">Set NULL</code> or{' '}
<code className="text-code-inline">Set default</code>: if a foreign-key
relationship represents optional information
</li>
</ul>
<p className="mt-2">
Typically, restricting and cascading deletes are the most common options,
but the default behavior is no action
</p>
</>
}
url="https://www.postgresql.org/docs/current/ddl-constraints.html#DDL-CONSTRAINTS-FK"
urlLabel="More information"
/>
<InformationBox
icon={<HelpCircle size="20" strokeWidth={1.5} />}
title="Which action is most appropriate?"
description={
<>
<p>
The choice of the action depends on what kinds of objects the related
tables represent:
</p>
<ul className="mt-2 list-disc pl-4 space-y-1">
<li>
<code className="text-code-inline">Cascade</code>: if the referencing
table represents something that is a component of what is represented
by the referenced table and cannot exist independently
</li>
<li>
<code className="text-code-inline">Restrict</code> or{' '}
<code className="text-code-inline">No action</code>: if the two tables
represent independent objects
</li>
<li>
<code className="text-code-inline">Set NULL</code> or{' '}
<code className="text-code-inline">Set default</code>: if a
foreign-key relationship represents optional information
</li>
</ul>
<p className="mt-2">
Typically, restricting and cascading deletes are the most common
options, but the default behavior is no action
</p>
</>
}
url="https://www.postgresql.org/docs/current/ddl-constraints.html#DDL-CONSTRAINTS-FK"
urlLabel="More information"
/>
<Listbox
id="updateAction"
value={fk.updateAction}
label="Action if referenced row is updated"
descriptionText={
<p>
{generateCascadeActionDescription(
'update',
fk.updateAction,
`${fk.schema}.${fk.table}`
)}
</p>
}
onChange={(value: string) => updateCascadeAction('updateAction', value)}
>
{FOREIGN_KEY_CASCADE_OPTIONS.filter((option) =>
['no-action', 'cascade', 'restrict'].includes(option.key)
).map((option) => (
<Listbox.Option key={option.key} value={option.value} label={option.label}>
<p className="text-foreground">{option.label}</p>
</Listbox.Option>
))}
</Listbox>
<Listbox
id="deletionAction"
value={fk.deletionAction}
className="[&>div>label]:flex [&>div>label]:items-center"
label="Action if referenced row is removed"
// @ts-ignore
labelOptional={
<DocsButton href={`${DOCS_URL}/guides/database/postgres/cascade-deletes`} />
}
descriptionText={
<>
<Listbox
id="updateAction"
value={fk.updateAction}
label="Action if referenced row is updated"
descriptionText={
<p>
{generateCascadeActionDescription(
'delete',
fk.deletionAction,
'update',
fk.updateAction,
`${fk.schema}.${fk.table}`
)}
</p>
</>
}
onChange={(value: string) => updateCascadeAction('deletionAction', value)}
>
{FOREIGN_KEY_CASCADE_OPTIONS.map((option) => (
<Listbox.Option key={option.key} value={option.value} label={option.label}>
<p className="text-foreground">{option.label}</p>
</Listbox.Option>
))}
</Listbox>
</>
)}
</>
)}
</div>
</SidePanel.Content>
</SidePanel>
}
onChange={(value: string) => updateCascadeAction('updateAction', value)}
>
{FOREIGN_KEY_CASCADE_OPTIONS.filter((option) =>
['no-action', 'cascade', 'restrict'].includes(option.key)
).map((option) => (
<Listbox.Option key={option.key} value={option.value} label={option.label}>
<p className="text-foreground">{option.label}</p>
</Listbox.Option>
))}
</Listbox>
<Listbox
id="deletionAction"
value={fk.deletionAction}
className="[&>div>label]:flex [&>div>label]:items-center"
label="Action if referenced row is removed"
// @ts-ignore
labelOptional={
<DocsButton href={`${DOCS_URL}/guides/database/postgres/cascade-deletes`} />
}
descriptionText={
<>
<p>
{generateCascadeActionDescription(
'delete',
fk.deletionAction,
`${fk.schema}.${fk.table}`
)}
</p>
</>
}
onChange={(value: string) => updateCascadeAction('deletionAction', value)}
>
{FOREIGN_KEY_CASCADE_OPTIONS.map((option) => (
<Listbox.Option key={option.key} value={option.value} label={option.label}>
<p className="text-foreground">{option.label}</p>
</Listbox.Option>
))}
</Listbox>
</>
)}
</>
)}
</div>
</SidePanel.Content>
</SidePanel>
<DiscardChangesConfirmationDialog {...modalProps} />
</>
)
}
@@ -0,0 +1,84 @@
import { describe, expect, it } from 'vitest'
import type { ForeignKey } from './ForeignKeySelector.types'
import {
hasForeignKeySelectorChanges,
normalizeForeignKeyForDirtyCheck,
} from './ForeignKeySelector.utils'
const BASE_FOREIGN_KEY: ForeignKey = {
id: 'fk-1',
name: 'messages_author_id_fkey',
tableId: 42,
schema: 'public',
table: 'profiles',
columns: [{ source: 'author_id', target: 'id' }],
deletionAction: 'NO ACTION',
updateAction: 'NO ACTION',
}
describe('ForeignKeySelector dirty state', () => {
it('is clean on first open', () => {
const initialState = normalizeForeignKeyForDirtyCheck(BASE_FOREIGN_KEY)
expect(hasForeignKeySelectorChanges(initialState, BASE_FOREIGN_KEY)).toBe(false)
})
it('is dirty after semantic foreign key changes', () => {
const initialState = normalizeForeignKeyForDirtyCheck(BASE_FOREIGN_KEY)
expect(
hasForeignKeySelectorChanges(initialState, {
...BASE_FOREIGN_KEY,
schema: 'storage',
})
).toBe(true)
expect(
hasForeignKeySelectorChanges(initialState, {
...BASE_FOREIGN_KEY,
table: 'users',
tableId: 99,
})
).toBe(true)
expect(
hasForeignKeySelectorChanges(initialState, {
...BASE_FOREIGN_KEY,
columns: [{ source: 'owner_id', target: 'id' }],
})
).toBe(true)
expect(
hasForeignKeySelectorChanges(initialState, {
...BASE_FOREIGN_KEY,
deletionAction: 'CASCADE',
})
).toBe(true)
expect(
hasForeignKeySelectorChanges(initialState, {
...BASE_FOREIGN_KEY,
updateAction: 'CASCADE',
})
).toBe(true)
})
it('ignores derived column type metadata when checking dirty state', () => {
const initialState = normalizeForeignKeyForDirtyCheck(BASE_FOREIGN_KEY)
expect(
hasForeignKeySelectorChanges(initialState, {
...BASE_FOREIGN_KEY,
columns: [
{
source: 'author_id',
sourceType: 'uuid',
target: 'id',
targetType: 'uuid',
},
],
})
).toBe(false)
})
})
@@ -1,10 +1,23 @@
import { FOREIGN_KEY_CASCADE_ACTION } from 'data/database/database-query-constants'
import { FOREIGN_KEY_CASCADE_ACTION } from '@supabase/pg-meta'
import type { ForeignKeyConstraint } from 'data/database/foreign-key-constraints-query'
import { isEqual } from 'lodash'
import { HelpCircle } from 'lucide-react'
import { Tooltip, TooltipContent, TooltipTrigger } from 'ui'
import { getForeignKeyCascadeAction } from '../ColumnEditor/ColumnEditor.utils'
import type { ForeignKey } from './ForeignKeySelector.types'
export interface ForeignKeyDirtyState {
id?: number | string
name?: string
tableId?: number
schema: string
table: string
columns: { source: string; target: string }[]
deletionAction: string
updateAction: string
}
export const formatForeignKeys = (fks: ForeignKeyConstraint[]): ForeignKey[] => {
return fks.map((x) => {
return {
@@ -20,6 +33,28 @@ export const formatForeignKeys = (fks: ForeignKeyConstraint[]): ForeignKey[] =>
})
}
export const normalizeForeignKeyForDirtyCheck = (foreignKey: ForeignKey): ForeignKeyDirtyState => {
return {
id: foreignKey.id,
name: foreignKey.name,
tableId: foreignKey.tableId,
schema: foreignKey.schema,
table: foreignKey.table,
columns: foreignKey.columns.map(({ source, target }) => ({ source, target })),
deletionAction: foreignKey.deletionAction,
updateAction: foreignKey.updateAction,
}
}
export const hasForeignKeySelectorChanges = (
initialState: ForeignKeyDirtyState | undefined,
foreignKey: ForeignKey
) => {
if (initialState === undefined) return false
return !isEqual(initialState, normalizeForeignKeyForDirtyCheck(foreignKey))
}
export const generateCascadeActionDescription = (
action: 'update' | 'delete',
cascadeAction: string,
@@ -1,8 +1,9 @@
import { FOREIGN_KEY_CASCADE_ACTION } from '@supabase/pg-meta'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { FOREIGN_KEY_CASCADE_ACTION } from 'data/database/database-query-constants'
import type { ForeignKey } from './ForeignKeySelector/ForeignKeySelector.types'
import type { ColumnField } from './SidePanelEditor.types'
import { createTable } from './SidePanelEditor.utils'
// Define mock functions at module level
const mockExecuteSql = vi.fn()
@@ -51,9 +52,6 @@ vi.mock('components/ui/SparkBar', () => ({
default: () => null,
}))
// Import after mocks are set up
import { createTable } from './SidePanelEditor.utils'
// Helper to create a column field with defaults
const createColumnField = (overrides: Partial<ColumnField> = {}): ColumnField => ({
id: 'col-1',
@@ -306,7 +304,7 @@ describe('createTable', () => {
const sqlCall = mockExecuteSql.mock.calls[0][0]
expect(sqlCall.sql).toContain('ADD FOREIGN KEY')
expect(sqlCall.sql).toContain('REFERENCES')
expect(sqlCall.sql).toContain('"users"')
expect(sqlCall.sql).toContain('users')
expect(sqlCall.sql).toContain('ON DELETE CASCADE')
})
@@ -1,5 +1,16 @@
import * as Sentry from '@sentry/nextjs'
import pgMeta from '@supabase/pg-meta'
import pgMeta, {
getAddForeignKeySQL,
getAddPrimaryKeySQL,
getDropConstraintSQL,
getDuplicateIdentitySequenceSQL,
getDuplicateRowsSQL,
getDuplicateTableSQL,
getEnableRLSSQL,
getRemoveForeignKeySQL,
getUpdateIdentitySequenceSQL,
type ForeignKey,
} from '@supabase/pg-meta'
import { Query } from '@supabase/pg-meta/src/query'
import type { PostgresPrimaryKey } from '@supabase/postgres-meta'
import type { SupaRow } from 'components/grid/types'
@@ -10,7 +21,6 @@ import { deleteDatabaseColumn } from 'data/database-columns/database-column-dele
import { updateDatabaseColumn } from 'data/database-columns/database-column-update-mutation'
import { createDatabasePolicy } from 'data/database-policies/database-policy-create-mutation'
import type { Constraint } from 'data/database/constraints-query'
import { FOREIGN_KEY_CASCADE_ACTION } from 'data/database/database-query-constants'
import { ForeignKeyConstraint } from 'data/database/foreign-key-constraints-query'
import { databaseKeys } from 'data/database/keys'
import { entityTypeKeys } from 'data/entity-types/keys'
@@ -24,10 +34,10 @@ import { tableRowKeys } from 'data/table-rows/keys'
import { executeWithRetry } from 'data/table-rows/table-rows-query'
import { tableKeys } from 'data/tables/keys'
import {
RetrieveTableResult,
RetrievedTableColumn,
getTable,
getTableQuery,
RetrievedTableColumn,
RetrieveTableResult,
} from 'data/tables/table-retrieve-query'
import {
UpdateTableBody,
@@ -45,7 +55,6 @@ import {
generateCreateColumnPayload,
generateUpdateColumnPayload,
} from './ColumnEditor/ColumnEditor.utils'
import type { ForeignKey } from './ForeignKeySelector/ForeignKeySelector.types'
import type { ColumnField, CreateColumnPayload, UpdateColumnPayload } from './SidePanelEditor.types'
import { checkIfRelationChanged } from './TableEditor/ForeignKeysManagement/ForeignKeysManagement.utils'
import type { ImportContent } from './TableEditor/TableEditor.types'
@@ -81,23 +90,6 @@ export function getRowFromSidePanel(
}
}
/**
* The functions below are basically just queries but may be supported directly
* from the pg-meta library in the future
*/
const getAddPrimaryKeySQL = ({
schema,
table,
columns,
}: {
schema: string
table: string
columns: string[]
}) => {
const primaryKeyColumns = columns.map((col) => `"${col}"`).join(', ')
return `ALTER TABLE "${schema}"."${table}" ADD PRIMARY KEY (${primaryKeyColumns})`
}
const addPrimaryKey = async (
projectRef: string,
connectionString: string | undefined | null,
@@ -121,7 +113,7 @@ const dropConstraint = async (
table: string,
name: string
) => {
const query = `ALTER TABLE "${schema}"."${table}" DROP CONSTRAINT "${name}"`
const query = getDropConstraintSQL({ schema, table, name })
return await executeSql({
projectRef: projectRef,
connectionString: connectionString,
@@ -130,49 +122,6 @@ const dropConstraint = async (
})
}
const getAddForeignKeySQL = ({
table,
foreignKeys,
}: {
table: { schema: string; name: string }
foreignKeys: ForeignKey[]
}) => {
const getOnDeleteSql = (action: string) =>
action === FOREIGN_KEY_CASCADE_ACTION.CASCADE
? 'ON DELETE CASCADE'
: action === FOREIGN_KEY_CASCADE_ACTION.RESTRICT
? 'ON DELETE RESTRICT'
: action === FOREIGN_KEY_CASCADE_ACTION.SET_DEFAULT
? 'ON DELETE SET DEFAULT'
: action === FOREIGN_KEY_CASCADE_ACTION.SET_NULL
? 'ON DELETE SET NULL'
: ''
const getOnUpdateSql = (action: string) =>
action === FOREIGN_KEY_CASCADE_ACTION.CASCADE
? 'ON UPDATE CASCADE'
: action === FOREIGN_KEY_CASCADE_ACTION.RESTRICT
? 'ON UPDATE RESTRICT'
: ''
return (
foreignKeys
.map((relation) => {
const { deletionAction, updateAction } = relation
const onDeleteSql = getOnDeleteSql(deletionAction)
const onUpdateSql = getOnUpdateSql(updateAction)
return `
ALTER TABLE "${table.schema}"."${table.name}"
ADD FOREIGN KEY (${relation.columns.map((column) => `"${column.source}"`).join(',')})
REFERENCES "${relation.schema}"."${relation.table}" (${relation.columns.map((column) => `"${column.target}"`).join(',')})
${onUpdateSql}
${onDeleteSql}
`
.replace(/\s+/g, ' ')
.trim()
})
.join(';') + ';'
)
}
const addForeignKey = async ({
projectRef,
connectionString,
@@ -193,27 +142,6 @@ const addForeignKey = async ({
})
}
const getRemoveForeignKeySQL = ({
table,
foreignKeys,
}: {
table: { schema: string; name: string }
foreignKeys: ForeignKey[]
}) => {
return (
foreignKeys
.map((relation) =>
`
ALTER TABLE IF EXISTS "${table.schema}"."${table.name}"
DROP CONSTRAINT IF EXISTS "${relation.name}"
`
.replace(/\s+/g, ' ')
.trim()
)
.join(';') + ';'
)
}
const removeForeignKey = async ({
projectRef,
connectionString,
@@ -259,22 +187,6 @@ const updateForeignKey = async ({
})
}
const getUpdateIdentitySequenceSQL = ({
schema,
table,
column,
}: {
schema: string
table: string
column: string
}) => {
return `SELECT setval('"${schema}"."${table}_${column}_seq"', (SELECT COALESCE(MAX("${column}"), 1) FROM "${schema}"."${table}"))`
}
const getEnableRLSSQL = ({ schema, table }: { schema: string; table: string }) => {
return `ALTER TABLE "${schema}"."${table}" ENABLE ROW LEVEL SECURITY`
}
/**
* The methods below involve several contexts due to the UI flow of the
* dashboard and hence do not sit within their own stores
@@ -350,7 +262,7 @@ export const createColumn = async ({
toast.success(`Successfully created column "${formattedPayload.name}"`, { id: toastId })
}
return { error: undefined }
} catch (error: any) {
} catch (error) {
toast.error(`An error occurred while creating the column "${payload.name}"`, { id: toastId })
return { error }
}
@@ -458,12 +370,12 @@ export const duplicateTable = async (
await executeSql({
projectRef,
connectionString,
sql: [
`CREATE TABLE "${sourceTableSchema}"."${duplicatedTableName}" (LIKE "${sourceTableSchema}"."${sourceTableName}" INCLUDING ALL);`,
payload.comment != undefined
? `comment on table "${sourceTableSchema}"."${duplicatedTableName}" is '${payload.comment}';`
: '',
].join('\n'),
sql: getDuplicateTableSQL({
sourceTableName,
sourceTableSchema,
duplicatedTableName,
comment: payload.comment,
}),
})
await queryClient.invalidateQueries({ queryKey: tableKeys.list(projectRef, sourceTableSchema) })
@@ -482,7 +394,11 @@ export const duplicateTable = async (
await executeSql({
projectRef,
connectionString,
sql: `INSERT INTO "${sourceTableSchema}"."${duplicatedTableName}" SELECT * FROM "${sourceTableSchema}"."${sourceTableName}";`,
sql: getDuplicateRowsSQL({
sourceTableName,
sourceTableSchema,
duplicatedTableName,
}),
})
// Insert into does not copy over auto increment sequences, so we manually do it next if any
@@ -492,7 +408,12 @@ export const duplicateTable = async (
await executeSql({
projectRef,
connectionString,
sql: `SELECT setval('"${sourceTableSchema}"."${duplicatedTableName}_${column.name}_seq"', (SELECT MAX("${column.name}") FROM "${sourceTableSchema}"."${sourceTableName}"));`,
sql: getDuplicateIdentitySequenceSQL({
sourceTableName,
sourceTableSchema,
duplicatedTableName,
columnName: column.name,
}),
})
})
}
@@ -560,7 +481,10 @@ export const createTable = async ({
// 2. Enable RLS if configured
if (isRLSEnabled) {
const enableRLSSQL = getEnableRLSSQL({ schema: payload.schema, table: payload.name })
const enableRLSSQL = getEnableRLSSQL({
schema: payload.schema,
table: payload.name,
})
sqlStatements.push(enableRLSSQL)
}
@@ -21,6 +21,7 @@ import { Admonition } from 'ui-patterns'
import { Input } from 'ui-patterns/DataInputs/Input'
import { useProjectApiUrl } from '@/data/config/project-endpoint-query'
import { useTrack } from 'lib/telemetry/track'
import { defaultPrivilegesQueryOptions } from '@/data/privileges/default-privileges-query'
import { useTableApiAccessQuery } from '@/data/privileges/table-api-access-query'
import { useStaticEffectEvent } from '@/hooks/useStaticEffectEvent'
@@ -282,6 +283,7 @@ export const ApiAccessToggle = ({
isNewRecord,
handler,
}: ApiAccessToggleComponentProps): ReactNode => {
const track = useTrack()
const isPending = handler.isPending
const isError = handler.isError
const isSchemaExposed = handler.data?.schemaExposed
@@ -295,6 +297,13 @@ export const ApiAccessToggle = ({
if (!handler.isSuccess) return
if (!isSchemaExposed) return
if (isNewRecord) {
track('table_api_access_toggle_clicked', {
newState: checked ? 'enabled' : 'disabled',
schemaName: schemaName ?? 'unknown',
})
}
if (checked) {
handler.data?.restorePreviousPrivileges()
} else {
@@ -16,7 +16,7 @@ import { useFloatingToolbarNavSize } from './useFloatingToolbarNavSize'
import { useFloatingToolbarSheet } from './useFloatingToolbarSheet'
import { useFloatingToolbarSidebarClick } from './useFloatingToolbarSidebarClick'
const FloatingMobileToolbar = ({ hideMobileMenu }: { hideMobileMenu?: boolean }) => {
export const FloatingMobileToolbar = ({ hideMobileMenu }: { hideMobileMenu?: boolean }) => {
const navRef = useRef<HTMLElement | null>(null)
const sheet = useFloatingToolbarSheet(hideMobileMenu)
const drag = useFloatingToolbarDrag(navRef)
@@ -58,6 +58,7 @@ const FloatingMobileToolbar = ({ hideMobileMenu }: { hideMobileMenu?: boolean })
>
<AnimatePresence initial={false}>
<ButtonTooltip
key="search"
type={isSearchOpen ? 'secondary' : 'outline'}
size="tiny"
id="search-trigger"
@@ -80,24 +81,25 @@ const FloatingMobileToolbar = ({ hideMobileMenu }: { hideMobileMenu?: boolean })
>
<Search size={16} strokeWidth={1} />
</ButtonTooltip>
<span data-sidebar-id={SIDEBAR_KEYS.HELP_PANEL}>
<span key="help" data-sidebar-id={SIDEBAR_KEYS.HELP_PANEL}>
<HelpButton />
</span>
<span data-sidebar-id={SIDEBAR_KEYS.ADVISOR_PANEL}>
<span key="advisor" data-sidebar-id={SIDEBAR_KEYS.ADVISOR_PANEL}>
<AdvisorButton projectRef={projectRef} />
</span>
{!!projectRef && (
<>
<span data-sidebar-id={SIDEBAR_KEYS.EDITOR_PANEL}>
<span key="editor" data-sidebar-id={SIDEBAR_KEYS.EDITOR_PANEL}>
<InlineEditorButton />
</span>
<span data-sidebar-id={SIDEBAR_KEYS.AI_ASSISTANT}>
<span key="assistant" data-sidebar-id={SIDEBAR_KEYS.AI_ASSISTANT}>
<AssistantButton />
</span>
</>
)}
{sheet.showMenuButton && sheet.isSheetOpen && (
<Button
key="menu"
title="Menu dropdown button"
type={sheet.isMenuOpen ? 'secondary' : 'default'}
className={cn(
@@ -129,5 +131,3 @@ const FloatingMobileToolbar = ({ hideMobileMenu }: { hideMobileMenu?: boolean })
</nav>
)
}
export default FloatingMobileToolbar
@@ -4,7 +4,7 @@ import { ConnectButton } from 'components/interfaces/ConnectButton/ConnectButton
import { LocalDropdown } from 'components/interfaces/LocalDropdown'
import { SidebarContent } from 'components/interfaces/Sidebar'
import { UserDropdown } from 'components/interfaces/UserDropdown'
import FloatingMobileToolbar from 'components/layouts/Navigation/FloatingMobileToolbar/FloatingMobileToolbar'
import { FloatingMobileToolbar } from 'components/layouts/Navigation/FloatingMobileToolbar/FloatingMobileToolbar'
import { useOrganizationsQuery } from 'data/organizations/organizations-query'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { IS_PLATFORM } from 'lib/constants'
@@ -0,0 +1,208 @@
import type { Project } from 'data/projects/project-detail-query'
import { describe, expect, it } from 'vitest'
import {
generateOtherRoutes,
generateProductRoutes,
generateSettingsRoutes,
generateToolRoutes,
} from './NavigationBar.utils'
const REF = 'test-project-ref'
const activeProject = { status: 'ACTIVE_HEALTHY' } as Project
const buildingProject = { status: 'COMING_UP' } as Project
const inactiveProject = { status: 'INACTIVE' } as Project
const keys = (routes: { key: string }[]) => routes.map((r) => r.key)
describe('generateToolRoutes', () => {
it('always returns Table Editor and SQL Editor', () => {
const routes = generateToolRoutes(REF, activeProject)
expect(keys(routes)).toEqual(['editor', 'sql'])
})
it('marks routes as disabled when project is not active', () => {
const routes = generateToolRoutes(REF, inactiveProject)
expect(routes.every((r) => r.disabled)).toBe(true)
})
it('points links to the building URL when project is building', () => {
const routes = generateToolRoutes(REF, buildingProject)
expect(routes.every((r) => r.link === `/project/${REF}`)).toBe(true)
})
it('returns links as false when ref is undefined', () => {
const routes = generateToolRoutes(undefined, activeProject)
expect(routes.every((r) => r.link === undefined)).toBe(true)
})
})
describe('generateProductRoutes', () => {
it('includes all product routes when all features are enabled', () => {
const routes = generateProductRoutes(REF, activeProject, {
auth: true,
storage: true,
edgeFunctions: true,
realtime: true,
})
expect(keys(routes)).toEqual(['database', 'auth', 'storage', 'functions', 'realtime'])
})
it('includes all product routes by default (features default to true)', () => {
const routes = generateProductRoutes(REF, activeProject)
expect(keys(routes)).toEqual(['database', 'auth', 'storage', 'functions', 'realtime'])
})
it('excludes auth when auth feature is disabled', () => {
const routes = generateProductRoutes(REF, activeProject, { auth: false })
expect(keys(routes)).not.toContain('auth')
expect(keys(routes)).toContain('database')
expect(keys(routes)).toContain('storage')
})
it('excludes storage when storage feature is disabled', () => {
const routes = generateProductRoutes(REF, activeProject, { storage: false })
expect(keys(routes)).not.toContain('storage')
})
it('excludes edge functions when edgeFunctions feature is disabled', () => {
const routes = generateProductRoutes(REF, activeProject, { edgeFunctions: false })
expect(keys(routes)).not.toContain('functions')
})
it('excludes realtime when realtime feature is disabled', () => {
const routes = generateProductRoutes(REF, activeProject, { realtime: false })
expect(keys(routes)).not.toContain('realtime')
})
it('links auth to overview page when authOverviewPage is enabled', () => {
const routes = generateProductRoutes(REF, activeProject, { authOverviewPage: true })
const authRoute = routes.find((r) => r.key === 'auth')
expect(authRoute?.link).toBe(`/project/${REF}/auth/overview`)
})
it('links auth to users page by default', () => {
const routes = generateProductRoutes(REF, activeProject)
const authRoute = routes.find((r) => r.key === 'auth')
expect(authRoute?.link).toBe(`/project/${REF}/auth/users`)
})
it('always includes database even when all optional features are disabled', () => {
const routes = generateProductRoutes(REF, activeProject, {
auth: false,
storage: false,
edgeFunctions: false,
realtime: false,
})
expect(keys(routes)).toEqual(['database'])
})
})
describe('generateOtherRoutes', () => {
it('always includes advisors, logs, and integrations', () => {
const routes = generateOtherRoutes(REF, activeProject, { isPlatform: true })
expect(keys(routes)).toContain('advisors')
expect(keys(routes)).toContain('logs')
expect(keys(routes)).toContain('integrations')
})
it('includes observability on platform when reports are enabled', () => {
const routes = generateOtherRoutes(REF, activeProject, {
isPlatform: true,
showReports: true,
})
expect(keys(routes)).toContain('observability')
})
it('excludes observability on platform when reports are disabled', () => {
const routes = generateOtherRoutes(REF, activeProject, {
isPlatform: true,
showReports: false,
})
expect(keys(routes)).not.toContain('observability')
})
it('excludes observability in self-hosted mode even when reports are enabled', () => {
const routes = generateOtherRoutes(REF, activeProject, {
isPlatform: false,
showReports: true,
})
expect(keys(routes)).not.toContain('observability')
})
it('excludes observability in self-hosted mode when reports are disabled', () => {
const routes = generateOtherRoutes(REF, activeProject, {
isPlatform: false,
showReports: false,
})
expect(keys(routes)).not.toContain('observability')
})
it('includes API Docs when apiDocsSidePanel is enabled', () => {
const routes = generateOtherRoutes(REF, activeProject, {
isPlatform: true,
apiDocsSidePanel: true,
})
expect(keys(routes)).toContain('api')
})
it('excludes API Docs when apiDocsSidePanel is disabled', () => {
const routes = generateOtherRoutes(REF, activeProject, {
isPlatform: true,
apiDocsSidePanel: false,
})
expect(keys(routes)).not.toContain('api')
})
it('links logs to unified logs page when unifiedLogs is enabled', () => {
const routes = generateOtherRoutes(REF, activeProject, {
isPlatform: true,
unifiedLogs: true,
})
const logsRoute = routes.find((r) => r.key === 'logs')
expect(logsRoute?.link).toBe(`/project/${REF}/logs`)
})
it('links logs to explorer page by default', () => {
const routes = generateOtherRoutes(REF, activeProject, { isPlatform: true })
const logsRoute = routes.find((r) => r.key === 'logs')
expect(logsRoute?.link).toBe(`/project/${REF}/logs/explorer`)
})
it('points links to building URL when project is building', () => {
const routes = generateOtherRoutes(REF, buildingProject, {
isPlatform: true,
showReports: true,
})
const observabilityRoute = routes.find((r) => r.key === 'observability')
expect(observabilityRoute?.link).toBe(`/project/${REF}`)
})
it('marks routes as disabled when project is not active', () => {
const routes = generateOtherRoutes(REF, inactiveProject, { isPlatform: true })
const advisorsRoute = routes.find((r) => r.key === 'advisors')
expect(advisorsRoute?.disabled).toBe(true)
})
})
describe('generateSettingsRoutes', () => {
it('links to general settings on platform', () => {
const routes = generateSettingsRoutes(REF, { isPlatform: true })
const settingsRoute = routes.find((r) => r.key === 'settings')
expect(settingsRoute?.link).toBe(`/project/${REF}/settings/general`)
})
it('links to log-drains settings in self-hosted mode', () => {
const routes = generateSettingsRoutes(REF, { isPlatform: false })
const settingsRoute = routes.find((r) => r.key === 'settings')
expect(settingsRoute?.link).toBe(`/project/${REF}/settings/log-drains`)
})
it('returns a link as false when ref is undefined', () => {
const routes = generateSettingsRoutes(undefined, { isPlatform: true })
const settingsRoute = routes.find((r) => r.key === 'settings')
expect(settingsRoute?.link).toBe(undefined)
})
})
@@ -1,5 +1,4 @@
import { ICON_SIZE, ICON_STROKE_WIDTH } from 'components/interfaces/Sidebar'
import { useGenerateSettingsMenu } from 'components/layouts/ProjectSettingsLayout/SettingsMenu.utils'
import type { Route } from 'components/ui/ui.types'
import { EditorIndexPageLink } from 'data/prefetchers/project.$ref.editor'
import type { Project } from 'data/projects/project-detail-query'
@@ -7,10 +6,43 @@ import { Auth, Database, EdgeFunctions, Realtime, SqlEditor, Storage, TableEdito
import { IS_PLATFORM, PROJECT_STATUS } from 'lib/constants'
import { Blocks, FileText, Lightbulb, List, Settings, Telescope } from 'lucide-react'
export const generateToolRoutes = (ref?: string, project?: Project, features?: {}): Route[] => {
const isProjectActive = project?.status === PROJECT_STATUS.ACTIVE_HEALTHY
const isProjectBuilding = project?.status === PROJECT_STATUS.COMING_UP
const buildingUrl = `/project/${ref}`
interface RouteContext {
ref?: string
isProjectActive: boolean
isProjectBuilding: boolean
buildingUrl: string
}
interface ProductFeatures {
auth?: boolean
edgeFunctions?: boolean
storage?: boolean
realtime?: boolean
authOverviewPage?: boolean
}
interface OtherFeatures {
isPlatform?: boolean
unifiedLogs?: boolean
showReports?: boolean
apiDocsSidePanel?: boolean
}
interface SettingsFeatures {
isPlatform?: boolean
}
function getRouteContext(ref?: string, project?: Project): RouteContext {
return {
ref,
isProjectActive: project?.status === PROJECT_STATUS.ACTIVE_HEALTHY,
isProjectBuilding: project?.status === PROJECT_STATUS.COMING_UP,
buildingUrl: `/project/${ref}`,
}
}
export const generateToolRoutes = (ref?: string, project?: Project): Route[] => {
const { isProjectActive, isProjectBuilding, buildingUrl } = getRouteContext(ref, project)
return [
{
@@ -34,17 +66,9 @@ export const generateToolRoutes = (ref?: string, project?: Project, features?: {
export const generateProductRoutes = (
ref?: string,
project?: Project,
features?: {
auth?: boolean
edgeFunctions?: boolean
storage?: boolean
realtime?: boolean
authOverviewPage?: boolean
}
features?: ProductFeatures
): Route[] => {
const isProjectActive = project?.status === PROJECT_STATUS.ACTIVE_HEALTHY
const isProjectBuilding = project?.status === PROJECT_STATUS.COMING_UP
const buildingUrl = `/project/${ref}`
const { isProjectActive, isProjectBuilding, buildingUrl } = getRouteContext(ref, project)
const authEnabled = features?.auth ?? true
const edgeFunctionsEnabled = features?.edgeFunctions ?? true
@@ -122,16 +146,14 @@ export const generateProductRoutes = (
export const generateOtherRoutes = (
ref?: string,
project?: Project,
features?: { unifiedLogs?: boolean; showReports?: boolean; apiDocsSidePanel?: boolean }
features?: OtherFeatures
): Route[] => {
const isProjectActive = project?.status === PROJECT_STATUS.ACTIVE_HEALTHY
const isProjectBuilding = project?.status === PROJECT_STATUS.COMING_UP
const buildingUrl = `/project/${ref}`
const { isProjectActive, isProjectBuilding, buildingUrl } = getRouteContext(ref, project)
const { unifiedLogs, showReports, apiDocsSidePanel } = features ?? {}
const unifiedLogsEnabled = unifiedLogs ?? false
const reportsEnabled = showReports ?? true
const apiDocsSidePanelEnabled = apiDocsSidePanel ?? false
const isPlatform = features?.isPlatform ?? IS_PLATFORM
const unifiedLogsEnabled = features?.unifiedLogs ?? false
const reportsEnabled = features?.showReports ?? true
const apiDocsSidePanelEnabled = features?.apiDocsSidePanel ?? false
return [
{
@@ -142,18 +164,16 @@ export const generateOtherRoutes = (
link: ref && (isProjectBuilding ? buildingUrl : `/project/${ref}/advisors/security`),
},
// Observability is only available on the platform, not for self-hosted/CLI
...(IS_PLATFORM
? reportsEnabled
? [
{
key: 'observability',
label: 'Observability',
disabled: !isProjectActive,
icon: <Telescope size={ICON_SIZE} strokeWidth={ICON_STROKE_WIDTH} />,
link: ref && (isProjectBuilding ? buildingUrl : `/project/${ref}/observability`),
},
]
: []
...(isPlatform && reportsEnabled
? [
{
key: 'observability',
label: 'Observability',
disabled: !isProjectActive,
icon: <Telescope size={ICON_SIZE} strokeWidth={ICON_STROKE_WIDTH} />,
link: ref && (isProjectBuilding ? buildingUrl : `/project/${ref}/observability`),
},
]
: []),
{
key: 'logs',
@@ -185,7 +205,9 @@ export const generateOtherRoutes = (
]
}
export const generateSettingsRoutes = (ref?: string): Route[] => {
export const generateSettingsRoutes = (ref?: string, features?: SettingsFeatures): Route[] => {
const isPlatform = features?.isPlatform ?? IS_PLATFORM
return [
{
key: 'settings',
@@ -193,7 +215,7 @@ export const generateSettingsRoutes = (ref?: string): Route[] => {
icon: <Settings size={ICON_SIZE} strokeWidth={ICON_STROKE_WIDTH} />,
link:
ref &&
(IS_PLATFORM ? `/project/${ref}/settings/general` : `/project/${ref}/settings/log-drains`),
(isPlatform ? `/project/${ref}/settings/general` : `/project/${ref}/settings/log-drains`),
disabled: false,
},
]
@@ -20,9 +20,10 @@ import { cn, Menu } from 'ui'
import { InnerSideBarEmptyPanel } from 'ui-patterns'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
import { useSupamonitorStatus } from '@/components/interfaces/QueryPerformance/hooks/useSupamonitorStatus'
import { generateObservabilityMenuItems } from './ObservabilityMenu.utils'
import { ObservabilityMenuItem } from './ObservabilityMenuItem'
import { useSupamonitorStatus } from '@/components/interfaces/QueryPerformance/hooks/useSupamonitorStatus'
const ObservabilityMenu = () => {
const router = useRouter()
@@ -124,87 +125,14 @@ const ObservabilityMenu = () => {
const reportMenuItems = getReportMenuItems()
const menuItems = [
{
title: 'GENERAL',
key: 'general-section',
items: [
...(showOverview
? [
{
name: 'Overview',
key: 'observability',
url: `/project/${ref}/observability${preservedQueryParams}`,
},
]
: []),
...(isSupamonitorEnabled
? [
{
name: 'Query Insights',
key: 'query-insights',
url: `/project/${ref}/observability/query-insights${preservedQueryParams}`,
},
]
: [
{
name: 'Query Performance',
key: 'query-performance',
url: `/project/${ref}/observability/query-performance${preservedQueryParams}`,
},
]),
...(IS_PLATFORM
? [
{
name: 'API Gateway',
key: 'api-overview',
url: `/project/${ref}/observability/api-overview${preservedQueryParams}`,
},
]
: []),
],
},
{
title: 'PRODUCT',
key: 'product-section',
items: [
{
name: 'Database',
key: 'database',
url: `/project/${ref}/observability/database${preservedQueryParams}`,
},
{
name: 'Data API',
key: 'postgrest',
url: `/project/${ref}/observability/postgrest${preservedQueryParams}`,
},
{
name: 'Auth',
key: 'auth',
url: `/project/${ref}/observability/auth${preservedQueryParams}`,
},
{
name: 'Edge Functions',
key: 'edge-functions',
url: `/project/${ref}/observability/edge-functions${preservedQueryParams}`,
},
...(storageSupported
? [
{
name: 'Storage',
key: 'storage',
url: `/project/${ref}/observability/storage${preservedQueryParams}`,
},
]
: []),
{
name: 'Realtime',
key: 'realtime',
url: `/project/${ref}/observability/realtime${preservedQueryParams}`,
},
],
},
]
const menuItems = generateObservabilityMenuItems({
ref,
preservedQueryParams,
showOverview,
isSupamonitorEnabled,
storageSupported,
isPlatform: IS_PLATFORM,
})
return (
<Menu type="pills" className="mt-6">
@@ -0,0 +1,280 @@
import { describe, expect, it } from 'vitest'
import {
generateObservabilityMenuItems,
type ObservabilityMenuSection,
} from './ObservabilityMenu.utils'
const REF = 'test-project-ref'
const QUERY_PARAMS = ''
const sectionTitles = (sections: ObservabilityMenuSection[]) => sections.map((s) => s.title)
const itemKeys = (section: ObservabilityMenuSection | undefined) =>
section?.items.map((i) => i.key) ?? []
const findSection = (sections: ObservabilityMenuSection[], title: string) =>
sections.find((s) => s.title === title)
describe('generateObservabilityMenuItems - PRODUCT section', () => {
it('includes PRODUCT section on platform', () => {
const menu = generateObservabilityMenuItems({
ref: REF,
preservedQueryParams: QUERY_PARAMS,
showOverview: false,
isSupamonitorEnabled: false,
storageSupported: true,
isPlatform: true,
})
expect(sectionTitles(menu)).toContain('PRODUCT')
const productSection = findSection(menu, 'PRODUCT')
expect(itemKeys(productSection)).toEqual([
'database',
'postgrest',
'auth',
'edge-functions',
'storage',
'realtime',
])
})
it('excludes PRODUCT section in self-hosted mode', () => {
const menu = generateObservabilityMenuItems({
ref: REF,
preservedQueryParams: QUERY_PARAMS,
showOverview: false,
isSupamonitorEnabled: false,
storageSupported: true,
isPlatform: false,
})
expect(sectionTitles(menu)).not.toContain('PRODUCT')
expect(menu.length).toBe(1) // Only GENERAL section
})
it('excludes Storage from PRODUCT when storageSupported is false', () => {
const menu = generateObservabilityMenuItems({
ref: REF,
preservedQueryParams: QUERY_PARAMS,
showOverview: false,
isSupamonitorEnabled: false,
storageSupported: false,
isPlatform: true,
})
const productSection = findSection(menu, 'PRODUCT')
expect(itemKeys(productSection)).not.toContain('storage')
expect(itemKeys(productSection)).toEqual([
'database',
'postgrest',
'auth',
'edge-functions',
'realtime',
])
})
it('includes Storage in PRODUCT when storageSupported is true', () => {
const menu = generateObservabilityMenuItems({
ref: REF,
preservedQueryParams: QUERY_PARAMS,
showOverview: false,
isSupamonitorEnabled: false,
storageSupported: true,
isPlatform: true,
})
const productSection = findSection(menu, 'PRODUCT')
expect(itemKeys(productSection)).toContain('storage')
})
})
describe('generateObservabilityMenuItems - GENERAL section', () => {
it('always includes GENERAL section', () => {
const menu = generateObservabilityMenuItems({
ref: REF,
preservedQueryParams: QUERY_PARAMS,
showOverview: false,
isSupamonitorEnabled: false,
storageSupported: true,
isPlatform: false,
})
expect(sectionTitles(menu)).toContain('GENERAL')
})
it('includes Query Performance when supamonitor is disabled', () => {
const menu = generateObservabilityMenuItems({
ref: REF,
preservedQueryParams: QUERY_PARAMS,
showOverview: false,
isSupamonitorEnabled: false,
storageSupported: true,
isPlatform: false,
})
const generalSection = findSection(menu, 'GENERAL')
expect(itemKeys(generalSection)).toContain('query-performance')
expect(itemKeys(generalSection)).not.toContain('query-insights')
})
it('includes Query Insights when supamonitor is enabled', () => {
const menu = generateObservabilityMenuItems({
ref: REF,
preservedQueryParams: QUERY_PARAMS,
showOverview: false,
isSupamonitorEnabled: true,
storageSupported: true,
isPlatform: false,
})
const generalSection = findSection(menu, 'GENERAL')
expect(itemKeys(generalSection)).toContain('query-insights')
expect(itemKeys(generalSection)).not.toContain('query-performance')
})
it('includes Overview when showOverview is true', () => {
const menu = generateObservabilityMenuItems({
ref: REF,
preservedQueryParams: QUERY_PARAMS,
showOverview: true,
isSupamonitorEnabled: false,
storageSupported: true,
isPlatform: false,
})
const generalSection = findSection(menu, 'GENERAL')
expect(itemKeys(generalSection)).toContain('observability')
})
it('excludes Overview when showOverview is false', () => {
const menu = generateObservabilityMenuItems({
ref: REF,
preservedQueryParams: QUERY_PARAMS,
showOverview: false,
isSupamonitorEnabled: false,
storageSupported: true,
isPlatform: false,
})
const generalSection = findSection(menu, 'GENERAL')
expect(itemKeys(generalSection)).not.toContain('observability')
})
it('includes API Gateway on platform', () => {
const menu = generateObservabilityMenuItems({
ref: REF,
preservedQueryParams: QUERY_PARAMS,
showOverview: false,
isSupamonitorEnabled: false,
storageSupported: true,
isPlatform: true,
})
const generalSection = findSection(menu, 'GENERAL')
expect(itemKeys(generalSection)).toContain('api-overview')
})
it('excludes API Gateway in self-hosted mode', () => {
const menu = generateObservabilityMenuItems({
ref: REF,
preservedQueryParams: QUERY_PARAMS,
showOverview: false,
isSupamonitorEnabled: false,
storageSupported: true,
isPlatform: false,
})
const generalSection = findSection(menu, 'GENERAL')
expect(itemKeys(generalSection)).not.toContain('api-overview')
})
})
describe('generateObservabilityMenuItems - URL construction', () => {
it('constructs correct URLs with preserved query params', () => {
const params = '?its=2024-01-01&ite=2024-01-31'
const menu = generateObservabilityMenuItems({
ref: REF,
preservedQueryParams: params,
showOverview: false,
isSupamonitorEnabled: false,
storageSupported: true,
isPlatform: true,
})
const generalSection = findSection(menu, 'GENERAL')
const queryPerfItem = generalSection?.items.find((i) => i.key === 'query-performance')
expect(queryPerfItem?.url).toBe(`/project/${REF}/observability/query-performance${params}`)
const productSection = findSection(menu, 'PRODUCT')
const databaseItem = productSection?.items.find((i) => i.key === 'database')
expect(databaseItem?.url).toBe(`/project/${REF}/observability/database${params}`)
})
it('handles undefined ref', () => {
const menu = generateObservabilityMenuItems({
ref: undefined,
preservedQueryParams: QUERY_PARAMS,
showOverview: false,
isSupamonitorEnabled: false,
storageSupported: true,
isPlatform: true,
})
const generalSection = findSection(menu, 'GENERAL')
const queryPerfItem = generalSection?.items.find((i) => i.key === 'query-performance')
expect(queryPerfItem?.url).toBe('/project/undefined/observability/query-performance')
})
})
describe('generateObservabilityMenuItems - complete structure', () => {
it('returns only GENERAL in self-hosted with all standard items', () => {
const menu = generateObservabilityMenuItems({
ref: REF,
preservedQueryParams: QUERY_PARAMS,
showOverview: true,
isSupamonitorEnabled: false,
storageSupported: true,
isPlatform: false,
})
expect(menu.length).toBe(1)
expect(menu[0].title).toBe('GENERAL')
expect(itemKeys(menu[0])).toEqual([
'observability', // Overview
'query-performance',
// API Gateway excluded
])
})
it('returns GENERAL and PRODUCT on platform with all items', () => {
const menu = generateObservabilityMenuItems({
ref: REF,
preservedQueryParams: QUERY_PARAMS,
showOverview: true,
isSupamonitorEnabled: true,
storageSupported: true,
isPlatform: true,
})
expect(menu.length).toBe(2)
expect(sectionTitles(menu)).toEqual(['GENERAL', 'PRODUCT'])
const generalSection = findSection(menu, 'GENERAL')
expect(itemKeys(generalSection)).toEqual([
'observability', // Overview
'query-insights', // Supamonitor enabled
'api-overview', // Platform only
])
const productSection = findSection(menu, 'PRODUCT')
expect(itemKeys(productSection)).toEqual([
'database',
'postgrest',
'auth',
'edge-functions',
'storage',
'realtime',
])
})
})
@@ -0,0 +1,126 @@
import { IS_PLATFORM } from 'lib/constants'
interface ObservabilityMenuItem {
name: string
key: string
url: string
}
export interface ObservabilityMenuSection {
title: string
key: string
items: ObservabilityMenuItem[]
}
interface GenerateObservabilityMenuOptions {
ref: string | undefined
preservedQueryParams: string
showOverview: boolean
isSupamonitorEnabled: boolean
storageSupported: boolean
isPlatform?: boolean
}
export function generateObservabilityMenuItems(
options: GenerateObservabilityMenuOptions
): ObservabilityMenuSection[] {
const {
ref,
preservedQueryParams,
showOverview,
isSupamonitorEnabled,
storageSupported,
isPlatform = IS_PLATFORM,
} = options
const generalItems: ObservabilityMenuItem[] = [
...(showOverview
? [
{
name: 'Overview',
key: 'observability',
url: `/project/${ref}/observability${preservedQueryParams}`,
},
]
: []),
...(isSupamonitorEnabled
? [
{
name: 'Query Insights',
key: 'query-insights',
url: `/project/${ref}/observability/query-insights${preservedQueryParams}`,
},
]
: [
{
name: 'Query Performance',
key: 'query-performance',
url: `/project/${ref}/observability/query-performance${preservedQueryParams}`,
},
]),
...(isPlatform
? [
{
name: 'API Gateway',
key: 'api-overview',
url: `/project/${ref}/observability/api-overview${preservedQueryParams}`,
},
]
: []),
]
const productItems: ObservabilityMenuItem[] = [
{
name: 'Database',
key: 'database',
url: `/project/${ref}/observability/database${preservedQueryParams}`,
},
{
name: 'Data API',
key: 'postgrest',
url: `/project/${ref}/observability/postgrest${preservedQueryParams}`,
},
{
name: 'Auth',
key: 'auth',
url: `/project/${ref}/observability/auth${preservedQueryParams}`,
},
{
name: 'Edge Functions',
key: 'edge-functions',
url: `/project/${ref}/observability/edge-functions${preservedQueryParams}`,
},
...(storageSupported
? [
{
name: 'Storage',
key: 'storage',
url: `/project/${ref}/observability/storage${preservedQueryParams}`,
},
]
: []),
{
name: 'Realtime',
key: 'realtime',
url: `/project/${ref}/observability/realtime${preservedQueryParams}`,
},
]
const sections: ObservabilityMenuSection[] = [
{
title: 'GENERAL',
key: 'general-section',
items: generalItems,
},
]
if (isPlatform) {
sections.push({
title: 'PRODUCT',
key: 'product-section',
items: productItems,
})
}
return sections
}
@@ -1,6 +1,3 @@
import { CheckCircle, Download, Loader } from 'lucide-react'
import { useEffect, useState } from 'react'
import { SupportCategories } from '@supabase/shared-types/out/constants'
import { useParams } from 'common'
import { SupportLink } from 'components/interfaces/Support/SupportLink'
@@ -9,11 +6,24 @@ import { useBackupDownloadMutation } from 'data/database/backup-download-mutatio
import { useDownloadableBackupQuery } from 'data/database/backup-query'
import { useInvalidateProjectDetailsQuery } from 'data/projects/project-detail-query'
import { useProjectStatusQuery } from 'data/projects/project-status-query'
import dayjs from 'dayjs'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { PROJECT_STATUS } from 'lib/constants'
import { Button } from 'ui'
import { CheckCircle, Download, Loader } from 'lucide-react'
import { useEffect, useState } from 'react'
import {
Button,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogSection,
DialogSectionSeparator,
DialogTitle,
DialogTrigger,
} from 'ui'
const RestoringState = () => {
export const RestoringState = () => {
const { ref } = useParams()
const { data: project } = useSelectedProjectQuery()
@@ -37,12 +47,8 @@ const RestoringState = () => {
}
)
useEffect(() => {
if (!isProjectStatusSuccess) return
if (projectStatusData.status === PROJECT_STATUS.ACTIVE_HEALTHY) {
setIsCompleted(true)
}
}, [isProjectStatusSuccess, projectStatusData, ref, invalidateProjectDetailsQuery])
const restoreInitiatedSinceMinutes = dayjs().diff(dayjs.utc(project?.updated_at), 'minute')
const showSupportCta = restoreInitiatedSinceMinutes >= 30
const { mutate: downloadBackup, isPending: isDownloading } = useBackupDownloadMutation({
onSuccess: (res) => {
@@ -70,6 +76,12 @@ const RestoringState = () => {
if (ref) await invalidateProjectDetailsQuery(ref)
}
useEffect(() => {
if (isProjectStatusSuccess && projectStatusData.status === PROJECT_STATUS.ACTIVE_HEALTHY) {
setIsCompleted(true)
}
}, [isProjectStatusSuccess, projectStatusData])
return (
<div className="flex items-center justify-center h-full">
<div className="bg-surface-100 border border-overlay rounded-md w-3/4 lg:w-1/2">
@@ -110,17 +122,43 @@ const RestoringState = () => {
</div>
</div>
<div className="border-t border-overlay flex items-center justify-end py-4 px-8 gap-x-2">
<Button asChild type="default">
<SupportLink
queryParams={{
category: SupportCategories.DATABASE_UNRESPONSIVE,
projectRef: project?.ref,
subject: 'Ongoing restoration for project',
}}
>
Contact support
</SupportLink>
</Button>
{showSupportCta && (
<Dialog>
<DialogTrigger>
<Button type="text" className="text-foreground-light">
Taking longer than expected?
</Button>
</DialogTrigger>
<DialogContent aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>Restoration taking longer than expected?</DialogTitle>
</DialogHeader>
<DialogSectionSeparator />
<DialogSection>
<p className="text-sm">
Restores may take from a minutes up to several hours depending on the size
of your database. However, if the restoration process is taking far longer
than expected for the size of your database, you may reach out to us via
Support for help.
</p>
</DialogSection>
<DialogFooter>
<Button asChild type="default">
<SupportLink
queryParams={{
category: SupportCategories.DATABASE_UNRESPONSIVE,
projectRef: project?.ref,
subject: 'Ongoing restoration for project',
}}
>
Contact support
</SupportLink>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
<ButtonTooltip
type="default"
icon={<Download />}
@@ -144,5 +182,3 @@ const RestoringState = () => {
</div>
)
}
export default RestoringState
@@ -36,7 +36,7 @@ export const UpgradingState = () => {
{
projectRef: ref,
projectStatus: project?.status,
trackingId: queryParams.get('trackingId'),
trackingId: queryParams?.get('trackingId'),
},
{
enabled: IS_PLATFORM,
@@ -34,7 +34,7 @@ import { PausingState } from './PausingState'
import { ResizingState } from './ResizingState'
import RestartingState from './RestartingState'
import { RestoreFailedState } from './RestoreFailedState'
import RestoringState from './RestoringState'
import { RestoringState } from './RestoringState'
import { UpgradingState } from './UpgradingState'
import { CreateBranchModal } from '@/components/interfaces/BranchManagement/CreateBranchModal'
import { ProjectAPIDocs } from '@/components/interfaces/ProjectAPIDocs/ProjectAPIDocs'
@@ -6,20 +6,18 @@ import { PropsWithChildren, useEffect } from 'react'
import { ProjectLayoutWithAuth } from '../ProjectLayout'
import { SaveQueueActionBar } from '@/components/grid/components/footer/operations/SaveQueueActionBar'
import { useIsTableFilterBarEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { BannerTableEditorFilter } from '@/components/ui/BannerStack/Banners/BannerTableEditorFilter'
import { useIsQueueOperationsEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { BannerTableEditorQueueOperations } from '@/components/ui/BannerStack/Banners/BannerTableEditorQueueOperations'
import { useBannerStack } from '@/components/ui/BannerStack/BannerStackProvider'
import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
const TABLE_EDITOR_NEW_FILTER_BANNER_ID = 'table-editor-new-filter-banner'
export const TableEditorLayout = ({ children }: PropsWithChildren<{}>) => {
const { ref } = useParams()
const { addBanner, dismissBanner } = useBannerStack()
const isTableFilterBarEnabled = useIsTableFilterBarEnabled()
const isTableQueueOperationsEnabled = useIsQueueOperationsEnabled()
const [isTableEditorNewFilterBannerDismissed] = useLocalStorageQuery(
LOCAL_STORAGE_KEYS.TABLE_EDITOR_NEW_FILTER_BANNER_DISMISSED(ref ?? ''),
const [isTableEditorQueueOperationsBannerDismissed] = useLocalStorageQuery(
LOCAL_STORAGE_KEYS.TABLE_EDITOR_QUEUE_OPERATIONS_BANNER_DISMISSED(ref ?? ''),
false
)
@@ -28,30 +26,39 @@ export const TableEditorLayout = ({ children }: PropsWithChildren<{}>) => {
'tables'
)
const { can: canWriteTables } = useAsyncCheckPermissions(
PermissionAction.TENANT_SQL_ADMIN_WRITE,
'tables'
)
useEffect(() => {
if (!isPermissionsLoaded) return
if (canReadTables && !isTableEditorNewFilterBannerDismissed && !isTableFilterBarEnabled) {
if (
canWriteTables &&
!isTableEditorQueueOperationsBannerDismissed &&
!isTableQueueOperationsEnabled
) {
addBanner({
id: TABLE_EDITOR_NEW_FILTER_BANNER_ID,
id: 'table-editor-queue-operations-banner',
priority: 2,
isDismissed: false,
content: <BannerTableEditorFilter />,
content: <BannerTableEditorQueueOperations />,
})
} else {
dismissBanner(TABLE_EDITOR_NEW_FILTER_BANNER_ID)
dismissBanner('table-editor-queue-operations-banner')
}
return () => {
dismissBanner(TABLE_EDITOR_NEW_FILTER_BANNER_ID)
dismissBanner('table-editor-queue-operations-banner')
}
}, [
addBanner,
dismissBanner,
canReadTables,
isPermissionsLoaded,
isTableEditorNewFilterBannerDismissed,
isTableFilterBarEnabled,
canWriteTables,
isTableEditorQueueOperationsBannerDismissed,
isTableQueueOperationsEnabled,
])
if (isPermissionsLoaded && !canReadTables) {
+3 -6
View File
@@ -8,6 +8,7 @@ import { Admonition } from 'ui-patterns/admonition'
export interface AlertErrorProps {
projectRef?: string
subject?: string
description?: string
error?: { message: string } | null
layout?: 'vertical' | 'horizontal' | 'responsive'
className?: string
@@ -46,6 +47,7 @@ export const ContactSupportButton = ({
export const AlertError = ({
projectRef,
subject,
description = 'Try refreshing your browser, but if the issue persists for more than a few minutes, please reach out to us via support.',
error,
className,
showIcon = true,
@@ -87,12 +89,7 @@ export const AlertError = ({
{formattedErrorMessage}
</p>
)}
{showInstructions && (
<p>
Try refreshing your browser, but if the issue persists for more than a few minutes,
please reach out to us via support.
</p>
)}
{showInstructions && <p>{description}</p>}
{children}
</>
}
@@ -1,7 +1,16 @@
import { createContext, useContext, useState, useCallback } from 'react'
import { createContext, useCallback, useContext, useState } from 'react'
const BANNER_ID = {
METRICS_API: 'metrics-api-banner',
INDEX_ADVISOR: 'index-advisor-banner',
TABLE_EDITOR_QUEUE_OPERATIONS: 'table-editor-queue-operations-banner',
RLS_EVENT_TRIGGER: 'rls-event-trigger-banner',
} as const
export type BannerId = (typeof BANNER_ID)[keyof typeof BANNER_ID]
export interface Banner {
id: string
id: BannerId
content: React.ReactNode
isDismissed: boolean
priority?: number
@@ -11,7 +20,7 @@ export interface Banner {
interface BannerStackContextType {
banners: Banner[]
addBanner: (banner: Banner) => void
dismissBanner: (id: string) => void
dismissBanner: (id: BannerId) => void
}
const BannerStackContext = createContext<BannerStackContextType | undefined>(undefined)
@@ -1,70 +0,0 @@
import { LOCAL_STORAGE_KEYS } from 'common'
import { useParams } from 'common/hooks'
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
import { Search } from 'lucide-react'
import { Badge, Button } from 'ui'
import { BannerCard } from '../BannerCard'
import { useBannerStack } from '../BannerStackProvider'
import {
useFeaturePreviewModal,
useIsTableFilterBarEnabled,
} from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
export const BannerTableEditorFilter = () => {
const { ref } = useParams()
const { selectFeaturePreview } = useFeaturePreviewModal()
const isTableFilterBarEnabled = useIsTableFilterBarEnabled()
const { dismissBanner } = useBannerStack()
const [, setIsDismissed] = useLocalStorageQuery(
LOCAL_STORAGE_KEYS.TABLE_EDITOR_NEW_FILTER_BANNER_DISMISSED(ref ?? ''),
false
)
const text = "name = 'John Doe'"
return (
<BannerCard
onDismiss={() => {
setIsDismissed(true)
dismissBanner('table-editor-new-filter-banner')
}}
>
<div className="flex flex-col gap-y-4">
<div className="flex flex-col gap-y-2 items-start">
<Badge variant="success" className="-ml-0.5 uppercase inline-flex items-center mb-2">
Preview
</Badge>
<div className="flex items-center gap-3 bg-surface-100 w-full border rounded-md px-1.5 py-1">
<Search size={14} />
<div className="bg-surface-200 border px-1.5 py-0.5 rounded">
<p
className="text-xs font-mono overflow-hidden whitespace-nowrap border-r-2 border-overlay"
style={{
width: `${text.length}ch`,
animation: `typewriter 2s steps(${text.length}) forwards, blink-caret 0.75s step-end infinite`,
}}
>
{text}
</p>
</div>
</div>
</div>
<div className="flex flex-col gap-y-1 mb-2">
<p className="text-sm font-medium">New Table Filter Bar</p>
<p className="text-xs text-foreground-lighter text-balance">
Build and modify complex filters visually
</p>
</div>
<Button
type="default"
className="w-min"
onClick={() => selectFeaturePreview(LOCAL_STORAGE_KEYS.UI_PREVIEW_TABLE_FILTER_BAR)}
>
{isTableFilterBarEnabled ? 'View' : 'Enable'} feature preview
</Button>
</div>
</BannerCard>
)
}
@@ -0,0 +1,75 @@
import { LOCAL_STORAGE_KEYS } from 'common'
import { useParams } from 'common/hooks'
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
import { Badge, Button, Card, CardContent, CardHeader } from 'ui'
import { BannerCard } from '../BannerCard'
import { useBannerStack } from '../BannerStackProvider'
import {
useFeaturePreviewModal,
useIsQueueOperationsEnabled,
} from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
export const BannerTableEditorQueueOperations = () => {
const { ref } = useParams()
const { selectFeaturePreview } = useFeaturePreviewModal()
const isQueueOperationsEnabled = useIsQueueOperationsEnabled()
const { dismissBanner } = useBannerStack()
const [, setIsDismissed] = useLocalStorageQuery(
LOCAL_STORAGE_KEYS.TABLE_EDITOR_QUEUE_OPERATIONS_BANNER_DISMISSED(ref ?? ''),
false
)
return (
<BannerCard
onDismiss={() => {
setIsDismissed(true)
dismissBanner('table-editor-queue-operations-banner')
}}
>
<div className="flex flex-col gap-y-4">
<div className="flex flex-col gap-y-2 items-start">
<Badge variant="success" className="-ml-0.5 uppercase inline-flex items-center mb-2">
Preview
</Badge>
<Card className="text-xs w-full">
<CardHeader className="flex flex-row gap-2 px-2 py-2">
<div className="min-w-0 flex-1">
<div className="text-xs text-foreground ml-0.5">
<span>name</span>
<span className="text-foreground-muted mx-1.5">·</span>
<span>where id = 10</span>
</div>
</div>
</CardHeader>
<CardContent className="font-mono text-xs px-2 py-1">
<div className="flex gap-2 py-0.5">
<span className="text-destructive select-none font-medium">-</span>
<span className="text-destructive truncate max-w-full">Red</span>
</div>
<div className="flex gap-2 py-0.5">
<span className="text-brand-link select-none font-medium">+</span>
<span className="text-brand-link truncate max-w-full">Blue</span>
</div>
</CardContent>
</Card>
</div>
<div className="flex flex-col gap-y-1 mb-2">
<p className="text-sm font-medium">Queue row edits in Table Editor</p>
<p className="text-xs text-foreground-lighter text-balance">
Batch multiple row edits and review them before saving to your database
</p>
</div>
<Button
type="default"
className="w-min"
onClick={() => selectFeaturePreview(LOCAL_STORAGE_KEYS.UI_PREVIEW_QUEUE_OPERATIONS)}
>
{isQueueOperationsEnabled ? 'View' : 'Enable'} feature preview
</Button>
</div>
</BannerCard>
)
}
@@ -1,16 +1,17 @@
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { getAccessToken } from 'common'
import { databaseKeys } from 'data/database/keys'
import { BASE_PATH } from 'lib/constants'
import { useTrack } from 'lib/telemetry/track'
import { toast } from 'sonner'
import type { ResponseError } from 'types'
import { stripeSyncKeys } from './keys'
export type StripeSyncInstallVariables = {
projectRef: string
stripeSecretKey: string
startTime: number
}
export type StripeSyncInstallResponse = {
@@ -21,6 +22,7 @@ export type StripeSyncInstallResponse = {
export async function installStripeSync({
projectRef,
stripeSecretKey,
startTime,
}: StripeSyncInstallVariables): Promise<StripeSyncInstallResponse> {
const accessToken = await getAccessToken()
@@ -33,6 +35,7 @@ export async function installStripeSync({
body: JSON.stringify({
projectRef,
stripeSecretKey,
startTime,
}),
})
@@ -1,15 +1,16 @@
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { getAccessToken } from 'common'
import { databaseKeys } from 'data/database/keys'
import { BASE_PATH } from 'lib/constants'
import { useTrack } from 'lib/telemetry/track'
import { toast } from 'sonner'
import type { ResponseError } from 'types'
import { stripeSyncKeys } from './keys'
export type StripeSyncUninstallVariables = {
projectRef: string
startTime: number
}
export type StripeSyncUninstallResponse = {
@@ -19,6 +20,7 @@ export type StripeSyncUninstallResponse = {
export async function uninstallStripeSync({
projectRef,
startTime,
}: StripeSyncUninstallVariables): Promise<StripeSyncUninstallResponse> {
const accessToken = await getAccessToken()
@@ -30,6 +32,7 @@ export async function uninstallStripeSync({
},
body: JSON.stringify({
projectRef,
startTime,
}),
})
@@ -1,7 +1,7 @@
import { useQuery } from '@tanstack/react-query'
import { get, handleError } from 'data/fetchers'
import type { ResponseError, UseCustomQueryOptions } from 'types'
import { projectKeys } from './keys'
export type ProjectStatusVariables = {
+2 -3
View File
@@ -1,7 +1,6 @@
import z from 'zod'
import { IS_PLATFORM } from 'common'
import { InternalServerError } from 'lib/api/apiHelpers'
import z from 'zod'
export type IncidentCache = {
affected_regions: Array<string> | null
@@ -81,7 +80,7 @@ export async function getActiveIncidents(): Promise<IncidentInfo[]> {
Accept: 'application/json',
'Content-Type': 'application/json',
},
cache: 'no-store',
next: { revalidate: 180 },
signal: AbortSignal.timeout(30_000),
})
const responseText = await response.text()
@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest'
import { httpEndpointUrlSchema, isValidHttpEndpointUrl } from './http-url'
const schema = httpEndpointUrlSchema({
requiredMessage: 'required',
invalidMessage: 'invalid',
prefixMessage: 'prefix',
})
describe('isValidHttpEndpointUrl', () => {
it('accepts valid http and https endpoints', () => {
expect(isValidHttpEndpointUrl('https://api.supabase.com/webhooks')).toBe(true)
expect(isValidHttpEndpointUrl('http://localhost:3000/hooks')).toBe(true)
expect(isValidHttpEndpointUrl('https://127.0.0.1:4318/v1/logs')).toBe(true)
expect(isValidHttpEndpointUrl('https://[::1]:4318/v1/logs')).toBe(true)
})
it('rejects invalid endpoint URLs', () => {
expect(isValidHttpEndpointUrl('https://webhook')).toBe(false)
expect(isValidHttpEndpointUrl('ftp://api.supabase.com/webhooks')).toBe(false)
expect(isValidHttpEndpointUrl('not a url')).toBe(false)
})
})
describe('httpEndpointUrlSchema', () => {
it('rejects empty values', () => {
const result = schema.safeParse('')
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.issues[0].message).toBe('required')
}
})
it('rejects URLs without an http or https prefix', () => {
const result = schema.safeParse('api.supabase.com/webhooks')
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.issues[0].message).toBe('prefix')
}
})
it('rejects incomplete hostnames', () => {
const result = schema.safeParse('https://webhook')
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.issues[0].message).toBe('invalid')
}
})
it('accepts valid endpoints after trimming', () => {
const result = schema.safeParse(' https://api.supabase.com/webhooks ')
expect(result.success).toBe(true)
if (result.success) {
expect(result.data).toBe('https://api.supabase.com/webhooks')
}
})
})
+59
View File
@@ -0,0 +1,59 @@
import { z } from 'zod'
const HTTP_URL_PROTOCOL_REGEX = /^https?:\/\//
const IPV4_SEGMENT = '(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)'
const IPV4_REGEX = new RegExp(`^(?:${IPV4_SEGMENT}\\.){3}${IPV4_SEGMENT}$`)
const BRACKETED_IPV6_REGEX = /^\[[0-9a-f:.]+\]$/i
export const hasHttpUrlProtocol = (value: string) => HTTP_URL_PROTOCOL_REGEX.test(value)
export const isValidHttpEndpointUrl = (value: string) => {
try {
const url = new URL(value)
if (url.protocol !== 'http:' && url.protocol !== 'https:') return false
const { hostname } = url
return (
hostname === 'localhost' ||
hostname.includes('.') ||
IPV4_REGEX.test(hostname) ||
BRACKETED_IPV6_REGEX.test(hostname)
)
} catch {
return false
}
}
type HttpEndpointUrlSchemaOptions = {
requiredMessage: string
invalidMessage: string
prefixMessage: string
}
export const httpEndpointUrlSchema = ({
requiredMessage,
invalidMessage,
prefixMessage,
}: HttpEndpointUrlSchemaOptions) =>
z
.string()
.trim()
.min(1, requiredMessage)
.superRefine((value, ctx) => {
if (!value) return
if (!hasHttpUrlProtocol(value)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: prefixMessage,
})
return
}
if (!isValidHttpEndpointUrl(value)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: invalidMessage,
})
}
})
+2 -2
View File
@@ -103,7 +103,7 @@
"memoize-one": "^5.0.1",
"mime-db": "^1.53.0",
"monaco-editor": "0.52.2",
"next": "~16.1.6",
"next": "~16.1.7",
"next-themes": "^0.3.0",
"nuqs": "2.7.1",
"openai": "^4.75.1",
@@ -137,7 +137,7 @@
"sql-formatter": "^15.0.0",
"sqlstring": "^2.3.2",
"streamdown": "^1.3.0",
"stripe-experiment-sync": "^1.0.19",
"stripe-experiment-sync": "1.0.27",
"tus-js-client": "^4.1.0",
"ui": "workspace:*",
"ui-patterns": "workspace:*",
@@ -7,10 +7,12 @@ import { z } from 'zod'
const InstallBodySchema = z.object({
projectRef: z.string().min(1),
stripeSecretKey: z.string().min(1),
startTime: z.number().positive().optional(),
})
const UninstallBodySchema = z.object({
projectRef: z.string().min(1),
startTime: z.number().positive().optional(),
})
async function isStripeSyncEnabled() {
@@ -26,6 +28,10 @@ function getBearerToken(req: NextApiRequest) {
return match?.[1]?.trim() ?? null
}
export const config = {
maxDuration: 300, // 5 minutes, since the installation process can take a while even if happening in background
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
// Hide endpoint if the integration is disabled.
if (!(await isStripeSyncEnabled())) {
@@ -59,7 +65,7 @@ async function handleDeleteStripeSyncInstall(req: NextApiRequest, res: NextApiRe
.status(400)
.json({ data: null, error: { message: 'Bad Request: Invalid request body' } })
}
const { projectRef } = parsed.data
const { projectRef, startTime } = parsed.data
waitUntil(
uninstall({
@@ -67,6 +73,7 @@ async function handleDeleteStripeSyncInstall(req: NextApiRequest, res: NextApiRe
supabaseProjectRef: projectRef,
baseProjectUrl: process.env.NEXT_PUBLIC_CUSTOMER_DOMAIN,
supabaseManagementUrl: process.env.NEXT_PUBLIC_API_DOMAIN,
startTime,
}).catch((error) => {
console.error('Stripe Sync Engine uninstallation failed.', error)
throw error
@@ -92,7 +99,7 @@ async function handleSetupStripeSyncInstall(req: NextApiRequest, res: NextApiRes
.status(400)
.json({ data: null, error: { message: 'Bad Request: Invalid request body' } })
}
const { projectRef, stripeSecretKey } = parsed.data
const { projectRef, stripeSecretKey, startTime } = parsed.data
// Validate the Stripe API key before proceeding with installation
try {
@@ -113,10 +120,12 @@ async function handleSetupStripeSyncInstall(req: NextApiRequest, res: NextApiRes
error: { message: errorMessage },
})
}
} catch (error: any) {
} catch (error) {
const normalizedErrorMessage = error instanceof Error ? error.message : String(error)
return res.status(400).json({
data: null,
error: { message: `Failed to validate Stripe API key: ${error.message}` },
error: { message: `Failed to validate Stripe API key: ${normalizedErrorMessage}` },
})
}
waitUntil(
@@ -127,6 +136,7 @@ async function handleSetupStripeSyncInstall(req: NextApiRequest, res: NextApiRes
baseProjectUrl: process.env.NEXT_PUBLIC_CUSTOMER_DOMAIN,
supabaseManagementUrl: process.env.NEXT_PUBLIC_API_DOMAIN,
packageVersion: VERSION,
startTime,
}).catch((error) => {
console.error('Stripe Sync Engine installation failed.', error)
throw error
+8
View File
@@ -46,6 +46,7 @@ import { useCustomContent } from 'hooks/custom-content/useCustomContent'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useDataApiGrantTogglesEnabled } from 'hooks/misc/useDataApiGrantTogglesEnabled'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { usePHFlag } from 'hooks/ui/useFlag'
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { withAuth } from 'hooks/misc/withAuth'
@@ -96,6 +97,10 @@ const Wizard: NextPageWithLayout = () => {
const showPostgresVersionSelector = useFlag('showPostgresVersionSelector')
const cloudProviderEnabled = useFlag('enableFlyCloudProvider')
const isDataApiGrantTogglesEnabled = useDataApiGrantTogglesEnabled()
// Read the raw flag for telemetry — useDataApiGrantTogglesEnabled coerces undefined→false,
// which would record false for users whose flags haven't loaded yet. The raw value preserves
// undefined (omitted from PostHog) so we only record true/false when the flag is resolved.
const tableEditorApiAccessToggleFlag = usePHFlag<boolean>('tableEditorApiAccessToggle')
const showNonProdFields = process.env.NEXT_PUBLIC_ENVIRONMENT !== 'prod'
const isNotOnHigherPlan = !['team', 'enterprise', 'platform'].includes(currentOrg?.plan.id ?? '')
@@ -259,6 +264,9 @@ const Wizard: NextPageWithLayout = () => {
enableRlsEventTrigger: form.getValues('enableRlsEventTrigger'),
dataApiEnabled: form.getValues('dataApi'),
useOrioleDb: form.getValues('useOrioleDb'),
...(tableEditorApiAccessToggleFlag !== undefined && {
tableEditorApiAccessToggleEnabled: tableEditorApiAccessToggleFlag,
}),
},
{
project: res.ref,
@@ -1,20 +1,20 @@
import { parseAsArrayOf, parseAsInteger, parseAsJson, parseAsString, useQueryStates } from 'nuqs'
import { NumericFilter } from 'components/interfaces/Reports/v2/ReportsNumericFilter'
import { useParams } from 'common'
import { useIndexAdvisorStatus } from 'components/interfaces/QueryPerformance/hooks/useIsIndexAdvisorStatus'
import { useQueryPerformanceSort } from 'components/interfaces/QueryPerformance/hooks/useQueryPerformanceSort'
import { QueryPerformance } from 'components/interfaces/QueryPerformance/QueryPerformance'
import { type QuerySource } from 'components/interfaces/QueryPerformance/QueryPerformance.types'
import { useQueryPerformanceQuery } from 'components/interfaces/QueryPerformance/useQueryPerformanceQuery'
import { PRESET_CONFIG } from 'components/interfaces/Reports/Reports.constants'
import { useQueryPerformanceQuery } from 'components/interfaces/Reports/Reports.queries'
import { Presets } from 'components/interfaces/Reports/Reports.types'
import { queriesFactory } from 'components/interfaces/Reports/Reports.utils'
import { NumericFilter } from 'components/interfaces/Reports/v2/ReportsNumericFilter'
import { DefaultLayout } from 'components/layouts/DefaultLayout'
import ObservabilityLayout from 'components/layouts/ObservabilityLayout/ObservabilityLayout'
import { DatabaseSelector } from 'components/ui/DatabaseSelector'
import { DocsButton } from 'components/ui/DocsButton'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { DOCS_URL } from 'lib/constants'
import { parseAsArrayOf, parseAsInteger, parseAsJson, parseAsString, useQueryStates } from 'nuqs'
import type { NextPageWithLayout } from 'types'
import { Admonition } from 'ui-patterns'
@@ -25,12 +25,20 @@ const QueryPerformanceReport: NextPageWithLayout = () => {
const { sort: sortConfig } = useQueryPerformanceSort()
const [
{ search: searchQuery, roles, minCalls, totalTimeFilter: totalTimeFilterRaw, indexAdvisor },
{
search: searchQuery,
roles,
sources,
minCalls,
totalTimeFilter: totalTimeFilterRaw,
indexAdvisor,
},
] = useQueryStates({
sort: parseAsString,
order: parseAsString,
search: parseAsString.withDefault(''),
roles: parseAsArrayOf(parseAsString).withDefault([]),
sources: parseAsArrayOf(parseAsString).withDefault([]),
minCalls: parseAsInteger,
totalTimeFilter: parseAsJson<NumericFilter | null>((value) =>
value === null || value === undefined ? null : (value as NumericFilter)
@@ -57,6 +65,7 @@ const QueryPerformanceReport: NextPageWithLayout = () => {
orderBy: sortConfig || undefined,
preset: 'unified',
roles,
sources: sources as QuerySource[],
runIndexAdvisor: isIndexAdvisorEnabled,
minCalls: minCalls ?? undefined,
minTotalTime,
@@ -12,7 +12,7 @@ import type { NextPageWithLayout } from 'types'
const SqlQuickstarts: NextPageWithLayout = () => {
const router = useRouter()
const { ref } = useParams<{ ref: string }>()
const ref = useParams<{ ref: string }>()?.ref
const tabs = useTabsStateSnapshot()
useEffect(() => {
@@ -12,7 +12,7 @@ import type { NextPageWithLayout } from 'types'
const SqlTemplates: NextPageWithLayout = () => {
const router = useRouter()
const { ref } = useParams<{ ref: string }>()
const ref = useParams<{ ref: string }>()?.ref
const tabs = useTabsStateSnapshot()
useEffect(() => {
+2 -1
View File
@@ -36,7 +36,8 @@
"**/*.js",
"**/*.jsx",
".next/types/**/*.ts",
"./../../packages/ui/src/**/*.d.ts"
"./../../packages/ui/src/**/*.d.ts",
".next/dev/types/**/*.ts"
],
"exclude": ["node_modules", "public/deno/*.ts"]
}
+20 -20
View File
@@ -1,19 +1,19 @@
{
"company": [
{
"number": "180+",
"text": "team members\n\nin 35+ countries"
"number": "280+",
"text": "team members\n\nin 55+ countries"
},
{
"number": "15+",
"number": "20+",
"text": "languages spoken"
},
{
"number": "$496M",
"text": "in funding"
"number": "$500M",
"text": "raised"
},
{
"number": "430,000+",
"number": "540,000+",
"text": "community members"
},
{
@@ -25,27 +25,27 @@
{
"icon": "egoless",
"title": "Egoless",
"text": "Willing to do the schlep, the team goals come first."
"text": "Mission driven, work for the good of colleagues."
},
{
"icon": "curious",
"title": "Polyglot",
"text": "Broad skillsets. Growth mindset."
},
{
"icon": "process",
"title": "Process Driven",
"text": "Engineer yourself out of the job."
"icon": "kaizen",
"title": "Kaizen Mindset",
"text": "Iterative approach, continuously improve all functions."
},
{
"icon": "honesty",
"title": "Intellectual Honesty",
"text": "Put our own biases aside."
"title": "Truth Seeking",
"text": "Intellectually honest, act with integrity, recognize biases."
},
{
"icon": "flexibility",
"title": "Kaizen mindset",
"text": "Continuously improve all functions."
"icon": "batteries",
"title": "Batteries Included",
"text": "High ownership, intrinsically motivated, care about quality."
},
{
"icon": "undeniable",
"title": "Undeniable",
"text": "So good you earn respect, reliable, consistently deliver."
}
],
"contributors": [
+1 -1
View File
@@ -299,7 +299,7 @@ SERVICE_ROLE_KEY_ASYMMETRIC=
############
# Enable webp support
IMGPROXY_ENABLE_WEBP_DETECTION=true
IMGPROXY_AUTO_WEBP=true
############
+3 -2
View File
@@ -394,7 +394,7 @@ services:
IMGPROXY_BIND: ":5001"
IMGPROXY_LOCAL_FILESYSTEM_ROOT: /
IMGPROXY_USE_ETAG: "true"
IMGPROXY_ENABLE_WEBP_DETECTION: ${IMGPROXY_ENABLE_WEBP_DETECTION}
IMGPROXY_AUTO_WEBP: ${IMGPROXY_AUTO_WEBP}
IMGPROXY_MAX_SRC_RESOLUTION: 16.8
meta:
@@ -600,7 +600,8 @@ services:
]
interval: 10s
timeout: 5s
retries: 5
retries: 10
start_period: 30s
depends_on:
db:
condition: service_healthy
+4 -1
View File
@@ -22,7 +22,10 @@ export const test = base.extend<TestOptions>({
page: async ({ page }, use) => {
const ref = env.PROJECT_REF ?? 'default'
await page.addInitScript((ref) => {
localStorage.setItem(`table-editor-new-filter-banner-dismissed-${ref}`, JSON.stringify(true))
localStorage.setItem(
`table-editor-queue-operations-banner-dismissed-${ref}`,
JSON.stringify(true)
)
}, ref)
await use(page)
},
@@ -1,90 +1,82 @@
// AWS SDK issue: https://github.com/aws/aws-sdk-js-v3/issues/6134
// We need to mock the file system for the AWS SDK to work.
import {
prepareVirtualFile,
} from "https://deno.land/x/mock_file@v1.1.2/mod.ts";
import { prepareVirtualFile } from 'https://deno.land/x/mock_file@v1.1.2/mod.ts'
import {
BedrockRuntimeClient,
InvokeModelCommand,
} from "npm:@aws-sdk/client-bedrock-runtime";
import { createClient } from "npm:@supabase/supabase-js";
import { decode } from "npm:base64-arraybuffer";
import { BedrockRuntimeClient, InvokeModelCommand } from 'npm:@aws-sdk/client-bedrock-runtime'
import { createClient } from 'npm:@supabase/supabase-js'
import { decode } from 'npm:base64-arraybuffer'
console.log("Hello from Amazon Bedrock!");
console.log('Hello from Amazon Bedrock!')
Deno.serve(async (req) => {
prepareVirtualFile("./aws/config");
prepareVirtualFile("./aws/credentials");
prepareVirtualFile('./aws/config')
prepareVirtualFile('./aws/credentials')
const client = new BedrockRuntimeClient({
region: "us-west-2",
region: 'us-west-2',
credentials: {
accessKeyId: Deno.env.get("AWS_ACCESS_KEY_ID") ?? "",
secretAccessKey: Deno.env.get("AWS_SECRET_ACCESS_KEY") ?? "",
sessionToken: Deno.env.get("AWS_SESSION_TOKEN") ?? "",
accessKeyId: Deno.env.get('AWS_ACCESS_KEY_ID') ?? '',
secretAccessKey: Deno.env.get('AWS_SECRET_ACCESS_KEY') ?? '',
sessionToken: Deno.env.get('AWS_SESSION_TOKEN') ?? '',
},
});
})
const { prompt, seed } = await req.json();
console.log(prompt);
const { prompt, seed } = await req.json()
console.log(prompt)
const input = {
contentType: "application/json",
accept: "*/*",
modelId: "amazon.titan-image-generator-v1",
contentType: 'application/json',
accept: '*/*',
modelId: 'amazon.titan-image-generator-v1',
body: JSON.stringify({
"taskType": "TEXT_IMAGE",
"textToImageParams": { "text": prompt },
"imageGenerationConfig": {
"numberOfImages": 1,
"quality": "standard",
"cfgScale": 8.0,
"height": 512,
"width": 512,
"seed": seed ?? 0,
taskType: 'TEXT_IMAGE',
textToImageParams: { text: prompt },
imageGenerationConfig: {
numberOfImages: 1,
quality: 'standard',
cfgScale: 8.0,
height: 512,
width: 512,
seed: seed ?? 0,
},
}),
};
}
const command = new InvokeModelCommand(input);
const response = await client.send(command);
console.log(response);
const command = new InvokeModelCommand(input)
const response = await client.send(command)
console.log(response)
if (response.$metadata.httpStatusCode === 200) {
const { body, $metadata } = response;
const { body, $metadata } = response
const textDecoder = new TextDecoder("utf-8");
const jsonString = textDecoder.decode(body.buffer);
const parsedData = JSON.parse(jsonString);
console.log(parsedData);
const image = parsedData.images[0];
const textDecoder = new TextDecoder('utf-8')
const jsonString = textDecoder.decode(body.buffer)
const parsedData = JSON.parse(jsonString)
console.log(parsedData)
const image = parsedData.images[0]
const supabaseClient = createClient(
// Supabase API URL - env var exported by default.
Deno.env.get("SUPABASE_URL")!,
Deno.env.get('SUPABASE_URL')!,
// Supabase API ANON KEY - env var exported by default.
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
);
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
const { data: upload, error: uploadError } = await supabaseClient.storage
.from("images")
.upload(`${$metadata.requestId ?? ""}.png`, decode(image), {
contentType: "image/png",
cacheControl: "3600",
.from('images')
.upload(`${$metadata.requestId ?? ''}.png`, decode(image), {
contentType: 'image/png',
cacheControl: '3600',
upsert: false,
});
})
if (!upload) {
return Response.json(uploadError);
return Response.json(uploadError)
}
const { data } = supabaseClient
.storage
.from("images")
.getPublicUrl(upload.path!);
return Response.json(data);
const { data } = supabaseClient.storage.from('images').getPublicUrl(upload.path!)
return Response.json(data)
}
return Response.json(response);
});
return Response.json(response)
})
/* To invoke locally:
@@ -1,10 +1,4 @@
export type Json =
| string
| number
| boolean
| null
| { [key: string]: Json | undefined }
| Json[]
export type Json = string | number | boolean | null | { [key: string]: Json | undefined } | Json[]
export type Database = {
graphql_public: {
@@ -180,11 +174,11 @@ export type Database = {
}
Relationships: [
{
foreignKeyName: "objects_bucketId_fkey"
columns: ["bucket_id"]
foreignKeyName: 'objects_bucketId_fkey'
columns: ['bucket_id']
isOneToOne: false
referencedRelation: "buckets"
referencedColumns: ["id"]
referencedRelation: 'buckets'
referencedColumns: ['id']
},
]
}
@@ -257,27 +251,25 @@ export type Database = {
}
}
type PublicSchema = Database[Extract<keyof Database, "public">]
type PublicSchema = Database[Extract<keyof Database, 'public'>]
export type Tables<
PublicTableNameOrOptions extends
| keyof (PublicSchema["Tables"] & PublicSchema["Views"])
| keyof (PublicSchema['Tables'] & PublicSchema['Views'])
| { schema: keyof Database },
TableName extends PublicTableNameOrOptions extends { schema: keyof Database }
? keyof (Database[PublicTableNameOrOptions["schema"]]["Tables"] &
Database[PublicTableNameOrOptions["schema"]]["Views"])
? keyof (Database[PublicTableNameOrOptions['schema']]['Tables'] &
Database[PublicTableNameOrOptions['schema']]['Views'])
: never = never,
> = PublicTableNameOrOptions extends { schema: keyof Database }
? (Database[PublicTableNameOrOptions["schema"]]["Tables"] &
Database[PublicTableNameOrOptions["schema"]]["Views"])[TableName] extends {
? (Database[PublicTableNameOrOptions['schema']]['Tables'] &
Database[PublicTableNameOrOptions['schema']]['Views'])[TableName] extends {
Row: infer R
}
? R
: never
: PublicTableNameOrOptions extends keyof (PublicSchema["Tables"] &
PublicSchema["Views"])
? (PublicSchema["Tables"] &
PublicSchema["Views"])[PublicTableNameOrOptions] extends {
: PublicTableNameOrOptions extends keyof (PublicSchema['Tables'] & PublicSchema['Views'])
? (PublicSchema['Tables'] & PublicSchema['Views'])[PublicTableNameOrOptions] extends {
Row: infer R
}
? R
@@ -285,20 +277,18 @@ export type Tables<
: never
export type TablesInsert<
PublicTableNameOrOptions extends
| keyof PublicSchema["Tables"]
| { schema: keyof Database },
PublicTableNameOrOptions extends keyof PublicSchema['Tables'] | { schema: keyof Database },
TableName extends PublicTableNameOrOptions extends { schema: keyof Database }
? keyof Database[PublicTableNameOrOptions["schema"]]["Tables"]
? keyof Database[PublicTableNameOrOptions['schema']]['Tables']
: never = never,
> = PublicTableNameOrOptions extends { schema: keyof Database }
? Database[PublicTableNameOrOptions["schema"]]["Tables"][TableName] extends {
? Database[PublicTableNameOrOptions['schema']]['Tables'][TableName] extends {
Insert: infer I
}
? I
: never
: PublicTableNameOrOptions extends keyof PublicSchema["Tables"]
? PublicSchema["Tables"][PublicTableNameOrOptions] extends {
: PublicTableNameOrOptions extends keyof PublicSchema['Tables']
? PublicSchema['Tables'][PublicTableNameOrOptions] extends {
Insert: infer I
}
? I
@@ -306,20 +296,18 @@ export type TablesInsert<
: never
export type TablesUpdate<
PublicTableNameOrOptions extends
| keyof PublicSchema["Tables"]
| { schema: keyof Database },
PublicTableNameOrOptions extends keyof PublicSchema['Tables'] | { schema: keyof Database },
TableName extends PublicTableNameOrOptions extends { schema: keyof Database }
? keyof Database[PublicTableNameOrOptions["schema"]]["Tables"]
? keyof Database[PublicTableNameOrOptions['schema']]['Tables']
: never = never,
> = PublicTableNameOrOptions extends { schema: keyof Database }
? Database[PublicTableNameOrOptions["schema"]]["Tables"][TableName] extends {
? Database[PublicTableNameOrOptions['schema']]['Tables'][TableName] extends {
Update: infer U
}
? U
: never
: PublicTableNameOrOptions extends keyof PublicSchema["Tables"]
? PublicSchema["Tables"][PublicTableNameOrOptions] extends {
: PublicTableNameOrOptions extends keyof PublicSchema['Tables']
? PublicSchema['Tables'][PublicTableNameOrOptions] extends {
Update: infer U
}
? U
@@ -327,14 +315,12 @@ export type TablesUpdate<
: never
export type Enums<
PublicEnumNameOrOptions extends
| keyof PublicSchema["Enums"]
| { schema: keyof Database },
PublicEnumNameOrOptions extends keyof PublicSchema['Enums'] | { schema: keyof Database },
EnumName extends PublicEnumNameOrOptions extends { schema: keyof Database }
? keyof Database[PublicEnumNameOrOptions["schema"]]["Enums"]
? keyof Database[PublicEnumNameOrOptions['schema']]['Enums']
: never = never,
> = PublicEnumNameOrOptions extends { schema: keyof Database }
? Database[PublicEnumNameOrOptions["schema"]]["Enums"][EnumName]
: PublicEnumNameOrOptions extends keyof PublicSchema["Enums"]
? PublicSchema["Enums"][PublicEnumNameOrOptions]
? Database[PublicEnumNameOrOptions['schema']]['Enums'][EnumName]
: PublicEnumNameOrOptions extends keyof PublicSchema['Enums']
? PublicSchema['Enums'][PublicEnumNameOrOptions]
: never
@@ -1,47 +1,47 @@
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import 'jsr:@supabase/functions-js/edge-runtime.d.ts'
import { createClient } from "jsr:@supabase/supabase-js@2";
import { Database, Tables } from "../_shared/database.types.ts";
import { createClient } from 'jsr:@supabase/supabase-js@2'
import { Database, Tables } from '../_shared/database.types.ts'
type EmbeddingsRecord = Tables<"embeddings">;
type EmbeddingsRecord = Tables<'embeddings'>
interface WebhookPayload {
type: "INSERT" | "UPDATE" | "DELETE";
table: string;
record: EmbeddingsRecord;
schema: "public";
old_record: null | EmbeddingsRecord;
type: 'INSERT' | 'UPDATE' | 'DELETE'
table: string
record: EmbeddingsRecord
schema: 'public'
old_record: null | EmbeddingsRecord
}
const supabase = createClient<Database>(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
);
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
const model = new Supabase.ai.Session("gte-small");
const model = new Supabase.ai.Session('gte-small')
Deno.serve(async (req) => {
const payload: WebhookPayload = await req.json();
const { content, id } = payload.record;
const payload: WebhookPayload = await req.json()
const { content, id } = payload.record
// Check if content has changed.
if (content === payload?.old_record?.content) {
return new Response("ok - no change");
return new Response('ok - no change')
}
// Generate embedding
const embedding = await model.run(content, {
mean_pool: true,
normalize: true,
});
})
// Store in DB
const { error } = await supabase.from("embeddings").update({
embedding: JSON.stringify(embedding),
}).eq(
"id",
id,
);
if (error) console.warn(error.message);
const { error } = await supabase
.from('embeddings')
.update({
embedding: JSON.stringify(embedding),
})
.eq('id', id)
if (error) console.warn(error.message)
return new Response("ok - updated");
});
return new Response('ok - updated')
})
@@ -1,38 +1,38 @@
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import 'jsr:@supabase/functions-js/edge-runtime.d.ts'
import { createClient } from "jsr:@supabase/supabase-js@2";
import { Database } from "../_shared/database.types.ts";
import { createClient } from 'jsr:@supabase/supabase-js@2'
import { Database } from '../_shared/database.types.ts'
const supabase = createClient<Database>(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
);
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
const model = new Supabase.ai.Session("gte-small");
const model = new Supabase.ai.Session('gte-small')
Deno.serve(async (req) => {
const { search } = await req.json();
if (!search) return new Response("Please provide a search param!");
const { search } = await req.json()
if (!search) return new Response('Please provide a search param!')
// Generate embedding for search term.
const embedding = await model.run(search, {
mean_pool: true,
normalize: true,
});
})
// Query embeddings.
const { data: result, error } = await supabase
.rpc("query_embeddings", {
.rpc('query_embeddings', {
embedding: JSON.stringify(embedding),
match_threshold: 0.8,
})
.select("content")
.limit(3);
.select('content')
.limit(3)
if (error) {
return Response.json(error);
return Response.json(error)
}
return Response.json({ search, result });
});
return Response.json({ search, result })
})
/* To invoke locally:
@@ -1,59 +1,59 @@
// https://github.com/Mozilla-Ocho/llamafile?tab=readme-ov-file#quickstart
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
const session = new Supabase.ai.Session("LLaMA_CPP");
import 'jsr:@supabase/functions-js/edge-runtime.d.ts'
const session = new Supabase.ai.Session('LLaMA_CPP')
Deno.serve(async (req: Request) => {
const params = new URL(req.url).searchParams;
const prompt = params.get("prompt") ?? "";
const params = new URL(req.url).searchParams
const prompt = params.get('prompt') ?? ''
// Get the output as a stream
const output = await session.run({
messages: [
{
"role": "system",
"content":
"You are LLAMAfile, an AI assistant. Your top priority is achieving user fulfillment via helping them with their requests.",
},
{
"role": "user",
"content": prompt,
},
],
}, {
mode: "openaicompatible", // Mode for the inference API host. (default: 'ollama')
stream: true,
}) as AsyncGenerator<any>;
const output = (await session.run(
{
messages: [
{
role: 'system',
content:
'You are LLAMAfile, an AI assistant. Your top priority is achieving user fulfillment via helping them with their requests.',
},
{
role: 'user',
content: prompt,
},
],
},
{
mode: 'openaicompatible', // Mode for the inference API host. (default: 'ollama')
stream: true,
}
)) as AsyncGenerator<any>
const body = new ReadableStream({
async pull(ctrl) {
try {
const item = await output.next();
const item = await output.next()
if (item.done) {
console.log("done");
ctrl.close();
return;
console.log('done')
ctrl.close()
return
}
ctrl.enqueue("data: ");
ctrl.enqueue(JSON.stringify(item.value));
ctrl.enqueue("\r\n\r\n");
ctrl.enqueue('data: ')
ctrl.enqueue(JSON.stringify(item.value))
ctrl.enqueue('\r\n\r\n')
} catch (err) {
console.error(err);
ctrl.close();
console.error(err)
ctrl.close()
}
},
});
})
return new Response(
body.pipeThrough(new TextEncoderStream()),
{
headers: {
"Content-Type": "text/event-stream",
},
return new Response(body.pipeThrough(new TextEncoderStream()), {
headers: {
'Content-Type': 'text/event-stream',
},
);
});
})
})
/**
Run locally:
@@ -1,32 +1,35 @@
// https://github.com/Mozilla-Ocho/llamafile?tab=readme-ov-file#quickstart
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
const session = new Supabase.ai.Session("LLaMA_CPP");
import 'jsr:@supabase/functions-js/edge-runtime.d.ts'
const session = new Supabase.ai.Session('LLaMA_CPP')
Deno.serve(async (req: Request) => {
const params = new URL(req.url).searchParams;
const prompt = params.get("prompt") ?? "";
const params = new URL(req.url).searchParams
const prompt = params.get('prompt') ?? ''
// Get the output as a stream
const output = await session.run({
messages: [
{
"role": "system",
"content":
"You are LLAMAfile, an AI assistant. Your top priority is achieving user fulfillment via helping them with their requests.",
},
{
"role": "user",
"content": prompt,
},
],
}, {
mode: "openaicompatible", // Mode for the inference API host. (default: 'ollama')
stream: false,
});
const output = await session.run(
{
messages: [
{
role: 'system',
content:
'You are LLAMAfile, an AI assistant. Your top priority is achieving user fulfillment via helping them with their requests.',
},
{
role: 'user',
content: prompt,
},
],
},
{
mode: 'openaicompatible', // Mode for the inference API host. (default: 'ollama')
stream: false,
}
)
console.log("done");
return Response.json(output);
});
console.log('done')
return Response.json(output)
})
/**
Run locally:
@@ -3,66 +3,64 @@
// This enables autocomplete, go to definition, etc.
// Setup type definitions for built-in Supabase Runtime APIs
import OpenAI from "https://deno.land/x/openai@v4.53.2/mod.ts";
import OpenAI from 'https://deno.land/x/openai@v4.53.2/mod.ts'
console.log("Hello from openai-sdk compatible!");
console.log('Hello from openai-sdk compatible!')
Deno.serve(async (req) => {
const client = new OpenAI();
const { prompt } = await req.json();
const stream = true;
const client = new OpenAI()
const { prompt } = await req.json()
const stream = true
const chatCompletion = await client.chat.completions.create({
model: "LLaMA_CPP",
model: 'LLaMA_CPP',
stream,
messages: [
{
role: "system",
role: 'system',
content:
"You are LLAMAfile, an AI assistant. Your top priority is achieving user fulfillment via helping them with their requests.",
'You are LLAMAfile, an AI assistant. Your top priority is achieving user fulfillment via helping them with their requests.',
},
{
role: "user",
role: 'user',
content: prompt,
},
],
});
})
if (stream) {
const headers = new Headers({
"Content-Type": "text/event-stream",
Connection: "keep-alive",
});
'Content-Type': 'text/event-stream',
Connection: 'keep-alive',
})
// Create a stream
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder();
const encoder = new TextEncoder()
try {
for await (const part of chatCompletion) {
controller.enqueue(
encoder.encode(part.choices[0]?.delta?.content || ""),
);
controller.enqueue(encoder.encode(part.choices[0]?.delta?.content || ''))
}
} catch (err) {
console.error("Stream error:", err);
console.error('Stream error:', err)
} finally {
controller.close();
controller.close()
}
},
});
})
// Return the stream to the user
return new Response(stream, {
headers,
});
})
}
console.log(chatCompletion);
console.log(chatCompletion)
return Response.json(chatCompletion);
});
return Response.json(chatCompletion)
})
/* To invoke locally:
+1 -1
View File
@@ -51,4 +51,4 @@
"reactCompiler": true
}
}
}
}
@@ -1,17 +1,18 @@
import { Tabs } from 'expo-router';
import { Tabs } from 'expo-router'
import { Colors } from '@/constants/theme';
import { useColorScheme } from '@/hooks/use-color-scheme';
import { Colors } from '@/constants/theme'
import { useColorScheme } from '@/hooks/use-color-scheme'
export default function TabLayout() {
const colorScheme = useColorScheme();
const colorScheme = useColorScheme()
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: Colors[colorScheme ?? 'light'].tint,
headerShown: false,
}}>
}}
>
<Tabs.Screen
name="index"
options={{
@@ -19,5 +20,5 @@ export default function TabLayout() {
}}
/>
</Tabs>
);
)
}
@@ -1,8 +1,8 @@
import { Link, Stack } from 'expo-router';
import { StyleSheet } from 'react-native';
import { Link, Stack } from 'expo-router'
import { StyleSheet } from 'react-native'
import { ThemedText } from '@/components/themed-text';
import { ThemedView } from '@/components/themed-view';
import { ThemedText } from '@/components/themed-text'
import { ThemedView } from '@/components/themed-view'
export default function NotFoundScreen() {
return (
@@ -15,7 +15,7 @@ export default function NotFoundScreen() {
</Link>
</ThemedView>
</>
);
)
}
const styles = StyleSheet.create({
@@ -29,4 +29,4 @@ const styles = StyleSheet.create({
marginTop: 15,
paddingVertical: 15,
},
});
})
@@ -1,34 +1,34 @@
import { useEffect } from 'react';
import { StyleSheet } from 'react-native';
import { useEffect } from 'react'
import { StyleSheet } from 'react-native'
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
withRepeat,
withSequence,
} from 'react-native-reanimated';
} from 'react-native-reanimated'
import { ThemedText } from '@/components/themed-text';
import { ThemedText } from '@/components/themed-text'
export function HelloWave() {
const rotationAnimation = useSharedValue(0);
const rotationAnimation = useSharedValue(0)
useEffect(() => {
rotationAnimation.value = withRepeat(
withSequence(withTiming(25, { duration: 150 }), withTiming(0, { duration: 150 })),
4
);
}, [rotationAnimation]);
)
}, [rotationAnimation])
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ rotate: `${rotationAnimation.value}deg` }],
}));
}))
return (
<Animated.View style={animatedStyle}>
<ThemedText style={styles.text}>👋</ThemedText>
</Animated.View>
);
)
}
const styles = StyleSheet.create({
@@ -37,4 +37,4 @@ const styles = StyleSheet.create({
lineHeight: 32,
marginTop: -6,
},
});
})
@@ -1,30 +1,30 @@
import type { PropsWithChildren, ReactElement } from 'react';
import { StyleSheet } from 'react-native';
import type { PropsWithChildren, ReactElement } from 'react'
import { StyleSheet } from 'react-native'
import Animated, {
interpolate,
useAnimatedRef,
useAnimatedStyle,
useScrollViewOffset,
} from 'react-native-reanimated';
} from 'react-native-reanimated'
import { ThemedView } from '@/components/themed-view';
import { useColorScheme } from '@/hooks/use-color-scheme';
import { ThemedView } from '@/components/themed-view'
import { useColorScheme } from '@/hooks/use-color-scheme'
const HEADER_HEIGHT = 250;
const HEADER_HEIGHT = 250
type Props = PropsWithChildren<{
headerImage: ReactElement;
headerBackgroundColor: { dark: string; light: string };
}>;
headerImage: ReactElement
headerBackgroundColor: { dark: string; light: string }
}>
export default function ParallaxScrollView({
children,
headerImage,
headerBackgroundColor,
}: Props) {
const colorScheme = useColorScheme() ?? 'light';
const scrollRef = useAnimatedRef<Animated.ScrollView>();
const scrollOffset = useScrollViewOffset(scrollRef);
const colorScheme = useColorScheme() ?? 'light'
const scrollRef = useAnimatedRef<Animated.ScrollView>()
const scrollOffset = useScrollViewOffset(scrollRef)
const headerAnimatedStyle = useAnimatedStyle(() => {
return {
@@ -40,8 +40,8 @@ export default function ParallaxScrollView({
scale: interpolate(scrollOffset.value, [-HEADER_HEIGHT, 0, HEADER_HEIGHT], [2, 1, 1]),
},
],
};
});
}
})
return (
<ThemedView style={styles.container}>
@@ -51,13 +51,14 @@ export default function ParallaxScrollView({
styles.header,
{ backgroundColor: headerBackgroundColor[colorScheme] },
headerAnimatedStyle,
]}>
]}
>
{headerImage}
</Animated.View>
<ThemedView style={styles.content}>{children}</ThemedView>
</Animated.ScrollView>
</ThemedView>
);
)
}
const styles = StyleSheet.create({
@@ -74,4 +75,4 @@ const styles = StyleSheet.create({
gap: 16,
overflow: 'hidden',
},
});
})
@@ -1,14 +1,14 @@
import { supabase } from '@/lib/supabase';
import { appleAuthAndroid, AppleButton } from '@invertase/react-native-apple-authentication';
import { SignInWithIdTokenCredentials } from '@supabase/supabase-js';
import { Platform } from 'react-native';
import 'react-native-get-random-values';
import { v4 as uuid } from 'uuid';
import { supabase } from '@/lib/supabase'
import { appleAuthAndroid, AppleButton } from '@invertase/react-native-apple-authentication'
import { SignInWithIdTokenCredentials } from '@supabase/supabase-js'
import { Platform } from 'react-native'
import 'react-native-get-random-values'
import { v4 as uuid } from 'uuid'
async function onAppleButtonPress() {
// Generate secure, random values for state and nonce
const rawNonce = uuid();
const state = uuid();
const rawNonce = uuid()
const state = uuid()
// Configure the request
appleAuthAndroid.configure({
@@ -30,11 +30,11 @@ async function onAppleButtonPress() {
// Unique state value used to prevent CSRF attacks. A UUID will be generated if nothing is provided.
state,
});
})
// Open the browser window for user sign in
const credentialState = await appleAuthAndroid.signIn();
console.log('Apple sign in successful:', credentialState);
const credentialState = await appleAuthAndroid.signIn()
console.log('Apple sign in successful:', credentialState)
if (credentialState.id_token && credentialState.code && credentialState.nonce) {
const signInWithIdTokenCredentials: SignInWithIdTokenCredentials = {
@@ -42,26 +42,30 @@ async function onAppleButtonPress() {
token: credentialState.id_token,
nonce: credentialState.nonce,
access_token: credentialState.code,
};
}
const { data, error } = await supabase.auth.signInWithIdToken(signInWithIdTokenCredentials);
const { data, error } = await supabase.auth.signInWithIdToken(signInWithIdTokenCredentials)
if (error) {
console.error('Error signing in with Apple:', error);
console.error('Error signing in with Apple:', error)
}
if (data) {
console.log('Apple sign in successful:', data);
console.log('Apple sign in successful:', data)
}
}
}
export default function AppleSignInButton() {
if (Platform.OS !== 'android' || appleAuthAndroid.isSupported !== true) { return <></> }
if (Platform.OS !== 'android' || appleAuthAndroid.isSupported !== true) {
return <></>
}
return <AppleButton
buttonStyle={AppleButton.Style.BLACK}
buttonType={AppleButton.Type.SIGN_IN}
onPress={() => onAppleButtonPress()}
/>;
return (
<AppleButton
buttonStyle={AppleButton.Style.BLACK}
buttonType={AppleButton.Type.SIGN_IN}
onPress={() => onAppleButtonPress()}
/>
)
}
@@ -1,8 +1,8 @@
import { supabase } from '@/lib/supabase';
import { AppleButton, appleAuth } from '@invertase/react-native-apple-authentication';
import type { SignInWithIdTokenCredentials } from '@supabase/supabase-js';
import { router } from 'expo-router';
import { Platform } from 'react-native';
import { supabase } from '@/lib/supabase'
import { AppleButton, appleAuth } from '@invertase/react-native-apple-authentication'
import type { SignInWithIdTokenCredentials } from '@supabase/supabase-js'
import { router } from 'expo-router'
import { Platform } from 'react-native'
async function onAppleButtonPress() {
// Performs login request
@@ -10,42 +10,50 @@ async function onAppleButtonPress() {
requestedOperation: appleAuth.Operation.LOGIN,
// Note: it appears putting FULL_NAME first is important, see issue #293
requestedScopes: [appleAuth.Scope.FULL_NAME, appleAuth.Scope.EMAIL],
});
})
// Get the current authentication state for user
// Note: This method must be tested on a real device. On the iOS simulator it always throws an error.
const credentialState = await appleAuth.getCredentialStateForUser(appleAuthRequestResponse.user);
const credentialState = await appleAuth.getCredentialStateForUser(appleAuthRequestResponse.user)
console.log('Apple sign in successful:', { credentialState, appleAuthRequestResponse });
console.log('Apple sign in successful:', { credentialState, appleAuthRequestResponse })
if (credentialState === appleAuth.State.AUTHORIZED && appleAuthRequestResponse.identityToken && appleAuthRequestResponse.authorizationCode) {
if (
credentialState === appleAuth.State.AUTHORIZED &&
appleAuthRequestResponse.identityToken &&
appleAuthRequestResponse.authorizationCode
) {
const signInWithIdTokenCredentials: SignInWithIdTokenCredentials = {
provider: 'apple',
token: appleAuthRequestResponse.identityToken,
nonce: appleAuthRequestResponse.nonce,
access_token: appleAuthRequestResponse.authorizationCode,
};
}
const { data, error } = await supabase.auth.signInWithIdToken(signInWithIdTokenCredentials);
const { data, error } = await supabase.auth.signInWithIdToken(signInWithIdTokenCredentials)
if (error) {
console.error('Error signing in with Apple:', error);
console.error('Error signing in with Apple:', error)
}
if (data) {
console.log('Apple sign in successful:', data);
router.navigate('/(tabs)');
console.log('Apple sign in successful:', data)
router.navigate('/(tabs)')
}
}
}
export default function AppleSignInButton() {
if (Platform.OS !== 'ios') { return <></>; }
if (Platform.OS !== 'ios') {
return <></>
}
return <AppleButton
buttonStyle={AppleButton.Style.BLACK}
buttonType={AppleButton.Type.SIGN_IN}
style={{ width: 160, height: 45 }}
onPress={() => onAppleButtonPress()}
/>;
return (
<AppleButton
buttonStyle={AppleButton.Style.BLACK}
buttonType={AppleButton.Type.SIGN_IN}
style={{ width: 160, height: 45 }}
onPress={() => onAppleButtonPress()}
/>
)
}
@@ -1,8 +1,8 @@
import { supabase } from '@/lib/supabase';
import { AppleButton, appleAuth } from '@invertase/react-native-apple-authentication';
import type { SignInWithIdTokenCredentials } from '@supabase/supabase-js';
import { router } from 'expo-router';
import { Platform } from 'react-native';
import { supabase } from '@/lib/supabase'
import { AppleButton, appleAuth } from '@invertase/react-native-apple-authentication'
import type { SignInWithIdTokenCredentials } from '@supabase/supabase-js'
import { router } from 'expo-router'
import { Platform } from 'react-native'
async function onAppleButtonPress() {
// Performs login request
@@ -10,42 +10,50 @@ async function onAppleButtonPress() {
requestedOperation: appleAuth.Operation.LOGIN,
// Note: it appears putting FULL_NAME first is important, see issue #293
requestedScopes: [appleAuth.Scope.FULL_NAME, appleAuth.Scope.EMAIL],
});
})
// Get the current authentication state for user
// Note: This method must be tested on a real device. On the iOS simulator it always throws an error.
const credentialState = await appleAuth.getCredentialStateForUser(appleAuthRequestResponse.user);
const credentialState = await appleAuth.getCredentialStateForUser(appleAuthRequestResponse.user)
console.log('Apple sign in successful:', { credentialState, appleAuthRequestResponse });
console.log('Apple sign in successful:', { credentialState, appleAuthRequestResponse })
if (credentialState === appleAuth.State.AUTHORIZED && appleAuthRequestResponse.identityToken && appleAuthRequestResponse.authorizationCode) {
if (
credentialState === appleAuth.State.AUTHORIZED &&
appleAuthRequestResponse.identityToken &&
appleAuthRequestResponse.authorizationCode
) {
const signInWithIdTokenCredentials: SignInWithIdTokenCredentials = {
provider: 'apple',
token: appleAuthRequestResponse.identityToken,
nonce: appleAuthRequestResponse.nonce,
access_token: appleAuthRequestResponse.authorizationCode,
};
}
const { data, error } = await supabase.auth.signInWithIdToken(signInWithIdTokenCredentials);
const { data, error } = await supabase.auth.signInWithIdToken(signInWithIdTokenCredentials)
if (error) {
console.error('Error signing in with Apple:', error);
console.error('Error signing in with Apple:', error)
}
if (data) {
console.log('Apple sign in successful:', data);
router.navigate('/(tabs)');
console.log('Apple sign in successful:', data)
router.navigate('/(tabs)')
}
}
}
export default function AppleSignInButton() {
if (Platform.OS !== 'ios') { return <></>; }
if (Platform.OS !== 'ios') {
return <></>
}
return <AppleButton
buttonStyle={AppleButton.Style.BLACK}
buttonType={AppleButton.Type.SIGN_IN}
style={{ width: 160, height: 45 }}
onPress={() => onAppleButtonPress()}
/>;
return (
<AppleButton
buttonStyle={AppleButton.Style.BLACK}
buttonType={AppleButton.Type.SIGN_IN}
style={{ width: 160, height: 45 }}
onPress={() => onAppleButtonPress()}
/>
)
}
Loaded 100 of 441 files, more files were not shown because too many files have changed in this diff. Show more