mirror of
https://github.com/supabase/supabase.git
synced 2026-10-17 07:09:07 +03:00
Merge branch 'master' of github.com:supabase/supabase
This commit is contained in:
commit
d50b3dc974
78 files changed
+2539
-837
No files matched your search
+7
-1
@@ -10,4 +10,10 @@
|
||||
/apps/www/public/images/blog @supabase/marketing
|
||||
/apps/www/lib/redirects.js
|
||||
|
||||
/docker/ @supabase/cli
|
||||
/docker/ @supabase/dev-workflows
|
||||
|
||||
/apps/studio/next.config.js @supabase/security
|
||||
/apps/studio/components/interfaces/Billing/Payment @supabase/security
|
||||
/apps/studio/components/interfaces/Organization/BillingSettings/ @supabase/security
|
||||
/apps/studio/components/interfaces/Organization/Documents/ @supabase/security
|
||||
/apps/studio/pages/new/index.tsx @supabase/security
|
||||
@@ -421,3 +421,47 @@ If you really need access to the key, please [open a support ticket](https://sup
|
||||
Yes, Supabase supports multi-tenant Single Sign-On (SSO) using SAML 2.0. While the dashboard displays only one SAML field, you can set up multiple SAML connections using the Supabase CLI.
|
||||
Each connection is assigned a unique `sso_provider_id`, which is included in the user's JWT and can be used in Row Level Security (RLS) policies. You can configure custom attribute mappings for each connection to include tenant-specific information, such as roles.
|
||||
This setup allows you to implement multi-tenant SSO for multiple clients or organizations within a single application. For example, if you have an app with multiple clients using different Azure Active Directories, you can create separate SAML connections for each and use the `sso_provider_id` to manage access and apply appropriate security policies.
|
||||
|
||||
### Is multi-subdomain SSO with SAML supported?
|
||||
|
||||
Yes, also referred to as [cross-origin authentication within the same site](https://web.dev/articles/same-site-same-origin). To redirect to a URL other than the [Site URL](https://supabase.com/docs/guides/auth/redirect-urls), following the SAML response from the IdP, the `redirectTo` option can be added to [`signInWithSSO`](https://supabase.com/docs/reference/javascript/auth-signinwithsso).
|
||||
|
||||
```ts
|
||||
const { data, error } = await supabase.auth.signInWithSSO({
|
||||
domain: 'company.com'
|
||||
options: {
|
||||
redirectTo: `https://app.company.com/callback`,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
When redirecting to a URL other than the Site URL, a `/callback` endpoint is necessary to process the auth code from the IdP and exchange it for a session. This assumes the [Supabase SSR client](https://supabase.com/docs/guides/auth/server-side/creating-a-client) has already been configured.
|
||||
|
||||
<Tabs scrollable size="small" type="underlined" defaultActiveId="sveltekit" queryGroup="framework">
|
||||
|
||||
<TabPanel id="sveltekit" label="SvelteKit">
|
||||
|
||||
```ts
|
||||
import { error, redirect } from '@sveltejs/kit'
|
||||
import type { RequestHandler } from './$types'
|
||||
|
||||
export const GET: RequestHandler = async ({ url, locals }) => {
|
||||
const code = url.searchParams.get('code')
|
||||
|
||||
if (!code) {
|
||||
error(400, 'No authorization code provided')
|
||||
}
|
||||
|
||||
const { error: tokenExchangeError } = await locals.supabase.auth.exchangeCodeForSession(code)
|
||||
|
||||
if (tokenExchangeError) {
|
||||
error(400, 'Failed to exchange authorization code for session')
|
||||
}
|
||||
|
||||
redirect(303, '/')
|
||||
}
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
|
||||
</Tabs>
|
||||
@@ -102,6 +102,20 @@ const { data, error } = await supabase.from('posts').insert({
|
||||
})
|
||||
```
|
||||
|
||||
## Specific usage cases
|
||||
|
||||
### Queries with filtering
|
||||
|
||||
If you use an IVFFlat or HNSW index and naively filter the results based on the value of another column, you may get fewer rows returned than requested.
|
||||
|
||||
For example, the following query may return fewer than 5 rows, even if 5 corresponding rows exist in the database. This is because the embedding index may not return 5 rows matching the filter.
|
||||
|
||||
```
|
||||
SELECT * FROM items WHERE category_id = 123 ORDER BY embedding <-> '[3,1,2]' LIMIT 5;
|
||||
```
|
||||
|
||||
To get the exact number of requested rows, use [iterative search](https://github.com/pgvector/pgvector/?tab=readme-ov-file#iterative-index-scans) to continue scanning the index until enough results are found.
|
||||
|
||||
## More pgvector and Supabase resources
|
||||
|
||||
- [Supabase Clippy: ChatGPT for Supabase Docs](https://supabase.com/blog/chatgpt-supabase-docs)
|
||||
|
||||
@@ -3,6 +3,7 @@ id: 'function-background-tasks'
|
||||
title: 'Background Tasks'
|
||||
description: 'How to run background tasks in an Edge Function outside of the request handler'
|
||||
subtitle: 'How to run background tasks in an Edge Function outside of the request handler'
|
||||
tocVideo: 'rSKBTJxG9VA'
|
||||
---
|
||||
|
||||
Edge Function instances can process background tasks outside of the request handler. Background tasks are useful for asynchronous operations like uploading a file to Storage, updating a database, or sending events to a logging service. You can respond to the request immediately and leave the task running in the background.
|
||||
@@ -12,18 +13,18 @@ A background task can run until the Edge Function instance hits its [wall-clock
|
||||
Here's an example of defining a background task using a custom event.
|
||||
|
||||
```ts
|
||||
// define a custom event
|
||||
// Define a custom event type for the background task.
|
||||
class MyBackgroundTaskEvent extends Event {
|
||||
readonly taskPromise: Promise<string>
|
||||
readonly taskPromise: Promise<Response>
|
||||
|
||||
constructor(taskPromise) {
|
||||
constructor(taskPromise: Promise<Response>) {
|
||||
super('myBackgroundTask')
|
||||
this.taskPromise = taskPromise
|
||||
}
|
||||
}
|
||||
|
||||
globalThis.addEventListener('myBackgroundTask', async (event: MyBackgroundTaskEvent) => {
|
||||
const res = await event.taskPromise
|
||||
globalThis.addEventListener('myBackgroundTask', async (event) => {
|
||||
const res = await (event as MyBackgroundTaskEvent).taskPromise
|
||||
console.log(await res.json())
|
||||
})
|
||||
|
||||
|
||||
@@ -9,53 +9,7 @@ hideToc: true
|
||||
|
||||
<StepHikeCompact.Step step={1}>
|
||||
|
||||
<StepHikeCompact.Details title="Create a Supabase project">
|
||||
|
||||
Go to [database.new](https://database.new) and create a new Supabase project.
|
||||
|
||||
When your project is up and running, go to the [Table Editor](https://supabase.com/dashboard/project/_/editor), create a new table and insert some data.
|
||||
|
||||
Alternatively, you can run the following snippet in your project's [SQL Editor](https://supabase.com/dashboard/project/_/sql/new). This will create a `notes` table with some sample data.
|
||||
|
||||
</StepHikeCompact.Details>
|
||||
|
||||
<StepHikeCompact.Code>
|
||||
|
||||
```sql SQL_EDITOR
|
||||
-- Create the table
|
||||
create table notes (
|
||||
id bigint primary key generated always as identity,
|
||||
title text not null
|
||||
);
|
||||
|
||||
-- Insert some sample data into the table
|
||||
insert into notes (title)
|
||||
values
|
||||
('Today I created a Supabase project.'),
|
||||
('I added some data and queried it from Next.js.'),
|
||||
('It was awesome!');
|
||||
|
||||
alter table notes enable row level security;
|
||||
```
|
||||
|
||||
</StepHikeCompact.Code>
|
||||
|
||||
<StepHikeCompact.Details>
|
||||
|
||||
Make the data in your table publicly readable by adding an RLS policy:
|
||||
|
||||
</StepHikeCompact.Details>
|
||||
|
||||
<StepHikeCompact.Code>
|
||||
|
||||
```sql SQL_EDITOR
|
||||
create policy "public can read notes"
|
||||
on public.notes
|
||||
for select to anon
|
||||
using (true);
|
||||
```
|
||||
|
||||
</StepHikeCompact.Code>
|
||||
<QuickstartDbSetup />
|
||||
|
||||
</StepHikeCompact.Step>
|
||||
|
||||
@@ -104,9 +58,9 @@ hideToc: true
|
||||
<StepHikeCompact.Step step={4}>
|
||||
<StepHikeCompact.Details title="Query Supabase data from Next.js">
|
||||
|
||||
Create a new file at `app/notes/page.tsx` and populate with the following.
|
||||
Create a new file at `app/countries/page.tsx` and populate with the following.
|
||||
|
||||
This will select all the rows from the `notes` table in Supabase and render them on the page.
|
||||
This will select all the rows from the `countries` table in Supabase and render them on the page.
|
||||
|
||||
</StepHikeCompact.Details>
|
||||
|
||||
@@ -114,14 +68,14 @@ hideToc: true
|
||||
|
||||
<CH.Code className="min-h-[34rem]">
|
||||
|
||||
```ts app/notes/page.tsx
|
||||
```ts app/countries/page.tsx
|
||||
import { createClient } from '@/utils/supabase/server';
|
||||
|
||||
export default async function Notes() {
|
||||
export default async function Countries() {
|
||||
const supabase = await createClient();
|
||||
const { data: notes } = await supabase.from("notes").select();
|
||||
const { data: countries } = await supabase.from("countries").select();
|
||||
|
||||
return <pre>{JSON.stringify(notes, null, 2)}</pre>
|
||||
return <pre>{JSON.stringify(countries, null, 2)}</pre>
|
||||
}
|
||||
```
|
||||
|
||||
@@ -166,7 +120,7 @@ hideToc: true
|
||||
<StepHikeCompact.Step step={5}>
|
||||
<StepHikeCompact.Details title="Start the app">
|
||||
|
||||
Run the development server, go to http://localhost:3000/notes in a browser and you should see the list of notes.
|
||||
Run the development server, go to http://localhost:3000/countries in a browser and you should see the list of countries.
|
||||
|
||||
</StepHikeCompact.Details>
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ hideToc: true
|
||||
<StepHikeCompact.Code>
|
||||
|
||||
```bash Terminal
|
||||
npm create svelte@latest myapp
|
||||
npx sv create my-app
|
||||
```
|
||||
|
||||
</StepHikeCompact.Code>
|
||||
@@ -43,7 +43,7 @@ hideToc: true
|
||||
<StepHikeCompact.Code>
|
||||
|
||||
```bash Terminal
|
||||
cd myapp && npm install @supabase/supabase-js
|
||||
cd my-app && npm install @supabase/supabase-js
|
||||
```
|
||||
|
||||
</StepHikeCompact.Code>
|
||||
@@ -53,7 +53,7 @@ hideToc: true
|
||||
<StepHikeCompact.Step step={4}>
|
||||
<StepHikeCompact.Details title="Create the Supabase client">
|
||||
|
||||
Create a `/src/lib` directory in your SvelteKit app, create a file called `supabaseClient.js` and add the following code to initialize the Supabase client with your project URL and public API (anon) key:
|
||||
Create a `src/lib` directory in your SvelteKit app, create a file called `supabaseClient.js` and add the following code to initialize the Supabase client with your project URL and public API (anon) key:
|
||||
|
||||
<ProjectConfigVariables variable="url" />
|
||||
<ProjectConfigVariables variable="anonKey" />
|
||||
@@ -77,7 +77,7 @@ hideToc: true
|
||||
|
||||
Use `load` method to fetch the data server-side and display the query results as a simple list.
|
||||
|
||||
Create `+page.server.js` file in the `routes` directory with the following code.
|
||||
Create `+page.server.js` file in the `src/routes` directory with the following code.
|
||||
|
||||
</StepHikeCompact.Details>
|
||||
<StepHikeCompact.Code>
|
||||
@@ -98,7 +98,7 @@ hideToc: true
|
||||
|
||||
<StepHikeCompact.Details title="">
|
||||
|
||||
Replace the existing content in your `+page.svelte` file in the `routes` directory with the following code.
|
||||
Replace the existing content in your `+page.svelte` file in the `src/routes` directory with the following code.
|
||||
|
||||
</StepHikeCompact.Details>
|
||||
<StepHikeCompact.Code>
|
||||
|
||||
@@ -149,10 +149,9 @@ const SupabaseGridLayout = (props: SupabaseGridProps) => {
|
||||
const { project } = useProjectContext()
|
||||
const { data, error, isSuccess, isError, isLoading, isRefetching } = useTableRowsQuery(
|
||||
{
|
||||
queryKey: [props.table.schema, props.table.name],
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
table: props.table,
|
||||
tableId: props.table.id,
|
||||
sorts,
|
||||
filters,
|
||||
page: snap.page,
|
||||
|
||||
@@ -23,7 +23,6 @@ export function SelectEditor<TRow, TSummaryRow = unknown>({
|
||||
const value = row[column.key as keyof TRow] as unknown as string
|
||||
|
||||
function onChange(event: any) {
|
||||
debugger
|
||||
if (!event.target.value || event.target.value == '') {
|
||||
onRowChange({ ...row, [column.key]: null }, true)
|
||||
} else {
|
||||
|
||||
@@ -44,7 +44,6 @@ const Pagination = () => {
|
||||
const [{ filter }] = useUrlState({ arrayKeys: ['filter'] })
|
||||
const filters = formatFilterURLParams(filter as string[])
|
||||
const page = snap.page
|
||||
const table = state.table ?? undefined
|
||||
|
||||
const roleImpersonationState = useRoleImpersonationStateSnapshot()
|
||||
const [isConfirmNextModalOpen, setIsConfirmNextModalOpen] = useState(false)
|
||||
@@ -60,10 +59,9 @@ const Pagination = () => {
|
||||
|
||||
const { data, isLoading, isSuccess, isError, isFetching } = useTableRowsCountQuery(
|
||||
{
|
||||
queryKey: [table?.schema, table?.name, 'count-estimate'],
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
table,
|
||||
tableId: id,
|
||||
filters,
|
||||
enforceExactCount: snap.enforceExactCount,
|
||||
impersonatedRole: roleImpersonationState.role,
|
||||
|
||||
@@ -239,10 +239,9 @@ const RowHeader = ({ table, sorts, filters }: RowHeaderProps) => {
|
||||
const [isExporting, setIsExporting] = useState(false)
|
||||
|
||||
const { data } = useTableRowsQuery({
|
||||
queryKey: [table.schema, table.name],
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
table,
|
||||
tableId: table.id,
|
||||
sorts,
|
||||
filters,
|
||||
page: snap.page,
|
||||
@@ -252,10 +251,9 @@ const RowHeader = ({ table, sorts, filters }: RowHeaderProps) => {
|
||||
|
||||
const { data: countData } = useTableRowsCountQuery(
|
||||
{
|
||||
queryKey: [table?.schema, table?.name, 'count-estimate'],
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
table,
|
||||
tableId: table.id,
|
||||
filters,
|
||||
enforceExactCount: snap.enforceExactCount,
|
||||
impersonatedRole: roleImpersonationState.role,
|
||||
|
||||
@@ -2,20 +2,18 @@ import { useQueryClient } from '@tanstack/react-query'
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import type { SupaTable } from 'components/grid/types'
|
||||
import { sqlKeys } from 'data/sql/keys'
|
||||
import { Entity } from 'data/table-editor/table-editor-types'
|
||||
import { tableRowKeys } from 'data/table-rows/keys'
|
||||
import { Button } from 'ui'
|
||||
|
||||
export type RefreshButtonProps = {
|
||||
table: Entity | SupaTable
|
||||
table: { id: number }
|
||||
isRefetching?: boolean
|
||||
}
|
||||
|
||||
const RefreshButton = ({ table, isRefetching }: RefreshButtonProps) => {
|
||||
const { ref } = useParams()
|
||||
const queryClient = useQueryClient()
|
||||
const queryKey = sqlKeys.query(ref, [table.schema, table.name])
|
||||
const queryKey = tableRowKeys.tableRowsAndCount(ref, table.id)
|
||||
|
||||
async function onClick() {
|
||||
await queryClient.invalidateQueries(queryKey)
|
||||
|
||||
@@ -8,7 +8,6 @@ import * as z from 'zod'
|
||||
import { useParams } from 'common'
|
||||
import { useUserUpdateMutation } from 'data/auth/user-update-mutation'
|
||||
import { User } from 'data/auth/users-infinite-query'
|
||||
import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query'
|
||||
import {
|
||||
Button,
|
||||
cn,
|
||||
@@ -34,7 +33,6 @@ interface BanUserModalProps {
|
||||
export const BanUserModal = ({ visible, user, onClose }: BanUserModalProps) => {
|
||||
const { ref: projectRef } = useParams()
|
||||
|
||||
const { data: settings } = useProjectSettingsV2Query({ projectRef })
|
||||
const { mutate: updateUser, isLoading: isBanningUser } = useUserUpdateMutation({
|
||||
onSuccess: (_, vars) => {
|
||||
const bannedUntil = dayjs()
|
||||
@@ -62,25 +60,15 @@ export const BanUserModal = ({ visible, user, onClose }: BanUserModalProps) => {
|
||||
const bannedUntil = dayjs().add(Number(value), unit).format('DD MMM YYYY HH:mm (ZZ)')
|
||||
|
||||
const onSubmit = (data: FormType) => {
|
||||
if (!settings) {
|
||||
return toast.error(`Failed to ban user: Error loading project config`)
|
||||
} else if (user.id === undefined) {
|
||||
if (projectRef === undefined) return console.error('Project ref is required')
|
||||
if (user.id === undefined) {
|
||||
return toast.error(`Failed to ban user: User ID not found`)
|
||||
}
|
||||
|
||||
const durationHours = data.unit === 'hours' ? Number(data.value) : Number(data.value) * 24
|
||||
const protocol = settings?.app_config?.protocol ?? 'https'
|
||||
const endpoint = settings?.app_config?.endpoint
|
||||
const { serviceKey } = getAPIKeys(settings)
|
||||
|
||||
if (!endpoint) return toast.error(`Failed to ban user: Unable to retrieve API endpoint`)
|
||||
if (!serviceKey?.api_key) return toast.error(`Failed to ban user: Unable to retrieve API key`)
|
||||
|
||||
updateUser({
|
||||
projectRef,
|
||||
protocol,
|
||||
endpoint,
|
||||
serviceApiKey: serviceKey.api_key,
|
||||
userId: user.id,
|
||||
banDuration: durationHours,
|
||||
})
|
||||
|
||||
@@ -7,7 +7,6 @@ import * as z from 'zod'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { useUserCreateMutation } from 'data/auth/user-create-mutation'
|
||||
import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query'
|
||||
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import {
|
||||
Button,
|
||||
@@ -39,13 +38,6 @@ const CreateUserFormSchema = z.object({
|
||||
|
||||
const CreateUserModal = ({ visible, setVisible }: CreateUserModalProps) => {
|
||||
const { ref: projectRef } = useParams()
|
||||
|
||||
const {
|
||||
data: settings,
|
||||
isLoading,
|
||||
isSuccess,
|
||||
} = useProjectSettingsV2Query({ projectRef }, { enabled: visible })
|
||||
|
||||
const canCreateUsers = useCheckPermissions(PermissionAction.AUTH_EXECUTE, 'create_user')
|
||||
|
||||
const { mutate: createUser, isLoading: isCreatingUser } = useUserCreateMutation({
|
||||
@@ -57,24 +49,9 @@ const CreateUserModal = ({ visible, setVisible }: CreateUserModalProps) => {
|
||||
})
|
||||
|
||||
const onCreateUser = async (values: any) => {
|
||||
if (!isSuccess) {
|
||||
return toast.error(`Failed to create user: Error loading project config`)
|
||||
}
|
||||
const protocol = settings?.app_config?.protocol ?? 'https'
|
||||
const endpoint = settings?.app_config?.endpoint
|
||||
const { serviceKey } = getAPIKeys(settings)
|
||||
if (!projectRef) return console.error('Project ref is required')
|
||||
|
||||
if (!endpoint) return toast.error(`Failed to create user: Unable to retrieve API endpoint`)
|
||||
if (!serviceKey?.api_key)
|
||||
return toast.error(`Failed to create user: Unable to retrieve API key`)
|
||||
|
||||
createUser({
|
||||
projectRef,
|
||||
protocol,
|
||||
endpoint,
|
||||
serviceApiKey: serviceKey.api_key,
|
||||
user: values,
|
||||
})
|
||||
createUser({ projectRef, user: values })
|
||||
}
|
||||
|
||||
const form = useForm<z.infer<typeof CreateUserFormSchema>>({
|
||||
@@ -115,7 +92,7 @@ const CreateUserModal = ({ visible, setVisible }: CreateUserModalProps) => {
|
||||
type="email"
|
||||
name="email"
|
||||
placeholder="user@example.com"
|
||||
disabled={isCreatingUser || isLoading}
|
||||
disabled={isCreatingUser}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
@@ -145,9 +122,9 @@ const CreateUserModal = ({ visible, setVisible }: CreateUserModalProps) => {
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder="••••••••"
|
||||
disabled={isCreatingUser || isLoading}
|
||||
disabled={isCreatingUser}
|
||||
className="pl-8"
|
||||
></Input_Shadcn_>
|
||||
/>
|
||||
</div>
|
||||
</FormControl_Shadcn_>
|
||||
<FormMessage_Shadcn_ />
|
||||
@@ -182,7 +159,7 @@ const CreateUserModal = ({ visible, setVisible }: CreateUserModalProps) => {
|
||||
size="small"
|
||||
htmlType="submit"
|
||||
loading={isCreatingUser}
|
||||
disabled={!canCreateUsers || isCreatingUser || isLoading}
|
||||
disabled={!canCreateUsers || isCreatingUser}
|
||||
>
|
||||
Create user
|
||||
</Button>
|
||||
|
||||
@@ -16,7 +16,6 @@ import { useUserSendMagicLinkMutation } from 'data/auth/user-send-magic-link-mut
|
||||
import { useUserSendOTPMutation } from 'data/auth/user-send-otp-mutation'
|
||||
import { useUserUpdateMutation } from 'data/auth/user-update-mutation'
|
||||
import { User } from 'data/auth/users-infinite-query'
|
||||
import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query'
|
||||
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
import { timeout } from 'lib/helpers'
|
||||
@@ -58,6 +57,7 @@ export const UserOverview = ({ user, onDeleteSuccess }: UserOverviewProps) => {
|
||||
}
|
||||
})
|
||||
|
||||
const canUpdateUser = useCheckPermissions(PermissionAction.AUTH_EXECUTE, '*')
|
||||
const canSendMagicLink = useCheckPermissions(PermissionAction.AUTH_EXECUTE, 'send_magic_link')
|
||||
const canSendRecovery = useCheckPermissions(PermissionAction.AUTH_EXECUTE, 'send_recovery')
|
||||
const canSendOtp = useCheckPermissions(PermissionAction.AUTH_EXECUTE, 'send_otp')
|
||||
@@ -76,7 +76,6 @@ export const UserOverview = ({ user, onDeleteSuccess }: UserOverviewProps) => {
|
||||
const [isDeleteFactorsModalOpen, setIsDeleteFactorsModalOpen] = useState(false)
|
||||
|
||||
const { data } = useAuthConfigQuery({ projectRef })
|
||||
const { data: settings } = useProjectSettingsV2Query({ projectRef })
|
||||
|
||||
const mailerOtpExpiry = data?.MAILER_OTP_EXP ?? 0
|
||||
const minutes = Math.floor(mailerOtpExpiry / 60)
|
||||
@@ -143,24 +142,13 @@ export const UserOverview = ({ user, onDeleteSuccess }: UserOverviewProps) => {
|
||||
}
|
||||
|
||||
const handleUnban = () => {
|
||||
if (!settings) {
|
||||
return toast.error(`Failed to ban user: Error loading project config`)
|
||||
} else if (user.id === undefined) {
|
||||
if (projectRef === undefined) return console.error('Proejct ref is required')
|
||||
if (user.id === undefined) {
|
||||
return toast.error(`Failed to ban user: User ID not found`)
|
||||
}
|
||||
|
||||
const protocol = settings?.app_config?.protocol ?? 'https'
|
||||
const endpoint = settings?.app_config?.endpoint
|
||||
const { serviceKey } = getAPIKeys(settings)
|
||||
|
||||
if (!endpoint) return toast.error(`Failed to unban user: Unable to retrieve API endpoint`)
|
||||
if (!serviceKey?.api_key) return toast.error(`Failed to unban user: Unable to retrieve API key`)
|
||||
|
||||
updateUser({
|
||||
projectRef,
|
||||
protocol,
|
||||
endpoint,
|
||||
serviceApiKey: serviceKey.api_key,
|
||||
userId: user.id,
|
||||
banDuration: 'none',
|
||||
})
|
||||
@@ -386,7 +374,7 @@ export const UserOverview = ({ user, onDeleteSuccess }: UserOverviewProps) => {
|
||||
button={{
|
||||
icon: <Ban />,
|
||||
text: isBanned ? 'Unban user' : 'Ban user',
|
||||
disabled: !canRemoveMFAFactors,
|
||||
disabled: !canUpdateUser,
|
||||
onClick: () => {
|
||||
if (isBanned) {
|
||||
setIsUnbanModalOpen(true)
|
||||
|
||||
@@ -31,7 +31,7 @@ export const NoProjectsOnPaidOrgInfo = ({ organization }: NoProjectsOnPaidOrgInf
|
||||
<Markdown
|
||||
className="!max-w-full"
|
||||
content={`The monthly fees for the paid plan still apply. To cancel your subscription, head over to
|
||||
your [organization billing settings](/org/${organization?.slug}/billing).`}
|
||||
your [organization billing settings](/dashboard/org/${organization?.slug}/billing).`}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -17,7 +17,9 @@ import {
|
||||
Button,
|
||||
Form_Shadcn_,
|
||||
FormControl_Shadcn_,
|
||||
FormDescription_Shadcn_,
|
||||
FormField_Shadcn_,
|
||||
FormLabel_Shadcn_,
|
||||
Input_Shadcn_,
|
||||
RadioGroupStacked,
|
||||
RadioGroupStackedItem,
|
||||
@@ -43,6 +45,7 @@ import { SqlFunctionSection } from './SqlFunctionSection'
|
||||
import { SqlSnippetSection } from './SqlSnippetSection'
|
||||
import EnableExtensionModal from 'components/interfaces/Database/Extensions/EnableExtensionModal'
|
||||
import { checkDomainOfScale } from 'recharts/types/util/ChartUtils'
|
||||
import { InfoIcon } from 'lucide-react'
|
||||
|
||||
export interface CreateCronJobSheetProps {
|
||||
selectedCronJob?: Pick<CronJob, 'jobname' | 'schedule' | 'active' | 'command'>
|
||||
@@ -229,10 +232,14 @@ export const CreateCronJobSheet = ({
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout label="Name" layout="vertical" className="gap-1">
|
||||
<FormItemLayout label="Name" layout="vertical" className="gap-1 relative">
|
||||
<FormControl_Shadcn_>
|
||||
<Input_Shadcn_ {...field} />
|
||||
<Input_Shadcn_ {...field} disabled={isEditing} />
|
||||
</FormControl_Shadcn_>
|
||||
|
||||
<FormLabel_Shadcn_ className="text-foreground-lighter text-xs absolute top-0 right-0 ">
|
||||
Cron jobs cannot be renamed once created
|
||||
</FormLabel_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -2,10 +2,17 @@ import { Rows2, Rows3, Rows4 } from 'lucide-react'
|
||||
|
||||
export const QUEUE_TYPES = [
|
||||
{
|
||||
value: 'normal',
|
||||
value: 'basic',
|
||||
icon: <Rows4 strokeWidth={1} />,
|
||||
label: 'Normal queue',
|
||||
description: 'Create a normal queue.',
|
||||
label: 'Basic queue',
|
||||
description: 'Create a basic queue.',
|
||||
},
|
||||
{
|
||||
value: 'unlogged',
|
||||
icon: <Rows2 strokeWidth={1} />,
|
||||
label: 'Unlogged queue',
|
||||
description:
|
||||
'Creates an unlogged queue which loses all data on database restart. Can be useful when write throughput is more important than durability.',
|
||||
},
|
||||
{
|
||||
value: 'partitioned',
|
||||
@@ -13,11 +20,4 @@ export const QUEUE_TYPES = [
|
||||
label: 'Partitioned queue',
|
||||
description: 'Create a partitioned queue which is optimized for large amount of messages',
|
||||
},
|
||||
|
||||
{
|
||||
value: 'unlogged',
|
||||
icon: <Rows2 strokeWidth={1} />,
|
||||
label: 'Unlogged queue',
|
||||
description: 'Creates an unlogged queue which loses all data on database restart.',
|
||||
},
|
||||
] as const
|
||||
@@ -1,12 +1,11 @@
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
|
||||
import EnableExtensionModal from 'components/interfaces/Database/Extensions/EnableExtensionModal'
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState'
|
||||
import { DocsButton } from 'components/ui/DocsButton'
|
||||
import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query'
|
||||
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { Button } from 'ui'
|
||||
@@ -65,15 +64,7 @@ export const QueuesDisabledState = () => {
|
||||
</Tooltip.Portal>
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
<Button asChild type="text" icon={<ExternalLink />}>
|
||||
<Link
|
||||
href="https://supabase.com/docs/guides/database/extensions/pgmq"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</Link>
|
||||
</Button>
|
||||
<DocsButton href="https://supabase.com/docs/guides/database/extensions/pgmq" />
|
||||
</div>
|
||||
</ProductEmptyState>
|
||||
</div>
|
||||
|
||||
@@ -69,10 +69,10 @@ export const QueuesListing = () => {
|
||||
{queues.length === 0 ? (
|
||||
<div
|
||||
className={
|
||||
'border rounded border-default px-20 py-16 flex flex-col items-center justify-center space-y-4'
|
||||
'border rounded border-default px-20 py-16 flex flex-col items-center justify-center space-y-4 border-dashed'
|
||||
}
|
||||
>
|
||||
<p className="text-sm text-foreground-light">No queues created yet</p>
|
||||
<p className="text-sm text-foreground">No queues created yet</p>
|
||||
<Button onClick={() => setCreateQueueSheetShown(true)}>Add a new queue</Button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -98,9 +98,12 @@ export const QueuesListing = () => {
|
||||
<Table.th key="arguments" className="table-cell">
|
||||
Type
|
||||
</Table.th>
|
||||
<Table.th key="created_at" className="table-cell">
|
||||
<Table.th key="created_at" className="table-cell w-60">
|
||||
Created at
|
||||
</Table.th>
|
||||
<Table.th key="queue_size" className="table-cell">
|
||||
<div className="flex justify-center">Size</div>
|
||||
</Table.th>
|
||||
<Table.th key="buttons" className="table-cell"></Table.th>
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -1,22 +1,76 @@
|
||||
import dayjs from 'dayjs'
|
||||
import { includes, sortBy } from 'lodash'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { ChevronRight, Loader2 } from 'lucide-react'
|
||||
import { useRouter } from 'next/router'
|
||||
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import Table from 'components/to-be-cleaned/Table'
|
||||
import { useQueuesMetricsQuery } from 'data/database-queues/database-queues-metrics-query'
|
||||
import { PostgresQueue } from 'data/database-queues/database-queues-query'
|
||||
import { DATETIME_FORMAT } from 'lib/constants'
|
||||
import { useRouter } from 'next/router'
|
||||
|
||||
interface QueuesRowsProps {
|
||||
queues: PostgresQueue[]
|
||||
filterString: string
|
||||
}
|
||||
|
||||
export const QueuesRows = ({ queues: fetchedQueues, filterString }: QueuesRowsProps) => {
|
||||
const QueueRow = ({ queue }: { queue: PostgresQueue }) => {
|
||||
const router = useRouter()
|
||||
const { project: selectedProject } = useProjectContext()
|
||||
|
||||
const { data: metrics, isLoading } = useQueuesMetricsQuery(
|
||||
{
|
||||
queueName: queue.queue_name,
|
||||
projectRef: selectedProject?.ref,
|
||||
connectionString: selectedProject?.connectionString,
|
||||
},
|
||||
{
|
||||
staleTime: 30 * 1000, // 60 seconds, talk with Oli whether this is ok to call every minute
|
||||
}
|
||||
)
|
||||
|
||||
const type = queue.is_partitioned ? 'Partitioned' : queue.is_unlogged ? 'Unlogged' : 'Basic'
|
||||
|
||||
return (
|
||||
<Table.tr
|
||||
key={queue.queue_name}
|
||||
onClick={() => {
|
||||
router.push(`/project/${selectedProject?.ref}/integrations/queues/${queue.queue_name}`)
|
||||
}}
|
||||
className="hover:"
|
||||
>
|
||||
<Table.td className="truncate">
|
||||
<p title={queue.queue_name}>{queue.queue_name}</p>
|
||||
</Table.td>
|
||||
<Table.td className="table-cell overflow-auto">
|
||||
<p title={type.toLocaleLowerCase()} className="truncate">
|
||||
{type}
|
||||
</p>
|
||||
</Table.td>
|
||||
<Table.td className="table-cell">
|
||||
<p title={queue.created_at}>{dayjs(queue.created_at).format(DATETIME_FORMAT)}</p>
|
||||
</Table.td>
|
||||
<Table.td className="table-cell">
|
||||
<div className="flex justify-center">
|
||||
{isLoading ? (
|
||||
<Loader2 className="animate-spin" size={16} />
|
||||
) : (
|
||||
<p>
|
||||
{metrics?.queue_length} {metrics?.method === 'estimated' ? '(Approximate)' : null}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Table.td>
|
||||
<Table.td>
|
||||
<div className="flex items-center justify-end">
|
||||
<ChevronRight size="18" />
|
||||
</div>
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
)
|
||||
}
|
||||
|
||||
export const QueuesRows = ({ queues: fetchedQueues, filterString }: QueuesRowsProps) => {
|
||||
const filteredQueues = fetchedQueues.filter((x) =>
|
||||
includes(x.queue_name.toLowerCase(), filterString.toLowerCase())
|
||||
)
|
||||
@@ -25,7 +79,7 @@ export const QueuesRows = ({ queues: fetchedQueues, filterString }: QueuesRowsPr
|
||||
if (queues.length === 0 && filterString.length > 0) {
|
||||
return (
|
||||
<Table.tr>
|
||||
<Table.td colSpan={4}>
|
||||
<Table.td colSpan={5}>
|
||||
<p className="text-sm text-foreground">No results found</p>
|
||||
<p className="text-sm text-foreground-light">
|
||||
Your search for "{filterString}" did not return any results
|
||||
@@ -37,34 +91,9 @@ export const QueuesRows = ({ queues: fetchedQueues, filterString }: QueuesRowsPr
|
||||
|
||||
return (
|
||||
<>
|
||||
{queues.map((q) => {
|
||||
const type = q.is_partitioned ? 'Partitioned' : q.is_unlogged ? 'Unlogged' : 'Regular'
|
||||
|
||||
return (
|
||||
<Table.tr
|
||||
key={q.queue_name}
|
||||
onClick={() => {
|
||||
router.push(`/project/${selectedProject?.ref}/integrations/queues/${q.queue_name}`)
|
||||
}}
|
||||
className="hover:"
|
||||
>
|
||||
<Table.td className="truncate">
|
||||
<p title={q.queue_name}>{q.queue_name}</p>
|
||||
</Table.td>
|
||||
<Table.td className="table-cell overflow-auto">
|
||||
<p title={type.toLocaleLowerCase()} className="truncate">
|
||||
{type}
|
||||
</p>
|
||||
</Table.td>
|
||||
<Table.td className="table-cell">
|
||||
<p title={q.created_at}>{dayjs(q.created_at).format(DATETIME_FORMAT)}</p>
|
||||
</Table.td>
|
||||
<Table.td className="flex items-center justify-end">
|
||||
<ChevronRight />
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
)
|
||||
})}
|
||||
{queues.map((q) => (
|
||||
<QueueRow key={q.queue_name} queue={q} />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
+105
-29
@@ -1,12 +1,15 @@
|
||||
import { useEscapeKeydown } from '@radix-ui/react-use-escape-keydown'
|
||||
import { isNil, noop } from 'lodash'
|
||||
import { Archive, X } from 'lucide-react'
|
||||
import { Archive, Clock12, Trash2, X } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { MonacoEditor } from 'components/grid/components/common/MonacoEditor'
|
||||
import { RowAction, RowData } from 'components/interfaces/Auth/Users/UserOverview'
|
||||
import { useDatabaseQueueMessageArchiveMutation } from 'data/database-queues/database-queue-messages-archive-mutation'
|
||||
import { useDatabaseQueueMessageDeleteMutation } from 'data/database-queues/database-queue-messages-delete-mutation'
|
||||
import { PostgresQueueMessage } from 'data/database-queues/database-queue-messages-infinite-query'
|
||||
import { useDatabaseQueueMessageReadMutation } from 'data/database-queues/database-queue-messages-read-mutation'
|
||||
import dayjs from 'dayjs'
|
||||
import { useSelectedProject } from 'hooks/misc/useSelectedProject'
|
||||
import { prettifyJSON } from 'lib/helpers'
|
||||
@@ -48,7 +51,25 @@ export const MessageDetailsPanel = ({
|
||||
const { name: queueName } = useParams()
|
||||
const project = useSelectedProject()
|
||||
|
||||
const { mutate, isLoading, isSuccess } = useDatabaseQueueMessageArchiveMutation()
|
||||
useEscapeKeydown(() => setSelectedMessage(null))
|
||||
|
||||
const {
|
||||
mutate: archiveMessage,
|
||||
isLoading: isLoadingArchive,
|
||||
isSuccess: isSuccessArchive,
|
||||
} = useDatabaseQueueMessageArchiveMutation()
|
||||
|
||||
const {
|
||||
mutate: readMessage,
|
||||
isLoading: isLoadingRead,
|
||||
isSuccess: isSuccessRead,
|
||||
} = useDatabaseQueueMessageReadMutation()
|
||||
|
||||
const {
|
||||
mutate: deleteMessage,
|
||||
isLoading: isLoadingDelete,
|
||||
isSuccess: isSuccessDelete,
|
||||
} = useDatabaseQueueMessageDeleteMutation()
|
||||
|
||||
const initialValue = JSON.stringify(selectedMessage?.message)
|
||||
const jsonString = prettifyJSON(!isNil(initialValue) ? tryFormatInitialValue(initialValue) : '')
|
||||
@@ -100,7 +121,7 @@ export const MessageDetailsPanel = ({
|
||||
<RowData property="Retries" value={`${selectedMessage.read_ct}`} />
|
||||
|
||||
<div>
|
||||
<h3 className="text-foreground-light pt-1">Payload</h3>
|
||||
<h3 className="text-foreground-light py-1">Payload</h3>
|
||||
<MonacoEditor
|
||||
key={selectedMessage.msg_id}
|
||||
onChange={noop}
|
||||
@@ -112,33 +133,88 @@ export const MessageDetailsPanel = ({
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex flex-col px-4 py-4">
|
||||
<div className="flex flex-col px-4 py-4 -space-y-1">
|
||||
{!selectedMessage.archived_at ? (
|
||||
<RowAction
|
||||
title="Archive message"
|
||||
description="The message will be marked as archived and hidden from future reads by consumers"
|
||||
button={{
|
||||
icon: <Archive />,
|
||||
text: 'Archive',
|
||||
isLoading: isLoading,
|
||||
onClick: () => {
|
||||
mutate({
|
||||
projectRef: project!.ref,
|
||||
connectionString: project?.connectionString,
|
||||
queryName: queueName!,
|
||||
messageId: selectedMessage.msg_id,
|
||||
})
|
||||
},
|
||||
}}
|
||||
success={
|
||||
isSuccess
|
||||
? {
|
||||
title: 'Archived',
|
||||
description: 'The message is archived successfully.',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<>
|
||||
<RowAction
|
||||
title="Postpone message"
|
||||
description="The message will be postponed and won't show up in reads for 60 seconds."
|
||||
button={{
|
||||
icon: <Clock12 />,
|
||||
text: 'Postpone',
|
||||
isLoading: isLoadingRead,
|
||||
onClick: () => {
|
||||
readMessage({
|
||||
projectRef: project!.ref,
|
||||
connectionString: project?.connectionString,
|
||||
queryName: queueName!,
|
||||
messageId: selectedMessage.msg_id,
|
||||
duration: 60,
|
||||
})
|
||||
},
|
||||
}}
|
||||
success={
|
||||
isSuccessRead
|
||||
? {
|
||||
title: 'Postponed',
|
||||
description: 'The message was postponed for 60 seconds.',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<RowAction
|
||||
title="Archive message"
|
||||
description="The message will be marked as archived and hidden from future reads by consumers. You can still access the message later."
|
||||
button={{
|
||||
icon: <Archive />,
|
||||
text: 'Archive',
|
||||
isLoading: isLoadingArchive,
|
||||
type: 'warning',
|
||||
onClick: () => {
|
||||
archiveMessage({
|
||||
projectRef: project!.ref,
|
||||
connectionString: project?.connectionString,
|
||||
queryName: queueName!,
|
||||
messageId: selectedMessage.msg_id,
|
||||
})
|
||||
},
|
||||
}}
|
||||
success={
|
||||
isSuccessArchive
|
||||
? {
|
||||
title: 'Archived',
|
||||
description: 'The message was archived successfully.',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<RowAction
|
||||
title="Delete message"
|
||||
description="The message cannot be recovered afterwards."
|
||||
button={{
|
||||
icon: <Trash2 />,
|
||||
text: 'Delete',
|
||||
type: 'danger',
|
||||
isLoading: isLoadingDelete,
|
||||
onClick: () => {
|
||||
deleteMessage({
|
||||
projectRef: project!.ref,
|
||||
connectionString: project?.connectionString,
|
||||
queueName: queueName!,
|
||||
messageId: selectedMessage.msg_id,
|
||||
})
|
||||
},
|
||||
}}
|
||||
success={
|
||||
isSuccessDelete
|
||||
? {
|
||||
title: 'Deleted',
|
||||
description: 'The message was deleted successfully.',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</TabsContent_Shadcn_>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useRouter } from 'next/router'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import { useDatabaseQueuePurgeMutation } from 'data/database-queues/database-queues-purge-mutation'
|
||||
import TextConfirmModal from 'ui-patterns/Dialogs/TextConfirmModal'
|
||||
|
||||
interface PurgeQueueProps {
|
||||
queueName: string
|
||||
visible: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const PurgeQueue = ({ queueName, visible, onClose }: PurgeQueueProps) => {
|
||||
const { project } = useProjectContext()
|
||||
const router = useRouter()
|
||||
|
||||
const { mutate: purgeDatabaseQueue, isLoading } = useDatabaseQueuePurgeMutation({
|
||||
onSuccess: () => {
|
||||
toast.success(`Successfully purged queue ${queueName}`)
|
||||
router.push(`/project/${project?.ref}/integrations/queues`)
|
||||
onClose()
|
||||
},
|
||||
})
|
||||
|
||||
async function handlePurge() {
|
||||
if (!project) return console.error('Project is required')
|
||||
|
||||
purgeDatabaseQueue({
|
||||
queueName: queueName,
|
||||
projectRef: project.ref,
|
||||
connectionString: project.connectionString,
|
||||
})
|
||||
}
|
||||
|
||||
if (!queueName) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<TextConfirmModal
|
||||
variant="warning"
|
||||
visible={visible}
|
||||
onCancel={() => onClose()}
|
||||
onConfirm={handlePurge}
|
||||
title="Purge this queue"
|
||||
loading={isLoading}
|
||||
confirmLabel={`Purge queue ${queueName}`}
|
||||
confirmPlaceholder="Type in name of queue"
|
||||
confirmString={queueName ?? 'Unknown'}
|
||||
text={
|
||||
<>
|
||||
<span>This will purge the queue</span>{' '}
|
||||
<span className="text-bold text-foreground">{queueName}</span>
|
||||
</>
|
||||
}
|
||||
alert={{
|
||||
title:
|
||||
"This action will delete all messages from the queue. They can't be recovered afterwards.",
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default PurgeQueue
|
||||
@@ -6,17 +6,18 @@ import { UIEvent, useMemo, useRef } from 'react'
|
||||
import DataGrid, { Column, DataGridHandle, Row } from 'react-data-grid'
|
||||
|
||||
import { PostgresQueueMessage } from 'data/database-queues/database-queue-messages-infinite-query'
|
||||
import { Badge, ResizableHandle, ResizablePanel, ResizablePanelGroup, cn } from 'ui'
|
||||
import { Badge, Button, ResizableHandle, ResizablePanel, ResizablePanelGroup, cn } from 'ui'
|
||||
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
import { DATE_FORMAT, MessageDetailsPanel } from './MessageDetailsPanel'
|
||||
|
||||
interface QueueDataGridProps {
|
||||
isLoading: boolean
|
||||
messages: PostgresQueueMessage[]
|
||||
showMessageModal: () => void
|
||||
fetchNextPage: () => void
|
||||
}
|
||||
|
||||
function isAtBottom({ currentTarget }: React.UIEvent<HTMLDivElement>): boolean {
|
||||
function isAtBottom({ currentTarget }: UIEvent<HTMLDivElement>): boolean {
|
||||
return currentTarget.scrollTop + 10 >= currentTarget.scrollHeight - currentTarget.clientHeight
|
||||
}
|
||||
|
||||
@@ -123,6 +124,7 @@ const columns = messagesCols.map((col) => {
|
||||
export const QueueMessagesDataGrid = ({
|
||||
isLoading,
|
||||
messages,
|
||||
showMessageModal,
|
||||
fetchNextPage,
|
||||
}: QueueDataGridProps) => {
|
||||
const gridRef = useRef<DataGridHandle>(null)
|
||||
@@ -188,6 +190,9 @@ export const QueueMessagesDataGrid = ({
|
||||
<p className="text-foreground-light">
|
||||
The selected queue doesn't have any messages.
|
||||
</p>
|
||||
<Button className="mt-2" onClick={() => showMessageModal()}>
|
||||
Add message
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { SubmitHandler, useForm } from 'react-hook-form'
|
||||
import z from 'zod'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import CodeEditor from 'components/ui/CodeEditor/CodeEditor'
|
||||
import { useDatabaseQueueMessageSendMutation } from 'data/database-queues/database-queue-messages-send-mutation'
|
||||
import { useEffect } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Form_Shadcn_, FormControl_Shadcn_, FormField_Shadcn_, Input, Modal } from 'ui'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
|
||||
interface SendMessageModalProps {
|
||||
visible: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const FormSchema = z.object({
|
||||
delay: z.coerce.number().int().gte(0).default(5),
|
||||
payload: z.string().refine(
|
||||
(val) => {
|
||||
try {
|
||||
JSON.parse(val)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
},
|
||||
{
|
||||
message: 'The payload should be a JSON object',
|
||||
}
|
||||
),
|
||||
})
|
||||
|
||||
export type SendMessageForm = z.infer<typeof FormSchema>
|
||||
|
||||
const FORM_ID = 'QUEUES_SEND_MESSAGE_FORM'
|
||||
|
||||
export const SendMessageModal = ({ visible, onClose }: SendMessageModalProps) => {
|
||||
const { name: queueName } = useParams()
|
||||
const { project } = useProjectContext()
|
||||
const form = useForm<SendMessageForm>({
|
||||
resolver: zodResolver(FormSchema),
|
||||
defaultValues: {
|
||||
delay: 1,
|
||||
payload: '{}',
|
||||
},
|
||||
})
|
||||
|
||||
const { isLoading, mutate } = useDatabaseQueueMessageSendMutation({
|
||||
onSuccess: () => {
|
||||
toast.success(`Successfully added a message to the queue.`)
|
||||
onClose()
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit: SubmitHandler<SendMessageForm> = (values) => {
|
||||
mutate({
|
||||
projectRef: project?.ref!,
|
||||
connectionString: project?.connectionString,
|
||||
queueName: queueName!,
|
||||
payload: values.payload,
|
||||
delay: values.delay,
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
form.reset({ delay: 1, payload: '{}' })
|
||||
}
|
||||
}, [visible])
|
||||
|
||||
return (
|
||||
<Modal
|
||||
size="medium"
|
||||
alignFooter="right"
|
||||
header="Add a message to the queue"
|
||||
visible={visible}
|
||||
loading={isLoading}
|
||||
onCancel={onClose}
|
||||
confirmText="Add"
|
||||
onConfirm={() => {
|
||||
const values = form.getValues()
|
||||
onSubmit(values)
|
||||
}}
|
||||
>
|
||||
<Modal.Content className="flex flex-col gap-y-4">
|
||||
<Form_Shadcn_ {...form}>
|
||||
<form
|
||||
id={FORM_ID}
|
||||
className="flex-grow overflow-auto gap-2 flex flex-col"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="delay"
|
||||
render={({ field: { ref, ...rest } }) => (
|
||||
<FormItemLayout
|
||||
label="Delay"
|
||||
layout="vertical"
|
||||
className="gap-1"
|
||||
description="Time in seconds before the message becomes available for reading."
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Input
|
||||
{...rest}
|
||||
type="number"
|
||||
placeholder="1"
|
||||
actions={<p className="text-foreground-light pr-2">sec</p>}
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="payload"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout label="Message payload" layout="vertical" className="gap-1">
|
||||
<FormControl_Shadcn_>
|
||||
<CodeEditor
|
||||
id="message-payload"
|
||||
language="json"
|
||||
className="!mb-0 h-32 overflow-hidden rounded border"
|
||||
onInputChange={(e: string | undefined) => field.onChange(e)}
|
||||
options={{ wordWrap: 'off', contextmenu: false }}
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form_Shadcn_>
|
||||
</Modal.Content>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
+7
-5
@@ -15,7 +15,6 @@ import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-que
|
||||
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
|
||||
import UpcomingInvoice from './UpcomingInvoice'
|
||||
import { Markdown } from 'components/interfaces/Markdown'
|
||||
|
||||
const BillingBreakdown = () => {
|
||||
const { slug: orgSlug } = useParams()
|
||||
@@ -70,10 +69,13 @@ const BillingBreakdown = () => {
|
||||
|
||||
{invoiceFeatureEnabled && (
|
||||
<>
|
||||
<Markdown
|
||||
content={`The table shows your upcoming invoice, excluding credits. This invoice will
|
||||
continue updating until the end of your billing period on ${billingCycleEnd.format('MMMM DD')}. See [usage page](/org/${orgSlug}/usage) for a more detailed usage breakdown.`}
|
||||
/>
|
||||
<p className="prose text-sm">
|
||||
The table shows your upcoming invoice, excluding credits. This invoice will
|
||||
continue updating until the end of your billing period on{' '}
|
||||
{billingCycleEnd.format('MMMM DD')}. See{' '}
|
||||
<Link href={`/org/${orgSlug}/usage`}>usage page</Link> for a more detailed usage
|
||||
breakdown.
|
||||
</p>
|
||||
|
||||
<UpcomingInvoice slug={orgSlug} />
|
||||
</>
|
||||
|
||||
+1
-2
@@ -86,8 +86,7 @@ const ColumnEditor = ({
|
||||
const { data: constraints } = useTableConstraintsQuery({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
schema: selectedTable?.schema,
|
||||
table: selectedTable?.name,
|
||||
id: selectedTable?.id,
|
||||
})
|
||||
const primaryKey = (constraints ?? []).find(
|
||||
(constraint) => constraint.type === CONSTRAINT_TYPE.PRIMARY_KEY_CONSTRAINT
|
||||
|
||||
+1
-2
@@ -59,10 +59,9 @@ const ForeignRowSelector = ({
|
||||
|
||||
const { data, isLoading, isSuccess, isError, isRefetching } = useTableRowsQuery(
|
||||
{
|
||||
queryKey: [schemaName, tableName],
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
table: supaTable,
|
||||
tableId: table?.id,
|
||||
sorts,
|
||||
filters,
|
||||
page,
|
||||
|
||||
+9
-27
@@ -10,10 +10,12 @@ import { useDatabasePublicationsQuery } from 'data/database-publications/databas
|
||||
import { useDatabasePublicationUpdateMutation } from 'data/database-publications/database-publications-update-mutation'
|
||||
import type { Constraint } from 'data/database/constraints-query'
|
||||
import type { ForeignKeyConstraint } from 'data/database/foreign-key-constraints-query'
|
||||
import { databaseKeys } from 'data/database/keys'
|
||||
import { entityTypeKeys } from 'data/entity-types/keys'
|
||||
import { sqlKeys } from 'data/sql/keys'
|
||||
import { tableEditorKeys } from 'data/table-editor/keys'
|
||||
import { isTableLike } from 'data/table-editor/table-editor-types'
|
||||
import { tableRowKeys } from 'data/table-rows/keys'
|
||||
import { useTableRowCreateMutation } from 'data/table-rows/table-row-create-mutation'
|
||||
import { useTableRowUpdateMutation } from 'data/table-rows/table-row-update-mutation'
|
||||
import { tableKeys } from 'data/tables/keys'
|
||||
@@ -258,17 +260,15 @@ const SidePanelEditor = ({
|
||||
queryClient.invalidateQueries(tableEditorKeys.tableEditor(project?.ref, selectedTable?.id)),
|
||||
queryClient.invalidateQueries(sqlKeys.query(project?.ref, ['foreign-key-constraints'])),
|
||||
queryClient.invalidateQueries(
|
||||
sqlKeys.query(project?.ref, [
|
||||
'table-definition',
|
||||
selectedTable!.schema,
|
||||
selectedTable!.name,
|
||||
])
|
||||
databaseKeys.tableDefinition(project?.ref, selectedTable?.id)
|
||||
),
|
||||
queryClient.invalidateQueries(entityTypeKeys.list(project?.ref)),
|
||||
])
|
||||
|
||||
// We need to invalidate tableRowsAndCount after tableEditor
|
||||
// to ensure the query sent is correct
|
||||
await queryClient.invalidateQueries(
|
||||
sqlKeys.query(project?.ref, [selectedTable!.schema, selectedTable!.name])
|
||||
tableRowKeys.tableRowsAndCount(project?.ref, selectedTable?.id)
|
||||
)
|
||||
|
||||
setIsEdited(false)
|
||||
@@ -472,22 +472,6 @@ const SidePanelEditor = ({
|
||||
if (hasError) {
|
||||
toast(`Table ${table.name} has been updated, but there were some errors`, { id: toastId })
|
||||
} else {
|
||||
queryClient.invalidateQueries(sqlKeys.query(project?.ref, ['foreign-key-constraints']))
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries(tableEditorKeys.tableEditor(project?.ref, table.id)),
|
||||
queryClient.invalidateQueries(
|
||||
sqlKeys.query(project?.ref, [selectedTable.schema, selectedTable.name])
|
||||
),
|
||||
queryClient.invalidateQueries(
|
||||
sqlKeys.query(project?.ref, [
|
||||
'table-definition',
|
||||
selectedTable.schema,
|
||||
selectedTable.name,
|
||||
])
|
||||
),
|
||||
queryClient.invalidateQueries(entityTypeKeys.list(project?.ref)),
|
||||
])
|
||||
|
||||
toast.success(`Successfully updated ${table.name}!`, { id: toastId })
|
||||
}
|
||||
}
|
||||
@@ -562,11 +546,9 @@ const SidePanelEditor = ({
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries(
|
||||
sqlKeys.query(project?.ref, [selectedTable!.schema, selectedTable!.name])
|
||||
),
|
||||
])
|
||||
await queryClient.invalidateQueries(
|
||||
tableRowKeys.tableRowsAndCount(project?.ref, selectedTable?.id)
|
||||
)
|
||||
toast.success(`Successfully imported ${rowCount} rows of data into ${selectedTable.name}`, {
|
||||
id: toastId,
|
||||
})
|
||||
|
||||
+34
-14
@@ -11,11 +11,15 @@ import { updateDatabaseColumn } from 'data/database-columns/database-column-upda
|
||||
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'
|
||||
import { prefetchEditorTablePage } from 'data/prefetchers/project.$ref.editor.$id'
|
||||
import { getQueryClient } from 'data/query-client'
|
||||
import { executeSql } from 'data/sql/execute-sql-query'
|
||||
import { sqlKeys } from 'data/sql/keys'
|
||||
import { getTableEditor } from 'data/table-editor/table-editor-query'
|
||||
import { tableEditorKeys } from 'data/table-editor/keys'
|
||||
import { prefetchTableEditor } from 'data/table-editor/table-editor-query'
|
||||
import { tableRowKeys } from 'data/table-rows/keys'
|
||||
import { tableKeys } from 'data/tables/keys'
|
||||
import { createTable as createTableMutation } from 'data/tables/table-create-mutation'
|
||||
import { deleteTable as deleteTableMutation } from 'data/tables/table-delete-mutation'
|
||||
@@ -211,6 +215,8 @@ export const createColumn = async ({
|
||||
selectedTable,
|
||||
primaryKey,
|
||||
foreignKeyRelations = [],
|
||||
skipSuccessMessage = false,
|
||||
toastId: _toastId,
|
||||
}: {
|
||||
projectRef: string
|
||||
connectionString: string | undefined
|
||||
@@ -218,8 +224,10 @@ export const createColumn = async ({
|
||||
selectedTable: PostgresTable
|
||||
primaryKey?: Constraint
|
||||
foreignKeyRelations?: ForeignKey[]
|
||||
skipSuccessMessage?: boolean
|
||||
toastId?: string | number
|
||||
}) => {
|
||||
const toastId = toast.loading(`Creating column "${payload.name}"...`)
|
||||
const toastId = _toastId ?? toast.loading(`Creating column "${payload.name}"...`)
|
||||
try {
|
||||
// Once pg-meta supports composite keys, we can remove this logic
|
||||
const { isPrimaryKey, ...formattedPayload } = payload
|
||||
@@ -265,7 +273,9 @@ export const createColumn = async ({
|
||||
})
|
||||
}
|
||||
|
||||
toast.success(`Successfully created column "${column.name}"`, { id: toastId })
|
||||
if (!skipSuccessMessage) {
|
||||
toast.success(`Successfully created column "${column.name}"`, { id: toastId })
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(`An error occurred while creating the column "${payload.name}"`, { id: toastId })
|
||||
return { error }
|
||||
@@ -454,6 +464,8 @@ export const createTable = async ({
|
||||
isRLSEnabled: boolean
|
||||
importContent?: ImportContent
|
||||
}) => {
|
||||
const queryClient = getQueryClient()
|
||||
|
||||
// Create the table first. Error may be thrown.
|
||||
const table = await createTableMutation({
|
||||
projectRef: projectRef,
|
||||
@@ -598,6 +610,13 @@ export const createTable = async ({
|
||||
}
|
||||
}
|
||||
|
||||
await prefetchEditorTablePage({
|
||||
queryClient,
|
||||
projectRef,
|
||||
connectionString,
|
||||
id: table.id,
|
||||
})
|
||||
|
||||
// Finally, return the created table
|
||||
return table
|
||||
} catch (error) {
|
||||
@@ -689,6 +708,8 @@ export const updateTable = async ({
|
||||
connectionString: connectionString,
|
||||
payload: columnPayload,
|
||||
selectedTable: updatedTable,
|
||||
skipSuccessMessage: true,
|
||||
toastId,
|
||||
})
|
||||
} else {
|
||||
const originalColumn = find(originalColumns, { id: column.id })
|
||||
@@ -696,16 +717,15 @@ export const updateTable = async ({
|
||||
const columnPayload = generateUpdateColumnPayload(originalColumn, updatedTable, column)
|
||||
if (!isEmpty(columnPayload)) {
|
||||
toast.loading(`Updating column ${column.name} from ${updatedTable.name}`, { id: toastId })
|
||||
const skipPKCreation = true
|
||||
const skipSuccessMessage = true
|
||||
|
||||
const res = await updateColumn({
|
||||
projectRef: projectRef,
|
||||
connectionString: connectionString,
|
||||
id: column.id,
|
||||
payload: columnPayload,
|
||||
selectedTable: updatedTable,
|
||||
skipPKCreation,
|
||||
skipSuccessMessage,
|
||||
skipPKCreation: true,
|
||||
skipSuccessMessage: true,
|
||||
})
|
||||
if (res?.error) {
|
||||
hasError = true
|
||||
@@ -739,18 +759,18 @@ export const updateTable = async ({
|
||||
const queryClient = getQueryClient()
|
||||
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries(tableEditorKeys.tableEditor(projectRef, table.id)),
|
||||
queryClient.invalidateQueries(sqlKeys.query(projectRef, ['foreign-key-constraints'])),
|
||||
// invalidate list of tables, as well as visible individual tables
|
||||
queryClient.invalidateQueries(['projects', projectRef, 'tables']),
|
||||
queryClient.invalidateQueries(sqlKeys.query(projectRef, [table.schema, table.name])),
|
||||
queryClient.invalidateQueries(
|
||||
sqlKeys.query(projectRef, ['table-definition', table.schema, table.name])
|
||||
),
|
||||
queryClient.invalidateQueries(databaseKeys.tableDefinition(projectRef, table.id)),
|
||||
queryClient.invalidateQueries(entityTypeKeys.list(projectRef)),
|
||||
])
|
||||
|
||||
// We need to invalidate tableRowsAndCount after tableEditor
|
||||
// to ensure the query sent is correct
|
||||
await queryClient.invalidateQueries(tableRowKeys.tableRowsAndCount(projectRef, table.id))
|
||||
|
||||
return {
|
||||
table: await getTableEditor({
|
||||
table: await prefetchTableEditor(queryClient, {
|
||||
projectRef,
|
||||
connectionString,
|
||||
id: table.id,
|
||||
|
||||
+1
-2
@@ -124,8 +124,7 @@ const TableEditor = ({
|
||||
const { data: constraints } = useTableConstraintsQuery({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
schema: table?.schema,
|
||||
table: table?.name,
|
||||
id: table?.id,
|
||||
})
|
||||
const primaryKey = (constraints ?? []).find(
|
||||
(constraint) => constraint.type === CONSTRAINT_TYPE.PRIMARY_KEY_CONSTRAINT
|
||||
|
||||
@@ -33,8 +33,7 @@ const TableDefinition = ({ entity }: TableDefinitionProps) => {
|
||||
|
||||
const viewResult = useViewDefinitionQuery(
|
||||
{
|
||||
schema: entity?.schema,
|
||||
name: entity?.name,
|
||||
id: entity?.id,
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
},
|
||||
@@ -45,8 +44,7 @@ const TableDefinition = ({ entity }: TableDefinitionProps) => {
|
||||
|
||||
const tableResult = useTableDefinitionQuery(
|
||||
{
|
||||
schema: entity?.schema,
|
||||
name: entity?.name,
|
||||
id: entity?.id,
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
},
|
||||
|
||||
@@ -9,14 +9,15 @@ import { SupabaseGrid } from 'components/grid/SupabaseGrid'
|
||||
import { parseSupaTable } from 'components/grid/SupabaseGrid.utils'
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import { DocsButton } from 'components/ui/DocsButton'
|
||||
import { sqlKeys } from 'data/sql/keys'
|
||||
import {
|
||||
Entity,
|
||||
isMaterializedView,
|
||||
isTableLike,
|
||||
isView,
|
||||
} from 'data/table-editor/table-editor-types'
|
||||
import { tableRowKeys } from 'data/table-rows/keys'
|
||||
import { useTableRowUpdateMutation } from 'data/table-rows/table-row-update-mutation'
|
||||
import { TableRowsData } from 'data/table-rows/table-rows-query'
|
||||
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import useLatest from 'hooks/misc/useLatest'
|
||||
import { useUrlState } from 'hooks/ui/useUrlState'
|
||||
@@ -61,20 +62,16 @@ const TableGridEditor = ({
|
||||
async onMutate({ projectRef, table, configuration, payload }) {
|
||||
const primaryKeyColumns = new Set(Object.keys(configuration.identifiers))
|
||||
|
||||
const queryKey = sqlKeys.query(projectRef, [
|
||||
table.schema,
|
||||
table.name,
|
||||
{ table: { name: table.name, schema: table.schema } },
|
||||
])
|
||||
const queryKey = tableRowKeys.tableRows(projectRef, { table: { id: table.id } })
|
||||
|
||||
await queryClient.cancelQueries(queryKey)
|
||||
|
||||
const previousRowsQueries = queryClient.getQueriesData<{ result: any[] }>(queryKey)
|
||||
const previousRowsQueries = queryClient.getQueriesData<TableRowsData>(queryKey)
|
||||
|
||||
queryClient.setQueriesData<{ result: any[] }>(queryKey, (old) => {
|
||||
queryClient.setQueriesData<TableRowsData>(queryKey, (old) => {
|
||||
return {
|
||||
result:
|
||||
old?.result.map((row) => {
|
||||
rows:
|
||||
old?.rows.map((row) => {
|
||||
// match primary keys
|
||||
if (
|
||||
Object.entries(row)
|
||||
|
||||
@@ -36,11 +36,10 @@ export const SQLEditorMenu = ({ onViewOngoingQueries }: SQLEditorMenuProps) => {
|
||||
const router = useRouter()
|
||||
const { profile } = useProfile()
|
||||
const project = useSelectedProject()
|
||||
const { ref, id: activeId } = useParams()
|
||||
const { ref } = useParams()
|
||||
|
||||
const snapV2 = useSqlEditorV2StateSnapshot()
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const [selectedQueries, setSelectedQueries] = useState<string[]>([])
|
||||
|
||||
const canCreateSQLSnippet = useCheckPermissions(PermissionAction.CREATE, 'user_content', {
|
||||
resource: { type: 'sql', owner_id: profile?.id },
|
||||
@@ -80,78 +79,76 @@ export const SQLEditorMenu = ({ onViewOngoingQueries }: SQLEditorMenuProps) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="h-full flex flex-col justify-between">
|
||||
<div className="mt-4 mb-2 flex flex-col gap-y-4">
|
||||
<div className="mx-4 flex items-center justify-between gap-x-2">
|
||||
<InnerSideBarFilters className="w-full p-0 gap-0">
|
||||
<InnerSideBarFilterSearchInput
|
||||
name="search-queries"
|
||||
placeholder="Search queries..."
|
||||
aria-labelledby="Search queries"
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
<div className="h-full flex flex-col justify-between">
|
||||
<div className="mt-4 mb-2 flex flex-col gap-y-4">
|
||||
<div className="mx-4 flex items-center justify-between gap-x-2">
|
||||
<InnerSideBarFilters className="w-full p-0 gap-0">
|
||||
<InnerSideBarFilterSearchInput
|
||||
name="search-queries"
|
||||
placeholder="Search queries..."
|
||||
aria-labelledby="Search queries"
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
>
|
||||
<InnerSideBarFilterSortDropdown
|
||||
value={snapV2.order}
|
||||
onValueChange={(value: any) => snapV2.setOrder(value)}
|
||||
>
|
||||
<InnerSideBarFilterSortDropdown
|
||||
value={snapV2.order}
|
||||
onValueChange={(value: any) => snapV2.setOrder(value)}
|
||||
>
|
||||
<InnerSideBarFilterSortDropdownItem key="name" value="name">
|
||||
Alphabetical
|
||||
</InnerSideBarFilterSortDropdownItem>
|
||||
<InnerSideBarFilterSortDropdownItem key="inserted_at" value="inserted_at">
|
||||
Created At
|
||||
</InnerSideBarFilterSortDropdownItem>
|
||||
</InnerSideBarFilterSortDropdown>
|
||||
</InnerSideBarFilterSearchInput>
|
||||
</InnerSideBarFilters>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="default"
|
||||
icon={<Plus className="text-foreground" />}
|
||||
className="w-[26px]"
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" side="bottom" className="w-48">
|
||||
<DropdownMenuItem className="gap-x-2" onClick={() => handleNewQuery()}>
|
||||
<FilePlus size={14} />
|
||||
Create a new snippet
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="gap-x-2" onClick={() => createNewFolder()}>
|
||||
<FolderPlus size={14} />
|
||||
Create a new folder
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div className="px-2">
|
||||
<InnerSideMenuItem
|
||||
title="Templates"
|
||||
isActive={router.asPath === `/project/${ref}/sql/templates`}
|
||||
href={`/project/${ref}/sql/templates`}
|
||||
>
|
||||
Templates
|
||||
</InnerSideMenuItem>
|
||||
<InnerSideMenuItem
|
||||
title="Quickstarts"
|
||||
isActive={router.asPath === `/project/${ref}/sql/quickstarts`}
|
||||
href={`/project/${ref}/sql/quickstarts`}
|
||||
>
|
||||
Quickstarts
|
||||
</InnerSideMenuItem>
|
||||
</div>
|
||||
|
||||
<SQLEditorNavV2 searchText={searchText} />
|
||||
<InnerSideBarFilterSortDropdownItem key="name" value="name">
|
||||
Alphabetical
|
||||
</InnerSideBarFilterSortDropdownItem>
|
||||
<InnerSideBarFilterSortDropdownItem key="inserted_at" value="inserted_at">
|
||||
Created At
|
||||
</InnerSideBarFilterSortDropdownItem>
|
||||
</InnerSideBarFilterSortDropdown>
|
||||
</InnerSideBarFilterSearchInput>
|
||||
</InnerSideBarFilters>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="default"
|
||||
icon={<Plus className="text-foreground" />}
|
||||
className="w-[26px]"
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" side="bottom" className="w-48">
|
||||
<DropdownMenuItem className="gap-x-2" onClick={() => handleNewQuery()}>
|
||||
<FilePlus size={14} />
|
||||
Create a new snippet
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="gap-x-2" onClick={() => createNewFolder()}>
|
||||
<FolderPlus size={14} />
|
||||
Create a new folder
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t sticky bottom-0 bg-studio">
|
||||
<Button block type="default" onClick={onViewOngoingQueries}>
|
||||
View running queries
|
||||
</Button>
|
||||
<div className="px-2">
|
||||
<InnerSideMenuItem
|
||||
title="Templates"
|
||||
isActive={router.asPath === `/project/${ref}/sql/templates`}
|
||||
href={`/project/${ref}/sql/templates`}
|
||||
>
|
||||
Templates
|
||||
</InnerSideMenuItem>
|
||||
<InnerSideMenuItem
|
||||
title="Quickstarts"
|
||||
isActive={router.asPath === `/project/${ref}/sql/quickstarts`}
|
||||
href={`/project/${ref}/sql/quickstarts`}
|
||||
>
|
||||
Quickstarts
|
||||
</InnerSideMenuItem>
|
||||
</div>
|
||||
|
||||
<SQLEditorNavV2 searchText={searchText} />
|
||||
</div>
|
||||
</>
|
||||
|
||||
<div className="p-4 border-t sticky bottom-0 bg-studio">
|
||||
<Button block type="default" onClick={onViewOngoingQueries}>
|
||||
View running queries
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChevronRight, Eye, EyeOffIcon, Heart, Unlock } from 'lucide-react'
|
||||
import { Eye, EyeOffIcon, Heart, Unlock } from 'lucide-react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
@@ -31,18 +31,15 @@ import {
|
||||
useSnippets,
|
||||
useSqlEditorV2StateSnapshot,
|
||||
} from 'state/sql-editor-v2'
|
||||
import { Separator, Skeleton, TreeView } from 'ui'
|
||||
import {
|
||||
AlertDescription_Shadcn_,
|
||||
AlertTitle_Shadcn_,
|
||||
Alert_Shadcn_,
|
||||
CollapsibleContent_Shadcn_,
|
||||
CollapsibleTrigger_Shadcn_,
|
||||
Collapsible_Shadcn_,
|
||||
Separator,
|
||||
TreeView,
|
||||
} from 'ui'
|
||||
InnerSideBarEmptyPanel,
|
||||
InnerSideMenuCollapsible,
|
||||
InnerSideMenuCollapsibleContent,
|
||||
InnerSideMenuCollapsibleTrigger,
|
||||
InnerSideMenuSeparator,
|
||||
} from 'ui-patterns'
|
||||
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
||||
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
import { ROOT_NODE, formatFolderResponseForTreeView } from './SQLEditorNav.utils'
|
||||
import { SQLEditorTreeViewItem } from './SQLEditorTreeViewItem'
|
||||
|
||||
@@ -75,11 +72,6 @@ export const SQLEditorNav = ({ searchText: _searchText }: SQLEditorNavProps) =>
|
||||
const [selectedSnippetToDownload, setSelectedSnippetToDownload] = useState<Snippet>()
|
||||
const [selectedFolderToDelete, setSelectedFolderToDelete] = useState<SnippetFolder>()
|
||||
|
||||
const COLLAPSIBLE_TRIGGER_CLASS_NAMES =
|
||||
'flex items-center gap-x-2 px-4 [&[data-state=open]>svg]:!rotate-90'
|
||||
const COLLAPSIBLE_ICON_CLASS_NAMES = 'text-foreground-light transition-transform duration-200'
|
||||
const COLLASIBLE_HEADER_CLASS_NAMES = 'text-foreground-light font-mono text-sm uppercase'
|
||||
|
||||
// =======================================================
|
||||
// [Joshen] Set up favorites, shared, and private snippets
|
||||
// =======================================================
|
||||
@@ -341,27 +333,24 @@ export const SQLEditorNav = ({ searchText: _searchText }: SQLEditorNavProps) =>
|
||||
|
||||
return (
|
||||
<>
|
||||
<Separator />
|
||||
|
||||
<InnerSideMenuSeparator />
|
||||
{((numProjectSnippets === 0 && searchText.length === 0) || numProjectSnippets > 0) && (
|
||||
<>
|
||||
<Collapsible_Shadcn_ open={showSharedSnippets} onOpenChange={setShowSharedSnippets}>
|
||||
<CollapsibleTrigger_Shadcn_ className={COLLAPSIBLE_TRIGGER_CLASS_NAMES}>
|
||||
<ChevronRight size={16} className={COLLAPSIBLE_ICON_CLASS_NAMES} />
|
||||
<span className={COLLASIBLE_HEADER_CLASS_NAMES}>
|
||||
Shared{numProjectSnippets > 0 && ` (${numProjectSnippets})`}
|
||||
</span>
|
||||
</CollapsibleTrigger_Shadcn_>
|
||||
<CollapsibleContent_Shadcn_ className="pt-2">
|
||||
<InnerSideMenuCollapsible
|
||||
open={showSharedSnippets}
|
||||
onOpenChange={setShowSharedSnippets}
|
||||
className="px-0"
|
||||
>
|
||||
<InnerSideMenuCollapsibleTrigger
|
||||
title={`Shared ${numProjectSnippets > 0 ? ` (${numProjectSnippets})` : ''}`}
|
||||
/>
|
||||
<InnerSideMenuCollapsibleContent className="group-data-[state=open]:pt-2">
|
||||
{numProjectSnippets === 0 ? (
|
||||
<div className="mx-4">
|
||||
<Alert_Shadcn_ className="p-3">
|
||||
<AlertTitle_Shadcn_ className="text-xs">No shared queries</AlertTitle_Shadcn_>
|
||||
<AlertDescription_Shadcn_ className="text-xs ">
|
||||
Share queries with your team by right-clicking on the query.
|
||||
</AlertDescription_Shadcn_>
|
||||
</Alert_Shadcn_>
|
||||
</div>
|
||||
<InnerSideBarEmptyPanel
|
||||
className="mx-2"
|
||||
title="No shared queries"
|
||||
description="Share queries with your team by right-clicking on the query."
|
||||
/>
|
||||
) : (
|
||||
<TreeView
|
||||
data={projectSnippetsTreeState}
|
||||
@@ -391,33 +380,35 @@ export const SQLEditorNav = ({ searchText: _searchText }: SQLEditorNavProps) =>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</CollapsibleContent_Shadcn_>
|
||||
</Collapsible_Shadcn_>
|
||||
<Separator />
|
||||
</InnerSideMenuCollapsibleContent>
|
||||
</InnerSideMenuCollapsible>
|
||||
<InnerSideMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{((numFavoriteSnippets === 0 && searchText.length === 0) || numFavoriteSnippets > 0) && (
|
||||
<>
|
||||
<Collapsible_Shadcn_ open={showFavouriteSnippets} onOpenChange={setShowFavouriteSnippets}>
|
||||
<CollapsibleTrigger_Shadcn_ className={COLLAPSIBLE_TRIGGER_CLASS_NAMES}>
|
||||
<ChevronRight size={16} className={COLLAPSIBLE_ICON_CLASS_NAMES} />
|
||||
<span className={COLLASIBLE_HEADER_CLASS_NAMES}>
|
||||
Favorites{numFavoriteSnippets > 0 && ` (${numFavoriteSnippets})`}
|
||||
</span>
|
||||
</CollapsibleTrigger_Shadcn_>
|
||||
<CollapsibleContent_Shadcn_ className="pt-2">
|
||||
<InnerSideMenuCollapsible
|
||||
className="px-0"
|
||||
open={showFavouriteSnippets}
|
||||
onOpenChange={setShowFavouriteSnippets}
|
||||
>
|
||||
<InnerSideMenuCollapsibleTrigger
|
||||
title={`Favorites ${numFavoriteSnippets > 0 ? ` (${numFavoriteSnippets})` : ''}`}
|
||||
/>
|
||||
<InnerSideMenuCollapsibleContent className="group-data-[state=open]:pt-2">
|
||||
{numFavoriteSnippets === 0 ? (
|
||||
<div className="mx-4">
|
||||
<Alert_Shadcn_ className="p-3">
|
||||
<AlertTitle_Shadcn_ className="text-xs">No favorite queries</AlertTitle_Shadcn_>
|
||||
<AlertDescription_Shadcn_ className="text-xs ">
|
||||
<InnerSideBarEmptyPanel
|
||||
title="No favorite queries"
|
||||
className="mx-2"
|
||||
description={
|
||||
<>
|
||||
Save a query to favorites for easy accessbility by clicking the{' '}
|
||||
<Heart size={12} className="inline-block relative align-center -top-[1px]" />{' '}
|
||||
icon.
|
||||
</AlertDescription_Shadcn_>
|
||||
</Alert_Shadcn_>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<TreeView
|
||||
data={favoritesTreeState}
|
||||
@@ -448,34 +439,51 @@ export const SQLEditorNav = ({ searchText: _searchText }: SQLEditorNavProps) =>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</CollapsibleContent_Shadcn_>
|
||||
</Collapsible_Shadcn_>
|
||||
<Separator />
|
||||
</InnerSideMenuCollapsibleContent>
|
||||
</InnerSideMenuCollapsible>
|
||||
<InnerSideMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Collapsible_Shadcn_ open={showPrivateSnippets} onOpenChange={setShowPrivateSnippets}>
|
||||
<CollapsibleTrigger_Shadcn_ className={COLLAPSIBLE_TRIGGER_CLASS_NAMES}>
|
||||
<ChevronRight size={16} className={COLLAPSIBLE_ICON_CLASS_NAMES} />
|
||||
<span className={COLLASIBLE_HEADER_CLASS_NAMES}>
|
||||
PRIVATE
|
||||
{numPrivateSnippets > 0 && ` (${numPrivateSnippets})`}
|
||||
</span>
|
||||
</CollapsibleTrigger_Shadcn_>
|
||||
<CollapsibleContent_Shadcn_ className="pt-2">
|
||||
<InnerSideMenuCollapsible
|
||||
open={showPrivateSnippets}
|
||||
onOpenChange={setShowPrivateSnippets}
|
||||
className="px-0"
|
||||
>
|
||||
<InnerSideMenuCollapsibleTrigger
|
||||
title={`PRIVATE
|
||||
${numPrivateSnippets > 0 ? ` (${numPrivateSnippets})` : ''}`}
|
||||
/>
|
||||
<InnerSideMenuCollapsibleContent className="group-data-[state=open]:pt-2">
|
||||
{!snapV2.loaded[projectRef as string] ? (
|
||||
<div className="px-4">
|
||||
<GenericSkeletonLoader />
|
||||
</div>
|
||||
<>
|
||||
<div className="flex flex-row h-6 px-3 items-center gap-3">
|
||||
<Skeleton className="h-4 w-5" />
|
||||
<Skeleton className="w-40 h-4" />
|
||||
</div>
|
||||
<div className="flex flex-row h-6 px-3 items-center gap-3">
|
||||
<Skeleton className="h-4 w-5" />
|
||||
<Skeleton className="w-32 h-4" />
|
||||
</div>
|
||||
<div className="flex flex-row h-6 px-3 items-center gap-3 opacity-75">
|
||||
<Skeleton className="h-4 w-5" />
|
||||
<Skeleton className="w-20 h-4" />
|
||||
</div>
|
||||
<div className="flex flex-row h-6 px-3 items-center gap-3 opacity-50">
|
||||
<Skeleton className="h-4 w-5" />
|
||||
<Skeleton className="w-40 h-4" />
|
||||
</div>
|
||||
<div className="flex flex-row h-6 px-3 items-center gap-3 opacity-25">
|
||||
<Skeleton className="h-4 w-5" />
|
||||
<Skeleton className="w-20 h-4" />
|
||||
</div>
|
||||
</>
|
||||
) : folders.length === 0 && numPrivateSnippets === 0 ? (
|
||||
<div className="mx-4">
|
||||
<Alert_Shadcn_ className="p-3">
|
||||
<AlertTitle_Shadcn_ className="text-xs">No queries created yet</AlertTitle_Shadcn_>
|
||||
<AlertDescription_Shadcn_ className="text-xs">
|
||||
Queries will be automatically saved once you start writing in the editor.
|
||||
</AlertDescription_Shadcn_>
|
||||
</Alert_Shadcn_>
|
||||
</div>
|
||||
<InnerSideBarEmptyPanel
|
||||
className="mx-3 px-4"
|
||||
title="No queries created yet"
|
||||
description="Queries will be automatically saved once you start writing in the editor"
|
||||
/>
|
||||
) : (
|
||||
<TreeView
|
||||
multiSelect
|
||||
@@ -544,8 +552,8 @@ export const SQLEditorNav = ({ searchText: _searchText }: SQLEditorNavProps) =>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</CollapsibleContent_Shadcn_>
|
||||
</Collapsible_Shadcn_>
|
||||
</InnerSideMenuCollapsibleContent>
|
||||
</InnerSideMenuCollapsible>
|
||||
|
||||
<Separator />
|
||||
|
||||
|
||||
@@ -1,59 +1,31 @@
|
||||
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { handleError, post } from 'data/fetchers'
|
||||
import { sqlKeys } from 'data/sql/keys'
|
||||
import { post } from 'lib/common/fetch'
|
||||
import { IS_PLATFORM } from 'lib/constants'
|
||||
import { PROJECT_ENDPOINT_PROTOCOL } from 'pages/api/constants'
|
||||
import type { ResponseError } from 'types'
|
||||
import { authKeys } from './keys'
|
||||
|
||||
export type UserCreateVariables = {
|
||||
projectRef?: string
|
||||
protocol: string
|
||||
endpoint: string
|
||||
serviceApiKey: string
|
||||
projectRef: string
|
||||
user: {
|
||||
email: string
|
||||
password: string
|
||||
autoConfirmUser: string
|
||||
autoConfirmUser: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export type UserCreateResponse = {
|
||||
id: string
|
||||
phone: string
|
||||
role: string
|
||||
updated_at: string
|
||||
app_metadata: {
|
||||
provider: string
|
||||
providers: string[]
|
||||
}
|
||||
aud: string
|
||||
created_at: string
|
||||
email: string
|
||||
email_confirmed_at: string
|
||||
identities: any[]
|
||||
user_metadata: any
|
||||
}
|
||||
|
||||
export async function createUser({ protocol, endpoint, serviceApiKey, user }: UserCreateVariables) {
|
||||
const response = await post(
|
||||
`${protocol}://${endpoint}/auth/v1/admin/users`,
|
||||
{
|
||||
export async function createUser({ projectRef, user }: UserCreateVariables) {
|
||||
const { data, error } = await post('/platform/auth/{ref}/users', {
|
||||
params: { path: { ref: projectRef } },
|
||||
body: {
|
||||
email: user.email,
|
||||
password: user.password,
|
||||
email_confirm: user.autoConfirmUser,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
apikey: serviceApiKey,
|
||||
Authorization: `Bearer ${serviceApiKey}`,
|
||||
},
|
||||
}
|
||||
)
|
||||
if (response.error) throw response.error
|
||||
return response
|
||||
})
|
||||
if (error) handleError(error)
|
||||
return data
|
||||
}
|
||||
|
||||
type UserCreateData = Awaited<ReturnType<typeof createUser>>
|
||||
|
||||
@@ -1,46 +1,25 @@
|
||||
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { put } from 'lib/common/fetch'
|
||||
import { IS_PLATFORM } from 'lib/constants'
|
||||
import { PROJECT_ENDPOINT_PROTOCOL } from 'pages/api/constants'
|
||||
import { handleError, patch } from 'data/fetchers'
|
||||
import type { ResponseError } from 'types'
|
||||
import { authKeys } from './keys'
|
||||
|
||||
export type UserUpdateVariables = {
|
||||
projectRef?: string
|
||||
protocol: string
|
||||
endpoint: string
|
||||
serviceApiKey: string
|
||||
|
||||
projectRef: string
|
||||
userId: string
|
||||
// For now just support updating banning the user
|
||||
banDuration: number | 'none' // In hours, "none" to unban, otherwise a string in hours e.g "24h"
|
||||
}
|
||||
|
||||
export async function updateUser({
|
||||
protocol,
|
||||
endpoint,
|
||||
serviceApiKey,
|
||||
userId,
|
||||
banDuration,
|
||||
}: UserUpdateVariables) {
|
||||
// [Joshen] This is probably the only endpoint that needs the put method from lib/common/fetch
|
||||
// as it's not our internal API.
|
||||
const response = await put(
|
||||
`${protocol}://${endpoint}/auth/v1/admin/users/${userId}`,
|
||||
{ ban_duration: typeof banDuration === 'number' ? `${banDuration}h` : banDuration },
|
||||
{
|
||||
headers: {
|
||||
apikey: serviceApiKey,
|
||||
Authorization: `Bearer ${serviceApiKey}`,
|
||||
},
|
||||
credentials: undefined,
|
||||
}
|
||||
)
|
||||
export async function updateUser({ projectRef, userId, banDuration }: UserUpdateVariables) {
|
||||
const { data, error } = await patch('/platform/auth/{ref}/users/{id}', {
|
||||
params: { path: { ref: projectRef, id: userId } },
|
||||
body: { ban_duration: typeof banDuration === 'number' ? `${banDuration}h` : banDuration },
|
||||
})
|
||||
|
||||
if (response.error) throw response.error
|
||||
return response
|
||||
if (error) handleError(error)
|
||||
return data
|
||||
}
|
||||
|
||||
type UserUpdateData = Awaited<ReturnType<typeof updateUser>>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { databaseKeys } from 'data/database/keys'
|
||||
import { entityTypeKeys } from 'data/entity-types/keys'
|
||||
import { del, handleError } from 'data/fetchers'
|
||||
import { sqlKeys } from 'data/sql/keys'
|
||||
import { tableEditorKeys } from 'data/table-editor/keys'
|
||||
import { tableRowKeys } from 'data/table-rows/keys'
|
||||
import { viewKeys } from 'data/views/keys'
|
||||
import type { ResponseError } from 'types'
|
||||
|
||||
@@ -62,18 +64,20 @@ export const useDatabaseColumnDeleteMutation = ({
|
||||
...(table !== undefined
|
||||
? [
|
||||
queryClient.invalidateQueries(tableEditorKeys.tableEditor(projectRef, table.id)),
|
||||
queryClient.invalidateQueries(
|
||||
sqlKeys.query(projectRef, [table.schema, table.name])
|
||||
),
|
||||
queryClient.invalidateQueries(
|
||||
sqlKeys.query(projectRef, ['table-definition', table.schema, table.name])
|
||||
),
|
||||
queryClient.invalidateQueries(databaseKeys.tableDefinition(projectRef, table.id)),
|
||||
// invalidate all views from this schema, not sure if this is needed since you can't actually delete a column
|
||||
// which has a view dependent on it
|
||||
queryClient.invalidateQueries(viewKeys.listBySchema(projectRef, table.schema)),
|
||||
]
|
||||
: []),
|
||||
])
|
||||
|
||||
if (table !== undefined) {
|
||||
// We need to invalidate tableRowsAndCount after tableEditor
|
||||
// to ensure the query sent is correct
|
||||
await queryClient.invalidateQueries(tableRowKeys.tableRowsAndCount(projectRef, table.id))
|
||||
}
|
||||
|
||||
await onSuccess?.(data, variables, context)
|
||||
},
|
||||
async onError(data, variables, context) {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { executeSql } from 'data/sql/execute-sql-query'
|
||||
import type { ResponseError } from 'types'
|
||||
import { databaseQueuesKeys } from './keys'
|
||||
|
||||
export type DatabaseQueueMessageDeleteVariables = {
|
||||
projectRef: string
|
||||
connectionString?: string
|
||||
queueName: string
|
||||
messageId: number
|
||||
}
|
||||
|
||||
export async function deleteDatabaseQueueMessage({
|
||||
projectRef,
|
||||
connectionString,
|
||||
queueName,
|
||||
messageId,
|
||||
}: DatabaseQueueMessageDeleteVariables) {
|
||||
const { result } = await executeSql({
|
||||
projectRef,
|
||||
connectionString,
|
||||
sql: `SELECT * FROM pgmq.delete('${queueName}', ${messageId})`,
|
||||
queryKey: databaseQueuesKeys.create(),
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
type DatabaseQueueMessageDeleteData = Awaited<ReturnType<typeof deleteDatabaseQueueMessage>>
|
||||
|
||||
export const useDatabaseQueueMessageDeleteMutation = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
...options
|
||||
}: Omit<
|
||||
UseMutationOptions<
|
||||
DatabaseQueueMessageDeleteData,
|
||||
ResponseError,
|
||||
DatabaseQueueMessageDeleteVariables
|
||||
>,
|
||||
'mutationFn'
|
||||
> = {}) => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation<
|
||||
DatabaseQueueMessageDeleteData,
|
||||
ResponseError,
|
||||
DatabaseQueueMessageDeleteVariables
|
||||
>((vars) => deleteDatabaseQueueMessage(vars), {
|
||||
async onSuccess(data, variables, context) {
|
||||
const { projectRef, queueName } = variables
|
||||
await queryClient.invalidateQueries(
|
||||
databaseQueuesKeys.getMessagesInfinite(projectRef, queueName)
|
||||
)
|
||||
await onSuccess?.(data, variables, context)
|
||||
},
|
||||
async onError(data, variables, context) {
|
||||
if (onError === undefined) {
|
||||
toast.error(`Failed to delete database queue message: ${data.message}`)
|
||||
} else {
|
||||
onError(data, variables, context)
|
||||
}
|
||||
},
|
||||
...options,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { executeSql } from 'data/sql/execute-sql-query'
|
||||
import type { ResponseError } from 'types'
|
||||
import { databaseQueuesKeys } from './keys'
|
||||
|
||||
export type DatabaseQueueMessageReadVariables = {
|
||||
projectRef: string
|
||||
connectionString?: string
|
||||
queryName: string
|
||||
duration: number
|
||||
messageId: number
|
||||
}
|
||||
|
||||
export async function readDatabaseQueueMessage({
|
||||
projectRef,
|
||||
connectionString,
|
||||
queryName,
|
||||
messageId,
|
||||
duration,
|
||||
}: DatabaseQueueMessageReadVariables) {
|
||||
const { result } = await executeSql({
|
||||
projectRef,
|
||||
connectionString,
|
||||
sql: `select * from pgmq.set_vt('${queryName}', ${messageId}, ${duration})`,
|
||||
queryKey: databaseQueuesKeys.create(),
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
type DatabaseQueueMessageReadData = Awaited<ReturnType<typeof readDatabaseQueueMessage>>
|
||||
|
||||
export const useDatabaseQueueMessageReadMutation = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
...options
|
||||
}: Omit<
|
||||
UseMutationOptions<
|
||||
DatabaseQueueMessageReadData,
|
||||
ResponseError,
|
||||
DatabaseQueueMessageReadVariables
|
||||
>,
|
||||
'mutationFn'
|
||||
> = {}) => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation<
|
||||
DatabaseQueueMessageReadData,
|
||||
ResponseError,
|
||||
DatabaseQueueMessageReadVariables
|
||||
>((vars) => readDatabaseQueueMessage(vars), {
|
||||
async onSuccess(data, variables, context) {
|
||||
const { projectRef, queryName } = variables
|
||||
await queryClient.invalidateQueries(
|
||||
databaseQueuesKeys.getMessagesInfinite(projectRef, queryName)
|
||||
)
|
||||
await onSuccess?.(data, variables, context)
|
||||
},
|
||||
async onError(data, variables, context) {
|
||||
if (onError === undefined) {
|
||||
toast.error(`Failed to postpone database queue message: ${data.message}`)
|
||||
} else {
|
||||
onError(data, variables, context)
|
||||
}
|
||||
},
|
||||
...options,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { executeSql } from 'data/sql/execute-sql-query'
|
||||
import type { ResponseError } from 'types'
|
||||
import { databaseQueuesKeys } from './keys'
|
||||
|
||||
export type DatabaseQueueMessageSendVariables = {
|
||||
projectRef: string
|
||||
connectionString?: string
|
||||
queueName: string
|
||||
payload: string
|
||||
delay: number
|
||||
}
|
||||
|
||||
export async function sendDatabaseQueueMessage({
|
||||
projectRef,
|
||||
connectionString,
|
||||
queueName,
|
||||
payload,
|
||||
delay,
|
||||
}: DatabaseQueueMessageSendVariables) {
|
||||
const { result } = await executeSql({
|
||||
projectRef,
|
||||
connectionString,
|
||||
sql: `select * from pgmq.send( '${queueName}', '${payload}', ${delay})`,
|
||||
queryKey: databaseQueuesKeys.create(),
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
type DatabaseQueueMessageSendData = Awaited<ReturnType<typeof sendDatabaseQueueMessage>>
|
||||
|
||||
export const useDatabaseQueueMessageSendMutation = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
...options
|
||||
}: Omit<
|
||||
UseMutationOptions<
|
||||
DatabaseQueueMessageSendData,
|
||||
ResponseError,
|
||||
DatabaseQueueMessageSendVariables
|
||||
>,
|
||||
'mutationFn'
|
||||
> = {}) => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation<
|
||||
DatabaseQueueMessageSendData,
|
||||
ResponseError,
|
||||
DatabaseQueueMessageSendVariables
|
||||
>((vars) => sendDatabaseQueueMessage(vars), {
|
||||
async onSuccess(data, variables, context) {
|
||||
const { projectRef, queueName } = variables
|
||||
await queryClient.invalidateQueries(
|
||||
databaseQueuesKeys.getMessagesInfinite(projectRef, queueName)
|
||||
)
|
||||
await onSuccess?.(data, variables, context)
|
||||
},
|
||||
async onError(data, variables, context) {
|
||||
if (onError === undefined) {
|
||||
toast.error(`Failed to send database queue message: ${data.message}`)
|
||||
} else {
|
||||
onError(data, variables, context)
|
||||
}
|
||||
},
|
||||
...options,
|
||||
})
|
||||
}
|
||||
@@ -20,7 +20,7 @@ export async function deleteDatabaseQueue({
|
||||
projectRef,
|
||||
connectionString,
|
||||
sql: `select * from pgmq.drop_queue('${queueName}');`,
|
||||
queryKey: databaseQueuesKeys.delete(),
|
||||
queryKey: databaseQueuesKeys.delete(queueName),
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { UseQueryOptions, useQuery } from '@tanstack/react-query'
|
||||
import { handleError } from 'data/fetchers'
|
||||
import { executeSql } from 'data/sql/execute-sql-query'
|
||||
import { ResponseError } from 'types'
|
||||
import { databaseQueuesKeys } from './keys'
|
||||
|
||||
export type DatabaseQueuesMetricsVariables = {
|
||||
projectRef?: string
|
||||
connectionString?: string
|
||||
queueName: string
|
||||
}
|
||||
|
||||
export type PostgresQueueMetric = {
|
||||
queue_name: string
|
||||
queue_length: number
|
||||
method: 'estimated' | 'precise'
|
||||
}
|
||||
|
||||
const preciseMetricsSqlQuery = (queueName: string) => `
|
||||
set local statement_timeout = '1s';
|
||||
SELECT
|
||||
COUNT(*) AS row_count
|
||||
FROM
|
||||
"pgmq"."q_${queueName}";
|
||||
`
|
||||
|
||||
const estimateMetricsSqlQuery = (queueName: string) => `
|
||||
select
|
||||
reltuples::bigint as estimated_rows
|
||||
from
|
||||
pg_class
|
||||
where
|
||||
relname = 'q_${queueName}'
|
||||
and relnamespace = 'pgmq'::regnamespace;
|
||||
`
|
||||
|
||||
export async function getDatabaseQueuesMetrics({
|
||||
projectRef,
|
||||
connectionString,
|
||||
queueName,
|
||||
}: DatabaseQueuesMetricsVariables) {
|
||||
if (!projectRef) throw new Error('Project ref is required')
|
||||
|
||||
try {
|
||||
const { result } = await executeSql({
|
||||
projectRef,
|
||||
connectionString,
|
||||
sql: preciseMetricsSqlQuery(queueName),
|
||||
})
|
||||
return {
|
||||
queue_name: queueName,
|
||||
queue_length: result[0].row_count,
|
||||
method: 'precise',
|
||||
} as PostgresQueueMetric
|
||||
} catch (error: any) {
|
||||
// if the error is caused because the count timeouted, try to fetch an approximate count
|
||||
if (error?.message === 'canceling statement due to statement timeout') {
|
||||
const { result } = await executeSql({
|
||||
projectRef,
|
||||
connectionString,
|
||||
sql: estimateMetricsSqlQuery(queueName),
|
||||
})
|
||||
return {
|
||||
queue_name: queueName,
|
||||
queue_length: result[0].estimated_rows,
|
||||
method: 'estimated',
|
||||
} as PostgresQueueMetric
|
||||
}
|
||||
return handleError(error)
|
||||
}
|
||||
}
|
||||
|
||||
export type DatabaseQueuesMetricsData = PostgresQueueMetric
|
||||
export type DatabaseQueuesMetricsError = ResponseError
|
||||
|
||||
export const useQueuesMetricsQuery = <TData = DatabaseQueuesMetricsData>(
|
||||
{ projectRef, connectionString, queueName }: DatabaseQueuesMetricsVariables,
|
||||
{
|
||||
enabled = true,
|
||||
...options
|
||||
}: UseQueryOptions<DatabaseQueuesMetricsData, DatabaseQueuesMetricsError, TData> = {}
|
||||
) =>
|
||||
useQuery<DatabaseQueuesMetricsData, DatabaseQueuesMetricsError, TData>(
|
||||
databaseQueuesKeys.metrics(projectRef, queueName),
|
||||
() => getDatabaseQueuesMetrics({ projectRef, connectionString, queueName }),
|
||||
{
|
||||
enabled: enabled && typeof projectRef !== 'undefined',
|
||||
...options,
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { executeSql } from 'data/sql/execute-sql-query'
|
||||
import type { ResponseError } from 'types'
|
||||
import { databaseQueuesKeys } from './keys'
|
||||
|
||||
export type DatabaseQueuePurgeVariables = {
|
||||
projectRef: string
|
||||
connectionString?: string
|
||||
queueName: string
|
||||
}
|
||||
|
||||
export async function purgeDatabaseQueue({
|
||||
projectRef,
|
||||
connectionString,
|
||||
queueName,
|
||||
}: DatabaseQueuePurgeVariables) {
|
||||
const { result } = await executeSql({
|
||||
projectRef,
|
||||
connectionString,
|
||||
sql: `select * from pgmq.purge_queue('${queueName}');`,
|
||||
queryKey: databaseQueuesKeys.purge(queueName),
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
type DatabaseQueuePurgeData = Awaited<ReturnType<typeof purgeDatabaseQueue>>
|
||||
|
||||
export const useDatabaseQueuePurgeMutation = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
...options
|
||||
}: Omit<
|
||||
UseMutationOptions<DatabaseQueuePurgeData, ResponseError, DatabaseQueuePurgeVariables>,
|
||||
'mutationFn'
|
||||
> = {}) => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation<DatabaseQueuePurgeData, ResponseError, DatabaseQueuePurgeVariables>(
|
||||
(vars) => purgeDatabaseQueue(vars),
|
||||
{
|
||||
async onSuccess(data, variables, context) {
|
||||
const { projectRef, queueName } = variables
|
||||
await queryClient.invalidateQueries(
|
||||
databaseQueuesKeys.getMessagesInfinite(projectRef, queueName)
|
||||
)
|
||||
await onSuccess?.(data, variables, context)
|
||||
},
|
||||
async onError(data, variables, context) {
|
||||
if (onError === undefined) {
|
||||
toast.error(`Failed to purge database queue: ${data.message}`)
|
||||
} else {
|
||||
onError(data, variables, context)
|
||||
}
|
||||
},
|
||||
...options,
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
export const databaseQueuesKeys = {
|
||||
create: () => ['queues', 'create'] as const,
|
||||
delete: () => ['queues', 'delete'] as const,
|
||||
delete: (name: string) => ['queues', name, 'delete'] as const,
|
||||
purge: (name: string) => ['queues', name, 'purge'] as const,
|
||||
getMessagesInfinite: (projectRef: string | undefined, queueName: string, options?: object) =>
|
||||
['projects', projectRef, 'queues', queueName, options].filter(Boolean),
|
||||
list: (projectRef: string | undefined) => ['projects', projectRef, 'queues'] as const,
|
||||
// invalidating queues.list will also invalidate queues.metrics
|
||||
metrics: (projectRef: string | undefined, queueName: string) =>
|
||||
['projects', projectRef, 'queues', 'metrics', queueName] as const,
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { UseQueryOptions } from '@tanstack/react-query'
|
||||
import { ExecuteSqlData, ExecuteSqlError, useExecuteSqlQuery } from '../sql/execute-sql-query'
|
||||
import { useQuery, UseQueryOptions } from '@tanstack/react-query'
|
||||
import { executeSql, ExecuteSqlError } from '../sql/execute-sql-query'
|
||||
import { databaseKeys } from './keys'
|
||||
|
||||
type GetTableConstraintsVariables = {
|
||||
schema?: string
|
||||
table?: string
|
||||
id?: number
|
||||
}
|
||||
|
||||
export type Constraint = {
|
||||
@@ -21,19 +21,29 @@ export enum CONSTRAINT_TYPE {
|
||||
EXCLUSION_CONSTRAINT = 'x',
|
||||
}
|
||||
|
||||
export const getTableConstraints = ({ schema, table }: GetTableConstraintsVariables) => {
|
||||
export const getTableConstraintsSql = ({ id }: GetTableConstraintsVariables) => {
|
||||
const sql = /* SQL */ `
|
||||
SELECT
|
||||
with table_info as (
|
||||
select
|
||||
n.nspname::text as schema,
|
||||
c.relname::text as name,
|
||||
to_regclass(concat('"', n.nspname, '"."', c.relname, '"')) as regclass
|
||||
from pg_class c
|
||||
join pg_namespace n on n.oid = c.relnamespace
|
||||
where c.oid = ${id}
|
||||
)
|
||||
select
|
||||
con.oid as id,
|
||||
con.conname as name,
|
||||
con.contype as type
|
||||
FROM pg_catalog.pg_constraint con
|
||||
INNER JOIN pg_catalog.pg_class rel
|
||||
ON rel.oid = con.conrelid
|
||||
INNER JOIN pg_catalog.pg_namespace nsp
|
||||
ON nsp.oid = connamespace
|
||||
WHERE nsp.nspname = '${schema}'
|
||||
AND rel.relname = '${table}';
|
||||
from pg_catalog.pg_constraint con
|
||||
inner join pg_catalog.pg_class rel
|
||||
on rel.oid = con.conrelid
|
||||
inner join pg_catalog.pg_namespace nsp
|
||||
on nsp.oid = connamespace
|
||||
inner join table_info ti
|
||||
on ti.schema = nsp.nspname
|
||||
and ti.name = rel.relname;
|
||||
`.trim()
|
||||
|
||||
return sql
|
||||
@@ -47,23 +57,31 @@ export type TableConstraintsVariables = GetTableConstraintsVariables & {
|
||||
export type TableConstraintsData = Constraint[]
|
||||
export type TableConstraintsError = ExecuteSqlError
|
||||
|
||||
export const useTableConstraintsQuery = <TData extends TableConstraintsData = TableConstraintsData>(
|
||||
{ projectRef, connectionString, schema, table }: TableConstraintsVariables,
|
||||
options: UseQueryOptions<ExecuteSqlData, TableConstraintsError, TData> = {}
|
||||
) => {
|
||||
return useExecuteSqlQuery(
|
||||
export async function getTableConstraints(
|
||||
{ projectRef, connectionString, id }: TableConstraintsVariables,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
const sql = getTableConstraintsSql({ id })
|
||||
const { result } = await executeSql(
|
||||
{ projectRef, connectionString, sql, queryKey: ['table-constraints', id] },
|
||||
signal
|
||||
)
|
||||
|
||||
return (result as TableConstraintsData) ?? []
|
||||
}
|
||||
|
||||
export const useTableConstraintsQuery = <TData = TableConstraintsData>(
|
||||
{ projectRef, connectionString, id }: TableConstraintsVariables,
|
||||
{
|
||||
enabled = true,
|
||||
...options
|
||||
}: UseQueryOptions<TableConstraintsData, TableConstraintsError, TData> = {}
|
||||
) =>
|
||||
useQuery<TableConstraintsData, TableConstraintsError, TData>(
|
||||
databaseKeys.tableConstraints(projectRef, id),
|
||||
({ signal }) => getTableConstraints({ projectRef, connectionString, id }, signal),
|
||||
{
|
||||
projectRef,
|
||||
connectionString,
|
||||
sql: getTableConstraints({ schema, table }),
|
||||
queryKey: ['table-constraints'],
|
||||
},
|
||||
{
|
||||
enabled: typeof schema !== 'undefined' && typeof table !== 'undefined',
|
||||
select(data) {
|
||||
return (data as any)?.result ?? []
|
||||
},
|
||||
enabled: enabled && typeof projectRef !== 'undefined' && typeof id !== 'undefined',
|
||||
...options,
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { UseQueryOptions } from '@tanstack/react-query'
|
||||
import { useQuery, UseQueryOptions } from '@tanstack/react-query'
|
||||
|
||||
import { SupportedAssistantEntities } from 'components/ui/AIAssistantPanel/AIAssistant.types'
|
||||
import { ExecuteSqlData, ExecuteSqlError, useExecuteSqlQuery } from '../sql/execute-sql-query'
|
||||
import { executeSql, ExecuteSqlError } from '../sql/execute-sql-query'
|
||||
import { databaseKeys } from './keys'
|
||||
|
||||
const generatePolicyDefinition = (policy: {
|
||||
@@ -21,14 +21,19 @@ CREATE POLICY "${policy.name}" on "${policy.schema}"."${policy.table}" AS ${poli
|
||||
}
|
||||
|
||||
// [Joshen] Eventually should support table definition and view definition as well if possible
|
||||
export const getEntityDefinitionQuery = ({
|
||||
export const getEntityDefinitionSql = ({
|
||||
id,
|
||||
type,
|
||||
}: {
|
||||
id?: number
|
||||
type?: SupportedAssistantEntities | 'table' | 'view' | null
|
||||
}) => {
|
||||
if (!id || !type) return ''
|
||||
if (!id) {
|
||||
throw new Error('id is required')
|
||||
}
|
||||
if (!type) {
|
||||
throw new Error('type is required')
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'functions':
|
||||
@@ -79,32 +84,54 @@ export type EntityDefinitionVariables = {
|
||||
connectionString?: string
|
||||
}
|
||||
|
||||
export type EntityDefinitionData = string
|
||||
export type EntityDefinitionError = ExecuteSqlError
|
||||
export async function getEntityDefinition(
|
||||
{ projectRef, connectionString, id, type }: EntityDefinitionVariables,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
if (!id) {
|
||||
throw new Error('id is required')
|
||||
}
|
||||
|
||||
export const useEntityDefinitionQuery = <TData extends EntityDefinitionData = EntityDefinitionData>(
|
||||
{ id, type, projectRef, connectionString }: EntityDefinitionVariables,
|
||||
{ enabled = true, ...options }: UseQueryOptions<ExecuteSqlData, EntityDefinitionError, TData> = {}
|
||||
) => {
|
||||
return useExecuteSqlQuery(
|
||||
const sql = getEntityDefinitionSql({ id, type })
|
||||
const { result } = await executeSql(
|
||||
{
|
||||
projectRef,
|
||||
connectionString,
|
||||
sql: getEntityDefinitionQuery({ id, type }),
|
||||
queryKey: databaseKeys.entityDefinition(projectRef, id),
|
||||
sql,
|
||||
queryKey: ['entity-definition', id],
|
||||
},
|
||||
signal
|
||||
)
|
||||
|
||||
if (type === 'functions') {
|
||||
return result[0].pg_get_functiondef
|
||||
} else if (type === 'rls-policies') {
|
||||
return generatePolicyDefinition(result[0])
|
||||
} else {
|
||||
return result[0]
|
||||
}
|
||||
}
|
||||
|
||||
export type EntityDefinitionData = string
|
||||
export type EntityDefinitionError = ExecuteSqlError
|
||||
|
||||
export const useEntityDefinitionQuery = <TData = EntityDefinitionData>(
|
||||
{ projectRef, connectionString, id, type }: EntityDefinitionVariables,
|
||||
{
|
||||
enabled = true,
|
||||
...options
|
||||
}: UseQueryOptions<EntityDefinitionData, EntityDefinitionError, TData> = {}
|
||||
) =>
|
||||
useQuery<EntityDefinitionData, EntityDefinitionError, TData>(
|
||||
databaseKeys.entityDefinition(projectRef, id),
|
||||
({ signal }) => getEntityDefinition({ projectRef, connectionString, id, type }, signal),
|
||||
{
|
||||
select(data) {
|
||||
if (type === 'functions') {
|
||||
return data.result[0].pg_get_functiondef
|
||||
} else if (type === 'rls-policies') {
|
||||
return generatePolicyDefinition(data.result[0])
|
||||
} else {
|
||||
return data.result[0]
|
||||
}
|
||||
},
|
||||
enabled: enabled && typeof id !== 'undefined' && typeof type !== 'undefined',
|
||||
enabled:
|
||||
enabled &&
|
||||
typeof projectRef !== 'undefined' &&
|
||||
typeof id !== 'undefined' &&
|
||||
!isNaN(id) &&
|
||||
typeof type !== 'undefined',
|
||||
...options,
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { UseQueryOptions } from '@tanstack/react-query'
|
||||
import { ExecuteSqlData, ExecuteSqlError, useExecuteSqlQuery } from '../sql/execute-sql-query'
|
||||
import { useQuery, UseQueryOptions } from '@tanstack/react-query'
|
||||
import { executeSql, ExecuteSqlError } from '../sql/execute-sql-query'
|
||||
import { CREATE_PG_GET_TABLEDEF_SQL } from './database-query-constants'
|
||||
import { databaseKeys } from './keys'
|
||||
|
||||
export const getEntityDefinitionsQuery = ({
|
||||
export const getEntityDefinitionsSql = ({
|
||||
schemas,
|
||||
limit = 100,
|
||||
}: {
|
||||
@@ -82,32 +82,40 @@ export type EntityDefinitionsVariables = {
|
||||
schemas: string[]
|
||||
}
|
||||
|
||||
export async function getEntityDefinitions(
|
||||
{ projectRef, connectionString, schemas, limit }: EntityDefinitionsVariables,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
const sql = getEntityDefinitionsSql({ schemas, limit })
|
||||
const { result } = await executeSql(
|
||||
{
|
||||
projectRef,
|
||||
connectionString,
|
||||
sql,
|
||||
queryKey: ['entity-definitions', schemas],
|
||||
},
|
||||
signal
|
||||
)
|
||||
|
||||
return result[0].data.definitions
|
||||
}
|
||||
|
||||
type EntityDefinition = { id: number; sql: string }
|
||||
export type EntityDefinitionsData = EntityDefinition[]
|
||||
export type EntityDefinitionsError = ExecuteSqlError
|
||||
|
||||
export const useEntityDefinitionsQuery = <
|
||||
TData extends EntityDefinitionsData = EntityDefinitionsData,
|
||||
>(
|
||||
{ schemas, limit, projectRef, connectionString }: EntityDefinitionsVariables,
|
||||
export const useEntityDefinitionsQuery = <TData = EntityDefinitionsData>(
|
||||
{ projectRef, connectionString, schemas, limit }: EntityDefinitionsVariables,
|
||||
{
|
||||
enabled = true,
|
||||
...options
|
||||
}: UseQueryOptions<ExecuteSqlData, EntityDefinitionsError, TData> = {}
|
||||
) => {
|
||||
return useExecuteSqlQuery(
|
||||
}: UseQueryOptions<EntityDefinitionsData, EntityDefinitionsError, TData> = {}
|
||||
) =>
|
||||
useQuery<EntityDefinitionsData, EntityDefinitionsError, TData>(
|
||||
databaseKeys.entityDefinitions(projectRef, schemas),
|
||||
({ signal }) => getEntityDefinitions({ projectRef, connectionString, schemas, limit }, signal),
|
||||
{
|
||||
projectRef,
|
||||
connectionString,
|
||||
sql: getEntityDefinitionsQuery({ schemas, limit }),
|
||||
queryKey: databaseKeys.entityDefinitions(projectRef, schemas),
|
||||
},
|
||||
{
|
||||
select(data) {
|
||||
return data.result[0].data.definitions
|
||||
},
|
||||
enabled: enabled && schemas.length > 0,
|
||||
enabled: enabled && typeof projectRef !== 'undefined' && schemas.length > 0,
|
||||
...options,
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -2,9 +2,13 @@ import { SupportedAssistantEntities } from 'components/ui/AIAssistantPanel/AIAss
|
||||
|
||||
export const databaseKeys = {
|
||||
entityDefinition: (projectRef: string | undefined, id?: number) =>
|
||||
['projects', projectRef, 'entity-definition', id].filter(Boolean),
|
||||
['projects', projectRef, 'entity-definition', id] as const,
|
||||
entityDefinitions: (projectRef: string | undefined, schemas: string[]) =>
|
||||
['projects', projectRef, 'entity-definitions', schemas] as const,
|
||||
tableDefinition: (projectRef: string | undefined, id?: number) =>
|
||||
['projects', projectRef, 'table-definition', id] as const,
|
||||
viewDefinition: (projectRef: string | undefined, id?: number) =>
|
||||
['projects', projectRef, 'view-definition', id] as const,
|
||||
backups: (projectRef: string | undefined) => [projectRef, 'database', 'backups'] as const,
|
||||
poolingConfiguration: (projectRef: string | undefined) =>
|
||||
[projectRef, 'database', 'pooling-configuration'] as const,
|
||||
@@ -12,4 +16,6 @@ export const databaseKeys = {
|
||||
['projects', projectRef, 'indexes', { query }] as const,
|
||||
indexAdvisorFromQuery: (projectRef: string | undefined, query: string) =>
|
||||
['projects', projectRef, 'index-advisor', { query }] as const,
|
||||
tableConstraints: (projectRef: string | undefined, id?: number) =>
|
||||
['projects', projectRef, 'table-constraints', id] as const,
|
||||
}
|
||||
@@ -1,53 +1,81 @@
|
||||
import { UseQueryOptions } from '@tanstack/react-query'
|
||||
import { ExecuteSqlData, ExecuteSqlError, useExecuteSqlQuery } from '../sql/execute-sql-query'
|
||||
import { useQuery, UseQueryOptions } from '@tanstack/react-query'
|
||||
import { executeSql, ExecuteSqlError } from '../sql/execute-sql-query'
|
||||
import { CREATE_PG_GET_TABLEDEF_SQL } from './database-query-constants'
|
||||
import { databaseKeys } from './keys'
|
||||
|
||||
type GetTableDefinition = {
|
||||
schema?: string
|
||||
name?: string
|
||||
type GetTableDefinitionArgs = {
|
||||
id?: number
|
||||
}
|
||||
|
||||
// [Joshen] Eventually move this into entity-definition-query
|
||||
export const getTableDefinitionQuery = ({ schema = 'public', name }: GetTableDefinition) => {
|
||||
const getTableDefinitionSql = ({ id }: GetTableDefinitionArgs) => {
|
||||
const sql = /* SQL */ `
|
||||
${CREATE_PG_GET_TABLEDEF_SQL}
|
||||
|
||||
with table_info as (
|
||||
select
|
||||
n.nspname::text as schema,
|
||||
c.relname::text as name
|
||||
from pg_class c
|
||||
join pg_namespace n on n.oid = c.relnamespace
|
||||
where c.oid = ${id}
|
||||
)
|
||||
select pg_temp.pg_get_tabledef (
|
||||
'${schema}',
|
||||
'${name}',
|
||||
t.schema,
|
||||
t.name,
|
||||
false,
|
||||
'FKEYS_INTERNAL',
|
||||
'INCLUDE_TRIGGERS'
|
||||
) as definition
|
||||
from table_info t;
|
||||
`.trim()
|
||||
|
||||
return sql
|
||||
}
|
||||
|
||||
export type TableDefinitionVariables = GetTableDefinition & {
|
||||
export type TableDefinitionVariables = GetTableDefinitionArgs & {
|
||||
projectRef?: string
|
||||
connectionString?: string
|
||||
}
|
||||
|
||||
export async function getTableDefinition(
|
||||
{ projectRef, connectionString, id }: TableDefinitionVariables,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
if (!id) {
|
||||
throw new Error('id is required')
|
||||
}
|
||||
|
||||
const sql = getTableDefinitionSql({ id })
|
||||
const { result } = await executeSql(
|
||||
{
|
||||
projectRef,
|
||||
connectionString,
|
||||
sql,
|
||||
queryKey: ['table-definition', id],
|
||||
},
|
||||
signal
|
||||
)
|
||||
|
||||
return result[0].definition.trim() as string
|
||||
}
|
||||
|
||||
export type TableDefinitionData = string
|
||||
export type TableDefinitionError = ExecuteSqlError
|
||||
|
||||
export const useTableDefinitionQuery = <TData extends TableDefinitionData = TableDefinitionData>(
|
||||
{ projectRef, connectionString, schema, name }: TableDefinitionVariables,
|
||||
options: UseQueryOptions<ExecuteSqlData, TableDefinitionError, TData> = {}
|
||||
) => {
|
||||
return useExecuteSqlQuery(
|
||||
export const useTableDefinitionQuery = <TData = TableDefinitionData>(
|
||||
{ projectRef, connectionString, id }: TableDefinitionVariables,
|
||||
{
|
||||
enabled = true,
|
||||
...options
|
||||
}: UseQueryOptions<TableDefinitionData, TableDefinitionError, TData> = {}
|
||||
) =>
|
||||
useQuery<TableDefinitionData, TableDefinitionError, TData>(
|
||||
databaseKeys.tableDefinition(projectRef, id),
|
||||
({ signal }) => getTableDefinition({ projectRef, connectionString, id }, signal),
|
||||
{
|
||||
projectRef,
|
||||
connectionString,
|
||||
sql: getTableDefinitionQuery({ schema, name }),
|
||||
queryKey: ['table-definition', schema, name],
|
||||
},
|
||||
{
|
||||
select(data) {
|
||||
return data.result[0].definition.trim()
|
||||
},
|
||||
enabled:
|
||||
enabled && typeof projectRef !== 'undefined' && typeof id !== 'undefined' && !isNaN(id),
|
||||
...options,
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -1,45 +1,73 @@
|
||||
import { UseQueryOptions } from '@tanstack/react-query'
|
||||
import { ExecuteSqlData, ExecuteSqlError, useExecuteSqlQuery } from '../sql/execute-sql-query'
|
||||
import { useQuery, UseQueryOptions } from '@tanstack/react-query'
|
||||
import { executeSql, ExecuteSqlError } from '../sql/execute-sql-query'
|
||||
import { databaseKeys } from './keys'
|
||||
|
||||
type GetViewDefinition = {
|
||||
schema?: string
|
||||
name?: string
|
||||
type GetViewDefinitionArgs = {
|
||||
id?: number
|
||||
}
|
||||
|
||||
// [Joshen] Eventually move this into entity-definition-query
|
||||
export const getViewDefinitionQuery = ({ schema, name }: GetViewDefinition) => {
|
||||
export const getViewDefinitionSql = ({ id }: GetViewDefinitionArgs) => {
|
||||
if (!id) {
|
||||
throw new Error('id is required')
|
||||
}
|
||||
|
||||
const sql = /* SQL */ `
|
||||
select pg_get_viewdef(to_regclass('"${schema}"."${name}"'), true) as definition
|
||||
with table_info as (
|
||||
select
|
||||
n.nspname::text as schema,
|
||||
c.relname::text as name,
|
||||
to_regclass(concat('"', n.nspname, '"."', c.relname, '"')) as regclass
|
||||
from pg_class c
|
||||
join pg_namespace n on n.oid = c.relnamespace
|
||||
where c.oid = ${id}
|
||||
)
|
||||
select pg_get_viewdef(t.regclass, true) as definition
|
||||
from table_info t
|
||||
`.trim()
|
||||
|
||||
return sql
|
||||
}
|
||||
|
||||
export type ViewDefinitionVariables = GetViewDefinition & {
|
||||
export type ViewDefinitionVariables = GetViewDefinitionArgs & {
|
||||
projectRef?: string
|
||||
connectionString?: string
|
||||
}
|
||||
|
||||
export async function getViewDefinition(
|
||||
{ projectRef, connectionString, id }: ViewDefinitionVariables,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
const sql = getViewDefinitionSql({ id })
|
||||
const { result } = await executeSql(
|
||||
{
|
||||
projectRef,
|
||||
connectionString,
|
||||
sql,
|
||||
queryKey: ['view-definition', id],
|
||||
},
|
||||
signal
|
||||
)
|
||||
|
||||
return result[0].definition.trim()
|
||||
}
|
||||
|
||||
export type ViewDefinitionData = string
|
||||
export type ViewDefinitionError = ExecuteSqlError
|
||||
|
||||
export const useViewDefinitionQuery = <TData extends ViewDefinitionData = ViewDefinitionData>(
|
||||
{ projectRef, connectionString, schema, name }: ViewDefinitionVariables,
|
||||
options: UseQueryOptions<ExecuteSqlData, ViewDefinitionError, TData> = {}
|
||||
) => {
|
||||
return useExecuteSqlQuery(
|
||||
export const useViewDefinitionQuery = <TData = ViewDefinitionData>(
|
||||
{ projectRef, connectionString, id }: ViewDefinitionVariables,
|
||||
{
|
||||
enabled = true,
|
||||
...options
|
||||
}: UseQueryOptions<ViewDefinitionData, ViewDefinitionError, TData> = {}
|
||||
) =>
|
||||
useQuery<ViewDefinitionData, ViewDefinitionError, TData>(
|
||||
databaseKeys.viewDefinition(projectRef, id),
|
||||
({ signal }) => getViewDefinition({ projectRef, connectionString, id }, signal),
|
||||
{
|
||||
projectRef,
|
||||
connectionString,
|
||||
sql: getViewDefinitionQuery({ schema, name }),
|
||||
queryKey: ['view-definition', schema, name],
|
||||
},
|
||||
{
|
||||
select(data) {
|
||||
return data.result[0].definition.trim()
|
||||
},
|
||||
enabled: typeof schema !== 'undefined' && typeof name !== 'undefined',
|
||||
enabled:
|
||||
enabled && typeof projectRef !== 'undefined' && typeof id !== 'undefined' && !isNaN(id),
|
||||
...options,
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { QueryClient, useQueryClient } from '@tanstack/react-query'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import { ComponentProps, PropsWithChildren, useCallback } from 'react'
|
||||
@@ -14,9 +14,54 @@ import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectConte
|
||||
import { prefetchTableEditor } from 'data/table-editor/table-editor-query'
|
||||
import { prefetchTableRows } from 'data/table-rows/table-rows-query'
|
||||
import { useFlag } from 'hooks/ui/useFlag'
|
||||
import { ImpersonationRole } from 'lib/role-impersonation'
|
||||
import { useRoleImpersonationStateSnapshot } from 'state/role-impersonation-state'
|
||||
import { TABLE_EDITOR_DEFAULT_ROWS_PER_PAGE } from 'state/table-editor'
|
||||
|
||||
interface PrefetchEditorTablePageArgs {
|
||||
queryClient: QueryClient
|
||||
projectRef: string
|
||||
connectionString?: string
|
||||
id: number
|
||||
sorts?: Sort[]
|
||||
filters?: Filter[]
|
||||
impersonatedRole?: ImpersonationRole
|
||||
}
|
||||
|
||||
export function prefetchEditorTablePage({
|
||||
queryClient,
|
||||
projectRef,
|
||||
connectionString,
|
||||
id,
|
||||
sorts,
|
||||
filters,
|
||||
impersonatedRole,
|
||||
}: PrefetchEditorTablePageArgs) {
|
||||
return prefetchTableEditor(queryClient, {
|
||||
projectRef,
|
||||
connectionString,
|
||||
id,
|
||||
}).then((entity) => {
|
||||
if (entity) {
|
||||
const supaTable = parseSupaTable(entity)
|
||||
|
||||
const { sorts: localSorts = [], filters: localFilters = [] } =
|
||||
loadTableEditorSortsAndFiltersFromLocalStorage(projectRef, entity.name, entity.schema) ?? {}
|
||||
|
||||
prefetchTableRows(queryClient, {
|
||||
projectRef,
|
||||
connectionString,
|
||||
tableId: id,
|
||||
sorts: sorts ?? formatSortURLParams(supaTable.name, localSorts),
|
||||
filters: filters ?? formatFilterURLParams(localFilters),
|
||||
page: 1,
|
||||
limit: TABLE_EDITOR_DEFAULT_ROWS_PER_PAGE,
|
||||
impersonatedRole,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function usePrefetchEditorTablePage() {
|
||||
const router = useRouter()
|
||||
const queryClient = useQueryClient()
|
||||
@@ -35,38 +80,17 @@ export function usePrefetchEditorTablePage() {
|
||||
router.prefetch(`/project/${project.ref}/editor/${id}`)
|
||||
|
||||
// Prefetch the data
|
||||
prefetchTableEditor(queryClient, {
|
||||
prefetchEditorTablePage({
|
||||
queryClient,
|
||||
projectRef: project.ref,
|
||||
connectionString: project.connectionString,
|
||||
id,
|
||||
sorts,
|
||||
filters,
|
||||
impersonatedRole: roleImpersonationState.role,
|
||||
}).catch(() => {
|
||||
// eat prefetching errors as they are not critical
|
||||
})
|
||||
.then((entity) => {
|
||||
if (entity) {
|
||||
const supaTable = parseSupaTable(entity)
|
||||
|
||||
const { sorts: localSorts = [], filters: localFilters = [] } =
|
||||
loadTableEditorSortsAndFiltersFromLocalStorage(
|
||||
project.ref,
|
||||
entity.name,
|
||||
entity.schema
|
||||
) ?? {}
|
||||
|
||||
prefetchTableRows(queryClient, {
|
||||
queryKey: [supaTable.schema, supaTable.name],
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
table: supaTable,
|
||||
sorts: sorts ?? formatSortURLParams(supaTable.name, localSorts),
|
||||
filters: filters ?? formatFilterURLParams(localFilters),
|
||||
page: 1,
|
||||
limit: TABLE_EDITOR_DEFAULT_ROWS_PER_PAGE,
|
||||
impersonatedRole: roleImpersonationState.role,
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// eat prefetching errors as they are not critical
|
||||
})
|
||||
},
|
||||
[project, queryClient, roleImpersonationState.role, router, tableEditorPrefetchingEnabled]
|
||||
)
|
||||
|
||||
@@ -56,7 +56,7 @@ export const useTableEditorQuery = <TData = TableEditorData>(
|
||||
|
||||
export function prefetchTableEditor(
|
||||
client: QueryClient,
|
||||
{ projectRef, connectionString, id }: Required<TableEditorVariables>
|
||||
{ projectRef, connectionString, id }: TableEditorVariables
|
||||
) {
|
||||
return client.fetchQuery(tableEditorKeys.tableEditor(projectRef, id), ({ signal }) =>
|
||||
getTableEditor(
|
||||
|
||||
@@ -51,8 +51,6 @@ export const useGetCellValueMutation = ({
|
||||
(vars) => getCellValue(vars),
|
||||
{
|
||||
async onSuccess(data, variables, context) {
|
||||
const { projectRef, table } = variables
|
||||
// await queryClient.invalidateQueries(sqlKeys.query(projectRef, [table.schema, table.name]))
|
||||
await onSuccess?.(data, variables, context)
|
||||
},
|
||||
async onError(data, variables, context) {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { GetTableRowsArgs } from './table-rows-query'
|
||||
|
||||
type TableRowKeyArgs = Omit<GetTableRowsArgs, 'table'> & { table?: { id?: number } }
|
||||
|
||||
export const tableRowKeys = {
|
||||
tableRows: (projectRef?: string, { table, ...args }: TableRowKeyArgs = {}) =>
|
||||
[projectRef, 'table-rows', table?.id, 'rows', args] as const,
|
||||
tableRowsCount: (projectRef?: string, { table, ...args }: TableRowKeyArgs = {}) =>
|
||||
[projectRef, 'table-rows', table?.id, 'count', args] as const,
|
||||
tableRowsAndCount: (projectRef?: string, tableId?: number) =>
|
||||
[projectRef, 'table-rows', tableId] as const,
|
||||
}
|
||||
@@ -3,15 +3,15 @@ import { toast } from 'sonner'
|
||||
|
||||
import { Query } from 'components/grid/query/Query'
|
||||
import { executeSql } from 'data/sql/execute-sql-query'
|
||||
import { sqlKeys } from 'data/sql/keys'
|
||||
import { ImpersonationRole, wrapWithRoleImpersonation } from 'lib/role-impersonation'
|
||||
import { isRoleImpersonationEnabled } from 'state/role-impersonation-state'
|
||||
import type { ResponseError } from 'types'
|
||||
import { tableRowKeys } from './keys'
|
||||
|
||||
export type TableRowCreateVariables = {
|
||||
projectRef: string
|
||||
connectionString?: string
|
||||
table: { name: string; schema?: string }
|
||||
table: { id: number; name: string; schema?: string }
|
||||
payload: any
|
||||
enumArrayColumns: string[]
|
||||
returning?: boolean
|
||||
@@ -74,7 +74,7 @@ export const useTableRowCreateMutation = ({
|
||||
{
|
||||
async onSuccess(data, variables, context) {
|
||||
const { projectRef, table } = variables
|
||||
await queryClient.invalidateQueries(sqlKeys.query(projectRef, [table.schema, table.name]))
|
||||
await queryClient.invalidateQueries(tableRowKeys.tableRowsAndCount(projectRef, table.id))
|
||||
await onSuccess?.(data, variables, context)
|
||||
},
|
||||
async onError(data, variables, context) {
|
||||
|
||||
@@ -4,10 +4,10 @@ import { toast } from 'sonner'
|
||||
import { Query } from 'components/grid/query/Query'
|
||||
import type { Filter, SupaTable } from 'components/grid/types'
|
||||
import { executeSql } from 'data/sql/execute-sql-query'
|
||||
import { sqlKeys } from 'data/sql/keys'
|
||||
import { ImpersonationRole, wrapWithRoleImpersonation } from 'lib/role-impersonation'
|
||||
import { isRoleImpersonationEnabled } from 'state/role-impersonation-state'
|
||||
import type { ResponseError } from 'types'
|
||||
import { tableRowKeys } from './keys'
|
||||
import { formatFilterValue } from './utils'
|
||||
|
||||
export type TableRowDeleteAllVariables = {
|
||||
@@ -76,7 +76,7 @@ export const useTableRowDeleteAllMutation = ({
|
||||
{
|
||||
async onSuccess(data, variables, context) {
|
||||
const { projectRef, table } = variables
|
||||
await queryClient.invalidateQueries(sqlKeys.query(projectRef, [table.schema, table.name]))
|
||||
await queryClient.invalidateQueries(tableRowKeys.tableRowsAndCount(projectRef, table.id))
|
||||
await onSuccess?.(data, variables, context)
|
||||
},
|
||||
async onError(data, variables, context) {
|
||||
|
||||
@@ -6,11 +6,11 @@ import type { SupaRow } from 'components/grid/types'
|
||||
import { Markdown } from 'components/interfaces/Markdown'
|
||||
import { DocsButton } from 'components/ui/DocsButton'
|
||||
import { executeSql } from 'data/sql/execute-sql-query'
|
||||
import { sqlKeys } from 'data/sql/keys'
|
||||
import { Entity } from 'data/table-editor/table-editor-types'
|
||||
import { ImpersonationRole, wrapWithRoleImpersonation } from 'lib/role-impersonation'
|
||||
import { isRoleImpersonationEnabled } from 'state/role-impersonation-state'
|
||||
import type { ResponseError } from 'types'
|
||||
import { tableRowKeys } from './keys'
|
||||
import { getPrimaryKeys } from './utils'
|
||||
|
||||
export type TableRowDeleteVariables = {
|
||||
@@ -76,7 +76,7 @@ export const useTableRowDeleteMutation = ({
|
||||
{
|
||||
async onSuccess(data, variables, context) {
|
||||
const { projectRef, table } = variables
|
||||
await queryClient.invalidateQueries(sqlKeys.query(projectRef, [table.schema, table.name]))
|
||||
await queryClient.invalidateQueries(tableRowKeys.tableRowsAndCount(projectRef, table.id))
|
||||
await onSuccess?.(data, variables, context)
|
||||
},
|
||||
async onError(data, variables, context) {
|
||||
|
||||
@@ -3,13 +3,13 @@ import { toast } from 'sonner'
|
||||
|
||||
import { Query } from 'components/grid/query/Query'
|
||||
import { executeSql } from 'data/sql/execute-sql-query'
|
||||
import { sqlKeys } from 'data/sql/keys'
|
||||
import type { ResponseError } from 'types'
|
||||
import { tableRowKeys } from './keys'
|
||||
|
||||
export type TableRowTruncateVariables = {
|
||||
projectRef: string
|
||||
connectionString?: string
|
||||
table: { name: string; schema?: string }
|
||||
table: { id: number; name: string; schema?: string }
|
||||
}
|
||||
|
||||
export function getTableRowTruncateSql({ table }: Pick<TableRowTruncateVariables, 'table'>) {
|
||||
@@ -51,7 +51,7 @@ export const useTableRowTruncateMutation = ({
|
||||
{
|
||||
async onSuccess(data, variables, context) {
|
||||
const { projectRef, table } = variables
|
||||
await queryClient.invalidateQueries(sqlKeys.query(projectRef, [table.schema, table.name]))
|
||||
await queryClient.invalidateQueries(tableRowKeys.tableRowsAndCount(projectRef, table.id))
|
||||
await onSuccess?.(data, variables, context)
|
||||
},
|
||||
async onError(data, variables, context) {
|
||||
|
||||
@@ -3,15 +3,15 @@ import { toast } from 'sonner'
|
||||
|
||||
import { Query } from 'components/grid/query/Query'
|
||||
import { executeSql } from 'data/sql/execute-sql-query'
|
||||
import { sqlKeys } from 'data/sql/keys'
|
||||
import { ImpersonationRole, wrapWithRoleImpersonation } from 'lib/role-impersonation'
|
||||
import { isRoleImpersonationEnabled } from 'state/role-impersonation-state'
|
||||
import type { ResponseError } from 'types'
|
||||
import { tableRowKeys } from './keys'
|
||||
|
||||
export type TableRowUpdateVariables = {
|
||||
projectRef: string
|
||||
connectionString?: string
|
||||
table: { name: string; schema?: string }
|
||||
table: { id: number; name: string; schema?: string }
|
||||
configuration: { identifiers: any }
|
||||
payload: any
|
||||
enumArrayColumns: string[]
|
||||
@@ -59,6 +59,7 @@ export async function updateTableRow({
|
||||
connectionString,
|
||||
sql,
|
||||
isRoleImpersonationEnabled: isRoleImpersonationEnabled(impersonatedRole),
|
||||
queryKey: ['table-row-update', table.id],
|
||||
})
|
||||
|
||||
return result
|
||||
@@ -81,7 +82,9 @@ export const useTableRowUpdateMutation = ({
|
||||
{
|
||||
async onSuccess(data, variables, context) {
|
||||
const { projectRef, table } = variables
|
||||
await queryClient.invalidateQueries(sqlKeys.query(projectRef, [table.schema, table.name]))
|
||||
await queryClient.invalidateQueries(
|
||||
tableRowKeys.tableRows(projectRef, { table: { id: table.id } })
|
||||
)
|
||||
await onSuccess?.(data, variables, context)
|
||||
},
|
||||
async onError(data, variables, context) {
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
import type { QueryKey, UseQueryOptions } from '@tanstack/react-query'
|
||||
import { QueryClient, useQuery, useQueryClient, type UseQueryOptions } from '@tanstack/react-query'
|
||||
import { Query } from 'components/grid/query/Query'
|
||||
import { parseSupaTable } from 'components/grid/SupabaseGrid.utils'
|
||||
import type { Filter, SupaTable } from 'components/grid/types'
|
||||
import { prefetchTableEditor } from 'data/table-editor/table-editor-query'
|
||||
import { ImpersonationRole, wrapWithRoleImpersonation } from 'lib/role-impersonation'
|
||||
import { useIsRoleImpersonationEnabled } from 'state/role-impersonation-state'
|
||||
import { ExecuteSqlData, ExecuteSqlError, useExecuteSqlQuery } from '../sql/execute-sql-query'
|
||||
import { isRoleImpersonationEnabled } from 'state/role-impersonation-state'
|
||||
import { executeSql, ExecuteSqlError } from '../sql/execute-sql-query'
|
||||
import { tableRowKeys } from './keys'
|
||||
import { formatFilterValue } from './utils'
|
||||
|
||||
type GetTableRowsCountArgs = {
|
||||
table?: SupaTable
|
||||
filters?: Filter[]
|
||||
enforceExactCount?: boolean
|
||||
impersonatedRole?: ImpersonationRole
|
||||
}
|
||||
|
||||
export const THRESHOLD_COUNT = 50000
|
||||
const COUNT_ESTIMATE_SQL = `
|
||||
const COUNT_ESTIMATE_SQL = /* SQL */ `
|
||||
CREATE OR REPLACE FUNCTION pg_temp.count_estimate(
|
||||
query text
|
||||
) RETURNS integer LANGUAGE plpgsql AS $$
|
||||
@@ -27,7 +29,7 @@ END;
|
||||
$$;
|
||||
`.trim()
|
||||
|
||||
export const getTableRowsCountSqlQuery = ({
|
||||
export const getTableRowsCountSql = ({
|
||||
table,
|
||||
filters = [],
|
||||
enforceExactCount = false,
|
||||
@@ -92,56 +94,78 @@ export type TableRowsCount = {
|
||||
is_estimate?: boolean
|
||||
}
|
||||
|
||||
export type TableRowsCountVariables = GetTableRowsCountArgs & {
|
||||
export type TableRowsCountVariables = Omit<GetTableRowsCountArgs, 'table'> & {
|
||||
queryClient: QueryClient
|
||||
tableId?: number
|
||||
impersonatedRole?: ImpersonationRole
|
||||
projectRef?: string
|
||||
connectionString?: string
|
||||
queryKey?: QueryKey
|
||||
}
|
||||
|
||||
export type TableRowsCountData = TableRowsCount
|
||||
export type TableRowsCountError = ExecuteSqlError
|
||||
|
||||
export const useTableRowsCountQuery = <TData extends TableRowsCountData = TableRowsCountData>(
|
||||
export async function getTableRowsCount(
|
||||
{
|
||||
queryClient,
|
||||
projectRef,
|
||||
connectionString,
|
||||
queryKey,
|
||||
table,
|
||||
enforceExactCount,
|
||||
tableId,
|
||||
filters,
|
||||
impersonatedRole,
|
||||
...args
|
||||
enforceExactCount,
|
||||
}: TableRowsCountVariables,
|
||||
options: UseQueryOptions<ExecuteSqlData, TableRowsCountError, TData> = {}
|
||||
) => {
|
||||
const isRoleImpersonationEnabled = useIsRoleImpersonationEnabled()
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
const entity = await prefetchTableEditor(queryClient, {
|
||||
projectRef,
|
||||
connectionString,
|
||||
id: tableId,
|
||||
})
|
||||
if (!entity) {
|
||||
throw new Error('Table not found')
|
||||
}
|
||||
|
||||
return useExecuteSqlQuery(
|
||||
const table = parseSupaTable(entity)
|
||||
|
||||
const sql = wrapWithRoleImpersonation(
|
||||
getTableRowsCountSql({ table, filters, enforceExactCount }),
|
||||
{
|
||||
projectRef: projectRef ?? 'ref',
|
||||
role: impersonatedRole,
|
||||
}
|
||||
)
|
||||
const { result } = await executeSql(
|
||||
{
|
||||
projectRef,
|
||||
connectionString,
|
||||
sql: wrapWithRoleImpersonation(
|
||||
getTableRowsCountSqlQuery({ table, enforceExactCount, ...args }),
|
||||
{ projectRef: projectRef ?? 'ref', role: impersonatedRole }
|
||||
),
|
||||
queryKey: [
|
||||
...(queryKey ?? []),
|
||||
{
|
||||
table: { name: table?.name, schema: table?.schema },
|
||||
enforceExactCount,
|
||||
impersonatedRole,
|
||||
...args,
|
||||
},
|
||||
],
|
||||
isRoleImpersonationEnabled,
|
||||
sql,
|
||||
queryKey: ['table-rows-count', table.id],
|
||||
isRoleImpersonationEnabled: isRoleImpersonationEnabled(impersonatedRole),
|
||||
},
|
||||
signal
|
||||
)
|
||||
|
||||
return {
|
||||
count: result[0].count,
|
||||
is_estimate: result[0].is_estimate ?? false,
|
||||
} as TableRowsCount
|
||||
}
|
||||
|
||||
export const useTableRowsCountQuery = <TData = TableRowsCountData>(
|
||||
{ projectRef, connectionString, tableId, ...args }: Omit<TableRowsCountVariables, 'queryClient'>,
|
||||
{
|
||||
enabled = true,
|
||||
...options
|
||||
}: UseQueryOptions<TableRowsCountData, TableRowsCountError, TData> = {}
|
||||
) => {
|
||||
const queryClient = useQueryClient()
|
||||
return useQuery<TableRowsCountData, TableRowsCountError, TData>(
|
||||
tableRowKeys.tableRowsCount(projectRef, { table: { id: tableId }, ...args }),
|
||||
({ signal }) =>
|
||||
getTableRowsCount({ queryClient, projectRef, connectionString, tableId, ...args }, signal),
|
||||
{
|
||||
select(data) {
|
||||
return {
|
||||
count: data.result[0].count,
|
||||
is_estimate: data.result[0].is_estimate ?? false,
|
||||
} as TData
|
||||
},
|
||||
enabled: typeof projectRef !== 'undefined' && typeof table !== 'undefined',
|
||||
enabled: enabled && typeof projectRef !== 'undefined' && typeof tableId !== 'undefined',
|
||||
...options,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,34 +1,33 @@
|
||||
import type { QueryClient, QueryKey, UseQueryOptions } from '@tanstack/react-query'
|
||||
import {
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
type QueryClient,
|
||||
type UseQueryOptions,
|
||||
} from '@tanstack/react-query'
|
||||
|
||||
import { IS_PLATFORM } from 'common'
|
||||
import { Query } from 'components/grid/query/Query'
|
||||
import { parseSupaTable } from 'components/grid/SupabaseGrid.utils'
|
||||
import { Filter, Sort, SupaRow, SupaTable } from 'components/grid/types'
|
||||
import {
|
||||
JSON_TYPES,
|
||||
TEXT_TYPES,
|
||||
} from 'components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.constants'
|
||||
import { sqlKeys } from 'data/sql/keys'
|
||||
import { prefetchTableEditor } from 'data/table-editor/table-editor-query'
|
||||
import { KB } from 'lib/constants'
|
||||
import {
|
||||
ImpersonationRole,
|
||||
ROLE_IMPERSONATION_NO_RESULTS,
|
||||
wrapWithRoleImpersonation,
|
||||
} from 'lib/role-impersonation'
|
||||
import {
|
||||
isRoleImpersonationEnabled,
|
||||
useIsRoleImpersonationEnabled,
|
||||
} from 'state/role-impersonation-state'
|
||||
import {
|
||||
ExecuteSqlData,
|
||||
ExecuteSqlError,
|
||||
executeSql,
|
||||
useExecuteSqlQuery,
|
||||
} from '../sql/execute-sql-query'
|
||||
import { isRoleImpersonationEnabled } from 'state/role-impersonation-state'
|
||||
import { ExecuteSqlError, executeSql } from '../sql/execute-sql-query'
|
||||
import { getPagination } from '../utils/pagination'
|
||||
import { tableRowKeys } from './keys'
|
||||
import { THRESHOLD_COUNT } from './table-rows-count-query'
|
||||
import { formatFilterValue } from './utils'
|
||||
|
||||
type GetTableRowsArgs = {
|
||||
export interface GetTableRowsArgs {
|
||||
table?: SupaTable
|
||||
filters?: Filter[]
|
||||
sorts?: Sort[]
|
||||
@@ -135,7 +134,7 @@ export const fetchAllTableRows = async ({
|
||||
return rows.filter((row) => row[ROLE_IMPERSONATION_NO_RESULTS] !== 1)
|
||||
}
|
||||
|
||||
export const getTableRowsSqlQuery = ({
|
||||
export const getTableRowsSql = ({
|
||||
table,
|
||||
filters = [],
|
||||
sorts = [],
|
||||
@@ -199,58 +198,79 @@ export const getTableRowsSqlQuery = ({
|
||||
|
||||
export type TableRows = { rows: SupaRow[] }
|
||||
|
||||
export type TableRowsVariables = GetTableRowsArgs & {
|
||||
export type TableRowsVariables = Omit<GetTableRowsArgs, 'table'> & {
|
||||
queryClient: QueryClient
|
||||
projectRef?: string
|
||||
connectionString?: string
|
||||
queryKey?: QueryKey
|
||||
tableId?: number
|
||||
}
|
||||
|
||||
export type TableRowsData = TableRows
|
||||
export type TableRowsError = ExecuteSqlError
|
||||
|
||||
function getTableRowsQueryKey({ queryKey, table, impersonatedRole, ...args }: TableRowsVariables) {
|
||||
return [
|
||||
...(queryKey ?? []),
|
||||
export async function getTableRows(
|
||||
{
|
||||
queryClient,
|
||||
projectRef,
|
||||
connectionString,
|
||||
tableId,
|
||||
impersonatedRole,
|
||||
filters,
|
||||
sorts,
|
||||
limit,
|
||||
page,
|
||||
}: TableRowsVariables,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
const entity = await prefetchTableEditor(queryClient, {
|
||||
projectRef,
|
||||
connectionString,
|
||||
id: tableId,
|
||||
})
|
||||
if (!entity) {
|
||||
throw new Error('Table not found')
|
||||
}
|
||||
|
||||
const table = parseSupaTable(entity)
|
||||
|
||||
const sql = wrapWithRoleImpersonation(
|
||||
getTableRowsSql({ table, filters, sorts, limit, page, impersonatedRole }),
|
||||
{
|
||||
table: {
|
||||
name: table?.name,
|
||||
schema: table?.schema,
|
||||
columns: table?.columns.map((c) => c.name),
|
||||
},
|
||||
impersonatedRole,
|
||||
...args,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export const useTableRowsQuery = <TData extends TableRowsData = TableRowsData>(
|
||||
{ projectRef, connectionString, queryKey, table, impersonatedRole, ...args }: TableRowsVariables,
|
||||
options: UseQueryOptions<ExecuteSqlData, TableRowsError, TData> = {}
|
||||
) => {
|
||||
const isRoleImpersonationEnabled = useIsRoleImpersonationEnabled()
|
||||
|
||||
return useExecuteSqlQuery(
|
||||
projectRef: projectRef ?? 'ref',
|
||||
role: impersonatedRole,
|
||||
}
|
||||
)
|
||||
const { result } = await executeSql(
|
||||
{
|
||||
projectRef,
|
||||
connectionString,
|
||||
sql: wrapWithRoleImpersonation(getTableRowsSqlQuery({ table, ...args }), {
|
||||
projectRef: projectRef ?? 'ref',
|
||||
role: impersonatedRole,
|
||||
}),
|
||||
queryKey: getTableRowsQueryKey({ queryKey, table, impersonatedRole, ...args }),
|
||||
isRoleImpersonationEnabled,
|
||||
sql,
|
||||
queryKey: ['table-rows', table?.id],
|
||||
isRoleImpersonationEnabled: isRoleImpersonationEnabled(impersonatedRole),
|
||||
},
|
||||
{
|
||||
select(data: { result: Record<string, any>[] }) {
|
||||
const rows = data.result.map((x, index) => {
|
||||
return { idx: index, ...x } as SupaRow
|
||||
})
|
||||
signal
|
||||
)
|
||||
|
||||
return {
|
||||
rows,
|
||||
} as TData
|
||||
},
|
||||
enabled: typeof projectRef !== 'undefined' && typeof table !== 'undefined',
|
||||
const rows = result.map((x: any, index: number) => {
|
||||
return { idx: index, ...x }
|
||||
}) as SupaRow[]
|
||||
|
||||
return {
|
||||
rows,
|
||||
}
|
||||
}
|
||||
|
||||
export const useTableRowsQuery = <TData = TableRowsData>(
|
||||
{ projectRef, connectionString, tableId, ...args }: Omit<TableRowsVariables, 'queryClient'>,
|
||||
{ enabled = true, ...options }: UseQueryOptions<TableRowsData, TableRowsError, TData> = {}
|
||||
) => {
|
||||
const queryClient = useQueryClient()
|
||||
return useQuery<TableRowsData, TableRowsError, TData>(
|
||||
tableRowKeys.tableRows(projectRef, { table: { id: tableId }, ...args }),
|
||||
({ signal }) =>
|
||||
getTableRows({ queryClient, projectRef, connectionString, tableId, ...args }, signal),
|
||||
{
|
||||
enabled: enabled && typeof projectRef !== 'undefined' && typeof tableId !== 'undefined',
|
||||
...options,
|
||||
}
|
||||
)
|
||||
@@ -258,23 +278,17 @@ export const useTableRowsQuery = <TData extends TableRowsData = TableRowsData>(
|
||||
|
||||
export function prefetchTableRows(
|
||||
client: QueryClient,
|
||||
{ projectRef, connectionString, queryKey, table, impersonatedRole, ...args }: TableRowsVariables
|
||||
{
|
||||
projectRef,
|
||||
connectionString,
|
||||
tableId,
|
||||
impersonatedRole,
|
||||
...args
|
||||
}: Omit<TableRowsVariables, 'queryClient'>
|
||||
) {
|
||||
return client.fetchQuery(
|
||||
sqlKeys.query(projectRef, getTableRowsQueryKey({ queryKey, table, impersonatedRole, ...args })),
|
||||
tableRowKeys.tableRows(projectRef, { table: { id: tableId }, ...args }),
|
||||
({ signal }) =>
|
||||
executeSql(
|
||||
{
|
||||
projectRef,
|
||||
connectionString,
|
||||
sql: wrapWithRoleImpersonation(getTableRowsSqlQuery({ table, ...args }), {
|
||||
projectRef: projectRef ?? 'ref',
|
||||
role: impersonatedRole,
|
||||
}),
|
||||
queryKey: getTableRowsQueryKey({ queryKey, table, impersonatedRole, ...args }),
|
||||
isRoleImpersonationEnabled: isRoleImpersonationEnabled(impersonatedRole),
|
||||
},
|
||||
signal
|
||||
)
|
||||
getTableRows({ queryClient: client, projectRef, connectionString, tableId, ...args }, signal)
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
import SqlString from 'sqlstring'
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
|
||||
import { post } from 'lib/common/fetch'
|
||||
import { tryParseInt } from 'lib/helpers'
|
||||
@@ -7,6 +8,8 @@ import { PG_META_URL } from 'lib/constants'
|
||||
import apiWrapper from 'lib/api/apiWrapper'
|
||||
import { constructHeaders } from 'lib/api/apiHelpers'
|
||||
|
||||
const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_KEY!)
|
||||
|
||||
export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler)
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
@@ -15,10 +18,12 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
switch (method) {
|
||||
case 'GET':
|
||||
return handleGetAll(req, res)
|
||||
case 'POST':
|
||||
return handlePost(req, res)
|
||||
case 'DELETE':
|
||||
return handleDelete(req, res)
|
||||
default:
|
||||
res.setHeader('Allow', ['GET', 'DELETE'])
|
||||
res.setHeader('Allow', ['GET', 'POST', 'DELETE'])
|
||||
res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } })
|
||||
}
|
||||
}
|
||||
@@ -110,18 +115,18 @@ const handleGetAll = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
return res.status(200).json({ total, users: getUsers })
|
||||
}
|
||||
|
||||
const handleDelete = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const headers = constructHeaders(req.headers)
|
||||
const handlePost = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const { email, password, email_confirm } = req.body
|
||||
const { data, error } = await supabase.auth.admin.createUser({ email, password, email_confirm })
|
||||
|
||||
const payload = req.body
|
||||
const query = { query: `DELETE from auth.users where id='${payload.id}';` }
|
||||
if (error) return res.status(400).json({ error: { message: error.message } })
|
||||
return res.status(200).json(data.user)
|
||||
}
|
||||
|
||||
const response = await post(`${PG_META_URL}/query`, query, { headers })
|
||||
const handleDelete = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const { id } = req.body
|
||||
const { data, error } = await supabase.auth.admin.deleteUser(id)
|
||||
|
||||
if (response.error) {
|
||||
console.error('Delete Users POST:', response.error)
|
||||
return res.status(400).json({ error: response.error })
|
||||
}
|
||||
|
||||
return res.status(200).json(response)
|
||||
if (error) return res.status(400).json({ error: { message: error.message } })
|
||||
return res.status(200).json(data.user)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
|
||||
import apiWrapper from 'lib/api/apiWrapper'
|
||||
|
||||
const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_KEY!)
|
||||
|
||||
export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler)
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const { method } = req
|
||||
|
||||
switch (method) {
|
||||
case 'PATCH':
|
||||
return handlePatch(req, res)
|
||||
default:
|
||||
res.setHeader('Allow', ['PATCH'])
|
||||
res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } })
|
||||
}
|
||||
}
|
||||
|
||||
const handlePatch = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const { id } = req.query
|
||||
const { ban_duration } = req.body
|
||||
const { data, error } = await supabase.auth.admin.updateUserById(id as string, { ban_duration })
|
||||
|
||||
if (error) return res.status(400).json({ error: { message: error.message } })
|
||||
return res.status(200).json(data.user)
|
||||
}
|
||||
@@ -1,16 +1,29 @@
|
||||
import { Paintbrush, Trash2 } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import DeleteQueue from 'components/interfaces/Integrations/Queues/SingleQueue/DeleteQueue'
|
||||
import PurgeQueue from 'components/interfaces/Integrations/Queues/SingleQueue/PurgeQueue'
|
||||
import { QUEUE_MESSAGE_TYPE } from 'components/interfaces/Integrations/Queues/SingleQueue/Queue.utils'
|
||||
import { QueueMessagesDataGrid } from 'components/interfaces/Integrations/Queues/SingleQueue/QueueDataGrid'
|
||||
import { QueueFilters } from 'components/interfaces/Integrations/Queues/SingleQueue/QueueFilters'
|
||||
import { SendMessageModal } from 'components/interfaces/Integrations/Queues/SingleQueue/SendMessageModal'
|
||||
import ProjectIntegrationsLayout from 'components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsLayout'
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import { FormHeader } from 'components/ui/Forms/FormHeader'
|
||||
import { useQueueMessagesInfiniteQuery } from 'data/database-queues/database-queue-messages-infinite-query'
|
||||
import type { NextPageWithLayout } from 'types'
|
||||
import { Button, LoadingLine } from 'ui'
|
||||
import {
|
||||
Breadcrumb_Shadcn_,
|
||||
BreadcrumbItem_Shadcn_,
|
||||
BreadcrumbLink_Shadcn_,
|
||||
BreadcrumbList_Shadcn_,
|
||||
BreadcrumbPage_Shadcn_,
|
||||
BreadcrumbSeparator_Shadcn_,
|
||||
Button,
|
||||
LoadingLine,
|
||||
Separator,
|
||||
} from 'ui'
|
||||
|
||||
const QueueMessagesPage: NextPageWithLayout = () => {
|
||||
// TODO: Change this to the correct permissions
|
||||
@@ -23,6 +36,8 @@ const QueueMessagesPage: NextPageWithLayout = () => {
|
||||
|
||||
const { name: queueName } = useParams()
|
||||
const { project } = useProjectContext()
|
||||
const [sendMessageModalShown, setSendMessageModalShown] = useState(false)
|
||||
const [purgeQueueModalShown, setPurgeQueueModalShown] = useState(false)
|
||||
const [deleteQueueModalShown, setDeleteQueueModalShown] = useState(false)
|
||||
const [selectedTypes, setSelectedTypes] = useState<QUEUE_MESSAGE_TYPE[]>([])
|
||||
|
||||
@@ -38,34 +53,72 @@ const QueueMessagesPage: NextPageWithLayout = () => {
|
||||
)
|
||||
const messages = useMemo(() => data?.pages.flatMap((p) => p), [data?.pages])
|
||||
|
||||
if (isLoading && isError) {
|
||||
if (isError) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<FormHeader
|
||||
className="py-4 px-6 !mb-0"
|
||||
title={`Queue ${queueName}`}
|
||||
actions={
|
||||
<Button type="danger" onClick={() => setDeleteQueueModalShown(true)}>
|
||||
Delete the queue
|
||||
<div className="flex items-center justify-between gap-x-4 py-4 px-6 mb-0">
|
||||
<div className="space-y-1 flex-shrink">
|
||||
<Breadcrumb_Shadcn_>
|
||||
<BreadcrumbList_Shadcn_>
|
||||
<BreadcrumbItem_Shadcn_>
|
||||
<BreadcrumbLink_Shadcn_ asChild className="text-xl">
|
||||
<Link href={`/project/${project?.ref}/integrations/queues`}>Queues</Link>
|
||||
</BreadcrumbLink_Shadcn_>
|
||||
</BreadcrumbItem_Shadcn_>
|
||||
<BreadcrumbSeparator_Shadcn_ />
|
||||
<BreadcrumbItem_Shadcn_>
|
||||
<BreadcrumbPage_Shadcn_ className="text-xl">{queueName}</BreadcrumbPage_Shadcn_>
|
||||
</BreadcrumbItem_Shadcn_>
|
||||
</BreadcrumbList_Shadcn_>
|
||||
</Breadcrumb_Shadcn_>
|
||||
</div>
|
||||
<div className="flex gap-x-2">
|
||||
<Button
|
||||
type="text"
|
||||
onClick={() => setPurgeQueueModalShown(true)}
|
||||
icon={<Paintbrush />}
|
||||
title="Purge messages"
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
onClick={() => setDeleteQueueModalShown(true)}
|
||||
icon={<Trash2 />}
|
||||
title="Delete queue"
|
||||
/>
|
||||
<Separator orientation="vertical" className="h-[26px]" />
|
||||
<Button type="primary" onClick={() => setSendMessageModalShown(true)}>
|
||||
Add message
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* <DocsButton href={docsUrl} />} */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<QueueFilters selectedTypes={selectedTypes} setSelectedTypes={setSelectedTypes} />
|
||||
<LoadingLine loading={isFetching} />
|
||||
<QueueMessagesDataGrid
|
||||
messages={messages || []}
|
||||
isLoading={isLoading}
|
||||
showMessageModal={() => setSendMessageModalShown(true)}
|
||||
fetchNextPage={fetchNextPage}
|
||||
/>
|
||||
<SendMessageModal
|
||||
visible={sendMessageModalShown}
|
||||
onClose={() => setSendMessageModalShown(false)}
|
||||
/>
|
||||
<DeleteQueue
|
||||
queueName={queueName!}
|
||||
visible={deleteQueueModalShown}
|
||||
onClose={() => setDeleteQueueModalShown(false)}
|
||||
/>
|
||||
<PurgeQueue
|
||||
queueName={queueName!}
|
||||
visible={purgeQueueModalShown}
|
||||
onClose={() => setPurgeQueueModalShown(false)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,473 @@
|
||||
---
|
||||
title: 'Executing Dynamic JavaScript Code on Supabase with Edge Functions'
|
||||
description: 'Learn how to execute dynamic JavaScript code on Supabase using Edge Functions.'
|
||||
author: rodrigo_mansueli
|
||||
image: 2024-11-13-supabase-dynamic-functions/dynamic-og.png
|
||||
thumb: 2024-11-13-supabase-dynamic-functions/dynamic-og.png
|
||||
categories:
|
||||
- engineering
|
||||
tags:
|
||||
- edge-funcion
|
||||
- funcions
|
||||
- AI
|
||||
date: '2024-11-13'
|
||||
toc_depth: 3
|
||||
---
|
||||
|
||||
We're always looking for ways to improve the developer experience and reduce complexity across your application development pipeline. One way you can use Supabase to do that is with dynamic JavaScript in Edge Functions. This greatly increases the versatility of your edge functions and reduces the need for you to redeploy your functions if you need to change business logic.
|
||||
|
||||
### Introduction to Edge Functions
|
||||
|
||||
Edge Functions in Supabase are serverless functions that execute in response to HTTP requests. These functions are deployed at the edge, meaning they run close to the user's location, resulting in faster response times.
|
||||
|
||||
### Why Use Dynamic Code Execution?
|
||||
|
||||
Dynamic code execution allows you to modify and run JavaScript code on the fly without having to redeploy your function each time the code changes. This is particularly useful when you need the flexibility to execute different logic depending on the incoming request, without incurring the overhead of redeployment.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
To follow along, you will need:
|
||||
|
||||
- A Supabase project
|
||||
- Supabase CLI installed on your local machine
|
||||
- Orb Stack or Docker Desktop installed on your local machine
|
||||
- Environment variables set up in Vault, ensuring it passes validation in the function (e.g., `service_role`)
|
||||
|
||||
> Edge Functions defaults to the verification of the JWT, so it could be called with the ANON API Key. Make sure to implement proper security measures.
|
||||
|
||||
## Install the SQL script from the repo
|
||||
|
||||
We have a repo with the SQL script to create helper functions to support the dynamic execution of JavaScript code. You can find the repo here: [supa-dynamic](https://github.com/mansueli/supa-dynamic)
|
||||
|
||||
Install the SQL script `supa-dynamic--0.1.sql` from the repo in your Supabase project. (You can copy and paste the code from the repo into the SQL editor in your Supabase project.)
|
||||
These are the functions we'll use to execute the JavaScript code:
|
||||
|
||||
- `edge.http_request(url text, method text, headers jsonb, params jsonb, payload jsonb, timeout_ms integer) RETURNS jsonb`: Makes an HTTP request with the specified parameters.
|
||||
- `edge_wrapper(code text) RETURNS text`: Executes the provided JavaScript code.
|
||||
- `edge.get_secret(secret_name text) RETURNS text`: Retrieves a secret from Vault.
|
||||
|
||||
### Deep Dive into the helper functions (optional)
|
||||
|
||||
You can skip this section if you are only interested in using the dynamic execution of JavaScript code. However, if you want to understand how the helper functions work, keep reading.
|
||||
|
||||
### `edge.http_request` Function
|
||||
|
||||
This function handles the actual HTTP request and processes the response. It ensures consistency in response format.
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION edge.http_request(
|
||||
url TEXT,
|
||||
method TEXT DEFAULT 'POST',
|
||||
headers JSONB DEFAULT '{"Content-Type": "application/json"}'::jsonb,
|
||||
params JSONB DEFAULT '{}'::jsonb,
|
||||
payload JSONB DEFAULT '{}'::jsonb,
|
||||
timeout_ms INTEGER DEFAULT 5000
|
||||
) RETURNS jsonb AS $$
|
||||
DECLARE
|
||||
http_response extensions.http_response;
|
||||
status_code integer := 0;
|
||||
response_json jsonb;
|
||||
response_text text;
|
||||
header_array extensions.http_header[];
|
||||
request extensions.http_request;
|
||||
BEGIN
|
||||
-- Set the timeout option
|
||||
IF timeout_ms > 0 THEN
|
||||
PERFORM http_set_curlopt('CURLOPT_TIMEOUT_MS', timeout_ms::text);
|
||||
END IF;
|
||||
|
||||
-- Convert headers JSONB to http_header array
|
||||
SELECT array_agg(extensions.http_header(key, value::text))
|
||||
FROM jsonb_each_text(headers)
|
||||
INTO header_array;
|
||||
|
||||
-- Construct the http_request composite type
|
||||
request := ROW(method, url, header_array, 'application/json', payload::text)::extensions.http_request;
|
||||
|
||||
-- Make the HTTP request
|
||||
http_response := http(request);
|
||||
status_code := http_response.status;
|
||||
|
||||
-- Attempt to extract JSONB response content
|
||||
BEGIN
|
||||
response_json := http_response.content::jsonb;
|
||||
EXCEPTION
|
||||
WHEN others THEN
|
||||
response_text := http_response.content;
|
||||
response_json := jsonb_build_object('status_code', status_code, 'response', response_text);
|
||||
END;
|
||||
|
||||
RETURN jsonb_build_object('status_code', status_code, 'response', response_json);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
```
|
||||
|
||||
### `edge_wrapper` Function
|
||||
|
||||
The `edge_wrapper` function manages HTTP requests with features like retries, custom headers, and region selection. Below are the parameters it accepts:
|
||||
|
||||
- **`url`**: The endpoint to call.
|
||||
- **`method`**: HTTP method, defaulting to `POST`.
|
||||
- **`headers`**: Custom headers to include, including region information.
|
||||
- **`timeout_ms`**: Timeout duration in milliseconds.
|
||||
- **`max_retries`**: Maximum retry attempts for the request.
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION edge.edge_wrapper(
|
||||
url TEXT,
|
||||
method TEXT DEFAULT 'POST',
|
||||
headers JSONB DEFAULT '{"Content-Type": "application/json"}'::jsonb,
|
||||
params JSONB DEFAULT '{}'::jsonb,
|
||||
payload JSONB DEFAULT '{}'::jsonb, -- Payload as JSONB
|
||||
timeout_ms INTEGER DEFAULT 5000,
|
||||
max_retries INTEGER DEFAULT 0,
|
||||
allowed_regions TEXT[] DEFAULT NULL
|
||||
) RETURNS jsonb AS $$
|
||||
DECLARE
|
||||
retry_count INTEGER := 0;
|
||||
retry_delays DOUBLE PRECISION[] := ARRAY[0, 0.250, 0.500, 1.000, 2.500, 5.000];
|
||||
succeeded BOOLEAN := FALSE;
|
||||
current_region_index INTEGER := 1; -- Start index at 1 for PostgreSQL array
|
||||
combined_headers JSONB;
|
||||
response_json JSONB;
|
||||
BEGIN
|
||||
-- Validate headers, params, and payload are JSON objects
|
||||
IF headers IS NULL OR NOT jsonb_typeof(headers) = 'object' THEN
|
||||
RAISE EXCEPTION 'Invalid headers parameter: %', headers;
|
||||
END IF;
|
||||
|
||||
IF params IS NULL OR NOT jsonb_typeof(params) = 'object' THEN
|
||||
RAISE EXCEPTION 'Invalid params parameter: %', params;
|
||||
END IF;
|
||||
|
||||
IF payload IS NULL OR NOT jsonb_typeof(payload) = 'object' THEN
|
||||
RAISE EXCEPTION 'Invalid payload parameter: %', payload;
|
||||
END IF;
|
||||
|
||||
-- Validate allowed_regions if provided
|
||||
IF allowed_regions IS NOT NULL AND cardinality(allowed_regions) = 0 THEN
|
||||
RAISE EXCEPTION 'allowed_regions parameter cannot be an empty array';
|
||||
END IF;
|
||||
|
||||
-- Check if retry_delays has enough elements
|
||||
IF cardinality(retry_delays) < max_retries + 1 THEN
|
||||
RAISE EXCEPTION 'retry_delays array must have at least % elements', max_retries + 1;
|
||||
END IF;
|
||||
|
||||
-- Retry loop
|
||||
WHILE NOT succeeded AND retry_count <= max_retries LOOP
|
||||
combined_headers := headers;
|
||||
|
||||
-- Set x-region header if allowed_regions is provided
|
||||
IF allowed_regions IS NOT NULL AND cardinality(allowed_regions) > 0 THEN
|
||||
combined_headers := combined_headers || jsonb_build_object('x-region', allowed_regions[current_region_index]);
|
||||
END IF;
|
||||
|
||||
-- Sleep if not the first attempt
|
||||
IF retry_count > 0 THEN
|
||||
PERFORM pg_sleep(retry_delays[retry_count]);
|
||||
END IF;
|
||||
|
||||
retry_count := retry_count + 1;
|
||||
|
||||
-- Increment region index, wrapping around if necessary
|
||||
IF allowed_regions IS NOT NULL AND cardinality(allowed_regions) > 0 THEN
|
||||
current_region_index := current_region_index + 1;
|
||||
IF current_region_index > cardinality(allowed_regions) THEN
|
||||
current_region_index := 1;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
BEGIN
|
||||
RAISE WARNING 'headers:%s', combined_headers;
|
||||
|
||||
-- Call the simplified HTTP request function
|
||||
response_json := edge.http_request(url, method, combined_headers, params, payload, timeout_ms);
|
||||
|
||||
-- Check the status code
|
||||
IF (response_json->>'status_code')::INTEGER < 500 THEN
|
||||
succeeded := TRUE;
|
||||
END IF;
|
||||
EXCEPTION
|
||||
WHEN OTHERS THEN
|
||||
IF retry_count > max_retries THEN
|
||||
RAISE EXCEPTION 'HTTP request failed after % retries. SQL Error: { %, % }',
|
||||
max_retries, SQLERRM, SQLSTATE;
|
||||
END IF;
|
||||
END;
|
||||
END LOOP;
|
||||
|
||||
RETURN response_json;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
```
|
||||
|
||||
To securely manage secrets, you will need to set your `service_role_key` in Vault. Here’s how you can create a function to retrieve secrets:
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION edge.get_secret(secret_name text) RETURNS text
|
||||
LANGUAGE "plpgsql"
|
||||
AS $$
|
||||
DECLARE
|
||||
decrypted text;
|
||||
BEGIN
|
||||
IF current_setting('request.jwt.claims', true)::jsonb->>'role' = 'service_role' OR current_user = 'postgres' THEN
|
||||
SELECT decrypted_secret
|
||||
INTO decrypted
|
||||
FROM vault.decrypted_secrets
|
||||
WHERE name = secret_name;
|
||||
RETURN decrypted;
|
||||
ELSE
|
||||
RAISE EXCEPTION 'Access denied: only service_role or postgres user can execute this function.';
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
```
|
||||
|
||||
This function can retrieve the `service_role` secret from [Vault](https://supabase.com/dashboard/project/_/settings/vault/secrets), it also ensures that only authorized roles can access sensitive environment variables.
|
||||
|
||||
## Setting Up the Edge Function
|
||||
|
||||
Let's dive into the code and set up our dynamic JavaScript executor Edge Function using Deno. Below is an overview of how to accomplish this.
|
||||
|
||||
### Code Walkthrough
|
||||
|
||||
We'll create a function named `multi-purpose`:
|
||||
|
||||
```jsx
|
||||
supabase functions new multi-purpose
|
||||
```
|
||||
|
||||
Now, we'll edit the code adding verification and the eval function, including the supabase client so we have it ready without the need to import.
|
||||
|
||||
```tsx
|
||||
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
||||
|
||||
// Import the supabase client
|
||||
import { createClient } from "<https://esm.sh/@supabase/supabase-js@2>";
|
||||
|
||||
console.log("===\\n\\tBooted Edge Worker!\\n===\\n");
|
||||
const supabase_url = Deno.env.get("SUPABASE_URL") ?? "";
|
||||
const service_role = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY");
|
||||
// Set the permission to service_role key:
|
||||
const supabase = createClient(supabase_url, service_role);
|
||||
// This allows us to use Supabase.ai in the function
|
||||
const session = new Supabase.ai.Session('gte-small');
|
||||
|
||||
Deno.serve(async (req: Request) =>
|
||||
const authorization = req.headers.get("Authorization");
|
||||
if (!authorization) throw new Error("Authorization header is missing.");
|
||||
// Ensures that the function is called with service_role to prevent missuse
|
||||
if (!authorization.includes(service_role)) {
|
||||
throw new Error("Authorization header is invalid.");
|
||||
}
|
||||
|
||||
const { code } = await req.json();
|
||||
try {
|
||||
// Wrap the provided code in an async function context
|
||||
const asyncFunction = new Function('supabase', `
|
||||
return (async () => {
|
||||
${code.replace(/\\\\/g, '')}
|
||||
})();
|
||||
`);
|
||||
// Pass the Supabase client as the scope for the function to use:
|
||||
const data = await asyncFunction(supabase);
|
||||
console.log(data);
|
||||
return new Response(
|
||||
JSON.stringify({ data }),
|
||||
{ headers: { 'Content-Type': 'application/json', 'Connection': 'keep-alive' } },
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error executing user code:", error);
|
||||
return new Response(
|
||||
JSON.stringify({ error: "An error occurred -> " + error.message }),
|
||||
{ status: 500, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
```
|
||||
|
||||
> Note: If you need more details, check the full guide to [create an edge function](https://supabase.com/docs/guides/functions/quickstart#create-an-edge-function).
|
||||
|
||||
### Step-by-Step Walkthrough
|
||||
|
||||
1. **Validate Authorization**: First, we ensure the request contains a valid authorization header. (this prevents calls from anon users)
|
||||
|
||||
```jsx
|
||||
const authorization = req.headers.get('Authorization')
|
||||
if (!authorization) throw new Error('Authorization header is missing.')
|
||||
// Ensures that the function is called with service_role to prevent missuse
|
||||
if (!authorization.includes(service_role)) {
|
||||
throw new Error('Authorization header is invalid.')
|
||||
}
|
||||
```
|
||||
|
||||
1. **Receive JavaScript Code Payload**: Extract the `code` from the request body.
|
||||
|
||||
```jsx
|
||||
const { code } = await req.json()
|
||||
```
|
||||
|
||||
1. **Wrap Code in Async Context**: Use `new Function()` to create an async function that executes the incoming JavaScript code. This allows async calls in the code to be executed:
|
||||
|
||||
```jsx
|
||||
try {
|
||||
// Wrap the provided code in an async function context
|
||||
const asyncFunction = new Function('supabase', `
|
||||
return (async () => {
|
||||
${code.replace(/\\\\/g, '')}
|
||||
})();
|
||||
`);
|
||||
}
|
||||
```
|
||||
|
||||
1. **Execute and Return Results**: Run the JavaScript code, which can interact with Supabase via the provided client, and return the results.
|
||||
|
||||
```jsx
|
||||
// Pass the Supabase client as the scope for the function to use:
|
||||
const data = await asyncFunction(supabase)
|
||||
console.log(data)
|
||||
return new Response(JSON.stringify({ data }), {
|
||||
headers: { 'Content-Type': 'application/json', Connection: 'keep-alive' },
|
||||
})
|
||||
```
|
||||
|
||||
### Deploying the Edge Function
|
||||
|
||||
To deploy this Edge Function, you'll need to use the Supabase CLI. Ensure you have Docker installed and running on your local machine. Follow these steps to deploy:
|
||||
|
||||
1. **Install the Supabase CLI**: If you haven't already, install the Supabase CLI by following the instructions in the [Supabase CLI Documentation](https://supabase.com/docs/guides/cli).
|
||||
2. **Log In to Supabase**: Use the command `supabase login` to authenticate your account.
|
||||
3. **Deploy the Function**: Run the command `supabase functions deploy <function_name>` to deploy your Edge Function. Replace `<function_name>` with the desired name for your function.
|
||||
|
||||
### Setting Environment Variables in Vault
|
||||
|
||||
## Creating the main function to interact with the edge function
|
||||
|
||||
We are using the helper functions defined earlier to create a function that interacts with the edge function. This function will execute the dynamic JavaScript code and return the results.
|
||||
This is the main function that will be used to execute the dynamic JavaScript code and return the results.
|
||||
|
||||
### `edge.exec` Function
|
||||
|
||||
The `edge.exec` is a simple function leverages `edge_wrapper` to execute dynamic JavaScript code. Here's an example of how it is structured:
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION edge.exec(data text) RETURNS JSONB LANGUAGE plpgsql
|
||||
AS $function$
|
||||
DECLARE
|
||||
custom_headers JSONB;
|
||||
-- Example restricting regions available to Europe
|
||||
allowed_regions TEXT[] := ARRAY['eu-west-1', 'eu-west-2', 'eu-west-3', 'eu-north-1', 'eu-central-1'];
|
||||
BEGIN
|
||||
-- Set headers with anon key and Content-Type
|
||||
custom_headers := jsonb_build_object(
|
||||
'Authorization', 'Bearer ' || edge.get_secret('service_role_key'),
|
||||
'Content-Type', 'application/json',
|
||||
'x-region', allowed_regions
|
||||
);
|
||||
-- Call edge_wrapper function with default values
|
||||
RETURN edge.edge_wrapper(
|
||||
url := ('https://<ref>.supabase.co/functions/v1/multi-purpose'),
|
||||
headers := custom_headers,
|
||||
payload := jsonb_build_object('code', data),
|
||||
max_retries := 5,
|
||||
allowed_regions := allowed_regions
|
||||
);
|
||||
END;
|
||||
$function$;
|
||||
|
||||
```
|
||||
|
||||
### Executing Dynamic JavaScript Code
|
||||
|
||||
The key to executing the dynamic JavaScript code is wrapping it in an `async` function context using `new Function()`. This approach lets you evaluate the code in isolation while retaining access to the `supabase` client for interacting with your database. You can check the examples of how to use this calling the [supabase client](https://www.notion.so/Executing-Dynamic-JavaScript-Code-on-Supabase-with-Edge-Functions-1125004b775f80ca97d4c367a2cfcd7d?pvs=21) or even [generating embeddings](https://www.notion.so/Executing-Dynamic-JavaScript-Code-on-Supabase-with-Edge-Functions-1125004b775f80ca97d4c367a2cfcd7d?pvs=21).
|
||||
|
||||
### Example of Using Supabase Client Libraries
|
||||
|
||||
To demonstrate the execution of dynamic JavaScript, you can use the Supabase client libraries within the SQL context. Here’s an example query:
|
||||
|
||||
```sql
|
||||
SELECT edge.exec(
|
||||
$js$
|
||||
const { data, error } = await supabase.rpc('postgres_function', {'foo': 'bar'});
|
||||
if (error) {
|
||||
return new Response(JSON.stringify({ error: "An error occurred ->" + error.message }), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
return data;
|
||||
$js$
|
||||
);
|
||||
|
||||
```
|
||||
|
||||
## Using the Edge Function in Practice
|
||||
|
||||
### Example: Generating Embeddings
|
||||
|
||||
The `edge.exec` function allows for dynamic JavaScript execution, such as interacting with an AI session to generate embeddings. When executed, the JavaScript code within the SQL context runs through the edge function, returning results to the database.
|
||||
|
||||
```sql
|
||||
select edge.exec(
|
||||
$js$
|
||||
|
||||
const session = new Supabase.ai.Session('gte-small');
|
||||
return await session.run('hello world');
|
||||
|
||||
$js$);
|
||||
```
|
||||
|
||||
You can also create a Postgres function to generate embeddings:
|
||||
|
||||
```
|
||||
CREATE OR REPLACE FUNCTION edge.generate_embedding(input_text TEXT) RETURNS JSONB AS $$
|
||||
DECLARE
|
||||
response JSONB;
|
||||
BEGIN
|
||||
-- Call the edge function to generate the embedding for the provided text
|
||||
response := edge.exec(
|
||||
format(
|
||||
$js$
|
||||
const session = new Supabase.ai.Session('gte-small');
|
||||
return await session.run(%L);
|
||||
$js$,
|
||||
input_text
|
||||
)
|
||||
);
|
||||
RETURN response->'response'->'data';
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
select edge.generate_embedding('The quick brown fox jumps over the lazy dog');
|
||||
|
||||
-- response:
|
||||
-- [-0.07254139333963394,-0.02173878252506256,0.042930446565151215,0.04853367060422897,0.015609614551067352,0.02912059798836708,0.0371023565530777,0.05054798722267151,0.0035842431243509054,0.0015563230263069272,0.0009484672918915749,-0.09247169643640518,0.04190639406442642,0.05874202027916908,-0.012341015040874481,0.01661474071443081,-0.013452880084514618,0.003742767730727792,-0.07664268463850021,0.03231268376111984,0.0006968052475713193,-0.06508929282426834,-0.04956015944480896,-0.014327225275337696,0.03270547464489937,0.01635774038732052,-0.022707758471369743,-0.007586371619254351,-0.03548099845647812,-0.17844657599925995,0.03325255215167999,-0.07009242475032806,0.02982083335518837,-0.05649203434586525,-0.006693259347230196,-0.02781110256910324,-0.01687553897500038,0.04976152256131172,-0.015715090557932854,0.038247860968112946,0.040495794266462326,-0.007263457402586937,-0.019288228824734688,-0.0527581050992012,-0.0065462407656013966,-0.022786622866988182,-0.04975651577115059,-0.04053974151611328,0.03047902137041092,-0.05064946785569191,-0.023929744958877563,-0.03891737014055252,0.03785012289881706,-0.0133274607360363,0.03001898154616356,-0.007281183265149593,0.060004156082868576,0.017414024099707603,0.025516854599118233,0.029599720612168312,0.02893918938934803,0.03455337509512901,-0.14698833227157593,0.09387505799531937,0.05768263339996338,0.019130567088723183,-0.0380706787109375,-0.04105521738529205,0.008963614702224731,0.012743324972689152,0.009223062545061111,0.060711149126291275,0.007398003712296486,0.04229794815182686,0.046996768563985825,-0.003397924592718482,0.00808036606758833,0.022617157548666,-0.01847437582910061,0.0026343590579926968,-0.010598739609122276,-0.037673674523830414,-0.04375630244612694,-0.0007789010996930301,-0.007935777306556702,-0.03272915259003639,0.021433845162391663,-0.07967976480722427,0.06888656318187714,0.07489841431379318,-0.02783842757344246,-0.006374717690050602,-0.035476282238960266,0.006344574969261885,-0.03357071802020073,-0.036727335304021835,0.012309364043176174,-0.00006389369809767231,-0.053050097078084946,0.19709722697734833,-0.05575009435415268,0.05757850036025047,0.0951322615146637,-0.04633559286594391,0.03476420044898987,0.012983368709683418,0.0004390157700981945,0.010212302207946777,-0.012741461396217346,0.014706282876431942,0.03321540355682373,-0.006495281588286161,0.041682176291942596,0.003406582633033395,0.02581774815917015,-0.0007246752502396703,0.011133069172501564,0.08353550732135773,0.006477882619947195,0.00224463758058846,0.020395604893565178,-0.013416256755590439,0.05663946643471718,-0.028388522565364838,0.019082417711615562,-0.08387858420610428,0.054498571902513504,0.10694538056850433,0.06286843866109848,0.03180928900837898,0.037740662693977356,-0.07479764521121979,0.010231229476630688,-0.04866624251008034,0.004061027429997921,0.0362103171646595,-0.009540606290102005,0.00915283989161253,0.031154874712228775,-0.04876647889614105,-0.015956921502947807,-0.1429857611656189,-0.01470054779201746,-0.09399641305208206,-0.019157350063323975,0.02896934375166893,-0.018669532611966133,0.014991801232099533,-0.06764508783817291,0.027312103658914566,-0.003859955817461014,0.025718173012137413,-0.018675100058317184,-0.016409857198596,-0.021459592506289482,0.004702075384557247,-0.0323822982609272,0.10394860059022903,-0.020106177777051926,-0.008876764215528965,-0.027185838669538498,0.0003392586368136108,-0.009877108968794346,-0.0004303457390051335,0.04185814782977104,-0.05188998952507973,-0.021185973659157753,0.00026368125691078603,-0.02180171199142933,-0.03400561958551407,0.020068379119038582,0.034275852143764496,-0.10943055897951126,0.031987469643354416,0.054017845541238785,-0.009243185631930828,-0.07103140652179718,0.00785127654671669,-0.0040434580296278,-0.05036382004618645,0.07858535647392273,-0.08356015384197235,-0.06914680451154709,0.06180981919169426,0.043073058128356934,-0.020246226340532303,-0.015496478416025639,-0.005946696270257235,0.006562687456607819,0.04845070466399193,-0.029123008251190186,0.02194702997803688,0.002446065191179514,-0.06825454533100128,-0.07056894898414612,0.01598423719406128,-0.04185032472014427,-0.01633128523826599,0.014294272288680077,-0.01768324337899685,0.05590462312102318,-0.044063832610845566,0.02461099997162819,0.0006756667862646282,0.07429251074790955,0.011551265604794025,0.014212443493306637,-0.02237367257475853,0.039057254791259766,0.000325449975207448,-0.004185846075415611,-0.003040974261239171,0.01800958439707756,-0.02479490265250206,-0.019247515127062798,0.04366869106888771,-0.027130864560604095,0.018955133855342865,0.03239727392792702,0.03226468712091446,0.06487660109996796,-0.06456360220909119,0.0006639647181145847,-0.20788206160068512,0.05066373199224472,-0.012870946899056435,-0.034873317927122116,0.023824242874979973,-0.02305314689874649,0.030056791380047798,-0.06937119364738464,0.0642433762550354,0.05418730527162552,0.06050065532326698,-0.04655877873301506,-0.026898164302110672,-0.003803820814937353,0.002598312683403492Line truncated
|
||||
```
|
||||
|
||||
### Example: Creating Users via Admin API
|
||||
|
||||
You can also leverage the admin API to create users:
|
||||
|
||||
```jsx
|
||||
select edge.exec(
|
||||
$js$
|
||||
|
||||
const { data, error } = await supabase.auth.admin.createUser({
|
||||
email: 'user@email.com',
|
||||
password: 'password',
|
||||
user_metadata: { name: 'Yoda' }
|
||||
});
|
||||
|
||||
$js$));
|
||||
|
||||
```
|
||||
|
||||
## Conclusion
|
||||
|
||||
As you can see, combining dynamic Javascript in Edge Functions with a few SQL support functions gets you a powerful new set of tools. By leveraging the edge_wrapper, edge.http_request, and `edge.exec` functions, developers can create robust and flexible serverless applications that can dynamically execute JavaScript code while interacting with PostgreSQL databases.
|
||||
|
||||
As we continue to build and innovate with Supabase, combining edge functions and SQL support functions opens up new avenues for building scalable, efficient, and secure applications. Whether developing a simple project or a complex application, these tools provide the flexibility and power to bring your ideas to life.
|
||||
@@ -8,13 +8,14 @@ og_image: '/images/events/hackathons/supabase-ai-hackathon-at-y-combinator/yc-ha
|
||||
onDemand: false
|
||||
main_cta:
|
||||
{
|
||||
url: 'https://forms.supabase.com/yai-hackathon',
|
||||
url: '',
|
||||
target: '_blank',
|
||||
label: 'Apply now',
|
||||
disabled_label: 'Registrations closed',
|
||||
label: 'Applications closed',
|
||||
disabled_label: 'Applications closed',
|
||||
disabled: true,
|
||||
}
|
||||
date: '2024-11-22T10:00:00.000-07:00'
|
||||
end_date: '2024-11-23T18:00:00.000-07:00'
|
||||
date: '2024-11-22T18:00:00.000-08:00'
|
||||
end_date: '2024-11-23T18:00:00.000-08:00'
|
||||
timezone: 'America/Los_Angeles'
|
||||
duration: '2 days'
|
||||
categories:
|
||||
@@ -29,7 +30,7 @@ We've already seen [several 1M+ user AI applications](https://x.com/kiwicopple/s
|
||||
|
||||
[Anthropic](https://www.anthropic.com) is also supporting the hackathon and will provide credits to every team.
|
||||
|
||||
Because we have limited capacity, we've created [a very short application](https://forms.supabase.com/yai-hackathon). We’ll keep the form open for a few weeks and let you know if you’re in by early November.
|
||||
**This event is full and invite only.**
|
||||
|
||||
### Jury
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_EMAIL_ABUSE_URL as string
|
||||
const supabaseServiceKey = process.env.EMAIL_ABUSE_SERVICE_KEY as string
|
||||
|
||||
export async function POST(req: NextRequest, { params }: { params: { ref: string } }) {
|
||||
const ref = params.ref
|
||||
const { reason, email } = await req.json()
|
||||
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey)
|
||||
|
||||
if (!ref) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Bad Request: Missing or invalid project reference.' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const refPattern = /^[a-zA-Z]{20}$/
|
||||
const refIsInvalid = !refPattern.test(ref)
|
||||
|
||||
if (refIsInvalid) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Bad Request: Missing or invalid project reference.' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const { error: supabaseError } = await supabase
|
||||
.from('manual_reports')
|
||||
.insert([{ project_ref: ref, reason, email }])
|
||||
|
||||
if (supabaseError) throw new Error(`Supabase error: ${supabaseError.message}`)
|
||||
|
||||
const response = await fetch(process.env.EMAIL_REPORT_SLACK_WEBHOOK as string, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: `New report from: ${ref} \n\n ${reason}` }),
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Failed to send to Slack')
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: 'Thank you! We have received your report.' },
|
||||
{ status: 200 }
|
||||
)
|
||||
} catch (error) {
|
||||
const errorMessage = (error as Error).message
|
||||
return NextResponse.json(
|
||||
{ error: `Failure: Could not send post to Slack. Error: ${errorMessage}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -119,7 +119,7 @@ const CareerPage: NextPage<CareersPageProps> = ({ jobs, placeholderJob, contribu
|
||||
We're on a mission to build the best developer platform
|
||||
</h2>
|
||||
<p className="text-sm md:text-base text-foreground-lighter max-w-sm sm:max-w-md md:max-w-lg mx-auto">
|
||||
Explore remote possibilities and join our team to help us achieve it.
|
||||
Explore remote opportunities and join our team to help us achieve it.
|
||||
</p>
|
||||
<Button asChild type="primary" className="mt-4">
|
||||
<Link href="#positions">Open positions</Link>
|
||||
|
||||
@@ -49,6 +49,7 @@ type CTA = {
|
||||
url: string
|
||||
label?: string
|
||||
disabled_label?: string
|
||||
disabled?: boolean
|
||||
target?: '_blank' | '_self'
|
||||
}
|
||||
|
||||
@@ -289,7 +290,9 @@ const EventPage = ({ event }: InferGetStaticPropsType<typeof getStaticProps>) =>
|
||||
type="primary"
|
||||
size="medium"
|
||||
className="mt-2"
|
||||
disabled={!IS_REGISTRATION_OPEN}
|
||||
disabled={
|
||||
!IS_REGISTRATION_OPEN || event.main_cta?.disabled || event.main_cta?.disabled
|
||||
}
|
||||
asChild
|
||||
>
|
||||
<Link
|
||||
@@ -381,7 +384,7 @@ const EventPage = ({ event }: InferGetStaticPropsType<typeof getStaticProps>) =>
|
||||
type="primary"
|
||||
size="medium"
|
||||
className="mt-2"
|
||||
disabled={!IS_REGISTRATION_OPEN}
|
||||
disabled={!IS_REGISTRATION_OPEN || event.main_cta?.disabled}
|
||||
asChild
|
||||
>
|
||||
<Link
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import Layout from 'components/Layouts/Default'
|
||||
import { NextSeo } from 'next-seo'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import {
|
||||
Button,
|
||||
cn,
|
||||
Form_Shadcn_,
|
||||
FormField_Shadcn_,
|
||||
FormMessage_Shadcn_,
|
||||
Select_Shadcn_,
|
||||
SelectContent_Shadcn_,
|
||||
SelectGroup_Shadcn_,
|
||||
SelectItem_Shadcn_,
|
||||
SelectTrigger_Shadcn_,
|
||||
SelectValue_Shadcn_,
|
||||
} from 'ui'
|
||||
import { Admonition } from 'ui-patterns'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
import { z } from 'zod'
|
||||
|
||||
const formSchema = z.object({
|
||||
ref: z.string(),
|
||||
reason: z.string().optional(),
|
||||
})
|
||||
|
||||
export default function OptOutPage() {
|
||||
const router = useRouter()
|
||||
const ref = router.query.ref as string
|
||||
const email = router.query.email as string | undefined
|
||||
|
||||
const [formMessage, setFormMessage] = useState<string | null>(null)
|
||||
const [submissionType, setSubmissionType] = useState<'success' | 'error' | null>(null)
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
ref: ref,
|
||||
reason: undefined,
|
||||
},
|
||||
})
|
||||
|
||||
const { register } = form
|
||||
|
||||
// Ensure ref is a string
|
||||
const refString = typeof ref !== 'string'
|
||||
const refPattern = /^[a-zA-Z]{20}$/
|
||||
const refIsInvalid = !refString && !refPattern.test(ref)
|
||||
const SelectItemContainerClasses = 'flex flex-col text-sm items-start'
|
||||
|
||||
const onSubmit = async (data: z.infer<typeof formSchema>) => {
|
||||
try {
|
||||
setSubmissionType(null)
|
||||
setFormMessage('Submitting...')
|
||||
|
||||
const response = await fetch(`/api-v2/opt-out/${data.ref}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ reason: data.reason, email: email }),
|
||||
})
|
||||
const message = await response.text()
|
||||
|
||||
if (refIsInvalid) {
|
||||
setFormMessage('Error: Invalid project reference.')
|
||||
setSubmissionType('error')
|
||||
} else if (response.ok) {
|
||||
setFormMessage(message)
|
||||
setSubmissionType('success')
|
||||
} else {
|
||||
console.error('Form submission failed:', message)
|
||||
setFormMessage('Error: Form submission failed.')
|
||||
setSubmissionType('error')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error submitting form:', error)
|
||||
setFormMessage('Error: An unexpected error occurred.')
|
||||
setSubmissionType('error')
|
||||
}
|
||||
}
|
||||
|
||||
function OptOutLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<NextSeo title="Supabase | Opt Out" description="Opt out of receiving emails." />
|
||||
<Layout className="overflow-visible mx-auto max-w-xl mt-24 flex flex-col gap-8">
|
||||
{children}
|
||||
</Layout>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
form.reset({ ref: ref, reason: '' })
|
||||
}, [ref])
|
||||
|
||||
return (
|
||||
<>
|
||||
<OptOutLayout>
|
||||
<div>
|
||||
<span className="label">Opt out</span>
|
||||
<h2 className="h4 !m-0">Receive an unexpected email from Supabase?</h2>
|
||||
<p className="text-foreground-light">Please report it here so we can investigate.</p>
|
||||
</div>
|
||||
|
||||
{refIsInvalid && (
|
||||
<Admonition type="warning" title="Invalid project reference">
|
||||
<p>The project reference you entered is invalid.</p>
|
||||
<p>Please check the URL and try again.</p>
|
||||
</Admonition>
|
||||
)}
|
||||
|
||||
<Form_Shadcn_ {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="gap-4 max-w-xs flex flex-col">
|
||||
<input hidden value={form.getValues('ref')} readOnly {...register('ref')} />
|
||||
<FormField_Shadcn_
|
||||
name="reason"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItemLayout label="Choose reason for reporting" labelOptional="Optional">
|
||||
<Select_Shadcn_ onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<SelectTrigger_Shadcn_ size="medium">
|
||||
<SelectValue_Shadcn_ placeholder="Select a reason (optional)">
|
||||
{field.value}
|
||||
</SelectValue_Shadcn_>
|
||||
</SelectTrigger_Shadcn_>
|
||||
<SelectContent_Shadcn_>
|
||||
<SelectGroup_Shadcn_>
|
||||
<SelectItem_Shadcn_ value="phishing">
|
||||
<div className={SelectItemContainerClasses}>
|
||||
<span>Phishing</span>
|
||||
<span className="text-foreground-light text-xs">
|
||||
Attempt to obtain sensitive information
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem_Shadcn_>
|
||||
<SelectItem_Shadcn_ value="advertisement">
|
||||
<div className={SelectItemContainerClasses}>
|
||||
<span>Advertisement</span>
|
||||
<span className="text-foreground-light text-xs">
|
||||
Unwanted promotional content
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem_Shadcn_>
|
||||
<SelectItem_Shadcn_ value="malware">
|
||||
<div className={SelectItemContainerClasses}>
|
||||
<span>Malware</span>
|
||||
<span className="text-foreground-light text-xs">
|
||||
Contains harmful software
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem_Shadcn_>
|
||||
<SelectItem_Shadcn_ value="scam">
|
||||
<div className={SelectItemContainerClasses}>
|
||||
<span>Scam</span>
|
||||
<span className="text-foreground-light text-xs">
|
||||
Fraudulent or deceptive content
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem_Shadcn_>
|
||||
<SelectItem_Shadcn_ value="other">
|
||||
<div className={SelectItemContainerClasses}>
|
||||
<span>Other</span>
|
||||
<span className="text-foreground-light text-xs">
|
||||
Any other type of spam
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem_Shadcn_>
|
||||
</SelectGroup_Shadcn_>
|
||||
</SelectContent_Shadcn_>
|
||||
</Select_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
<Button htmlType="submit" size="small" disabled={submissionType === 'success'}>
|
||||
Report spam
|
||||
</Button>
|
||||
<FormMessage_Shadcn_
|
||||
className={cn(
|
||||
'border-l pl-3',
|
||||
submissionType === 'error'
|
||||
? 'border-destructive'
|
||||
: 'border-foreground-muted text-foreground'
|
||||
)}
|
||||
>
|
||||
{formMessage}
|
||||
</FormMessage_Shadcn_>
|
||||
</form>
|
||||
</Form_Shadcn_>
|
||||
</OptOutLayout>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useRouter } from 'next/router'
|
||||
|
||||
const OptOutIndex = () => {
|
||||
const router = useRouter()
|
||||
|
||||
// We don't want anyone landing here directly
|
||||
useEffect(() => {
|
||||
router.push('/')
|
||||
}, [router])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export default OptOutIndex
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
@@ -0,0 +1,76 @@
|
||||
import { createClient } from 'jsr:@supabase/supabase-js@2.45.6'
|
||||
import OpenAI from 'https://deno.land/x/openai@v4.68.2/mod.ts'
|
||||
|
||||
const client = new OpenAI({ apiKey: Deno.env.get('OPENAI_API_KEY')! })
|
||||
|
||||
const supabase = createClient(
|
||||
Deno.env.get('SUPABASE_URL')!,
|
||||
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
|
||||
)
|
||||
|
||||
type StorageFileApi = ReturnType<typeof supabase.storage.from>
|
||||
type StorageUploadPromise = ReturnType<StorageFileApi['upload']>
|
||||
|
||||
class MyBackgroundTaskEvent extends Event {
|
||||
readonly taskPromise: StorageUploadPromise
|
||||
|
||||
constructor(taskPromise: StorageUploadPromise) {
|
||||
super('myBackgroundTask')
|
||||
this.taskPromise = taskPromise
|
||||
}
|
||||
}
|
||||
|
||||
globalThis.addEventListener('myBackgroundTask', async (event) => {
|
||||
const { data, error } = await (event as MyBackgroundTaskEvent).taskPromise
|
||||
console.log({ data, error })
|
||||
})
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
const url = new URL(req.url)
|
||||
const params = new URLSearchParams(url.search)
|
||||
const text = params.get('text')
|
||||
|
||||
if (!text) {
|
||||
return new Response(JSON.stringify({ error: 'Text parameter is required' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await client.audio.speech.create({
|
||||
model: 'tts-1',
|
||||
voice: 'nova',
|
||||
input: text,
|
||||
})
|
||||
|
||||
const stream = response.body
|
||||
if (!stream) {
|
||||
throw new Error('No stream')
|
||||
}
|
||||
|
||||
// Branch stream to Supabase Storage
|
||||
const [browserStream, storageStream] = stream.tee()
|
||||
|
||||
// Upload to Supabase Storage
|
||||
const storageUploadPromise = supabase.storage
|
||||
.from('videos')
|
||||
.upload(`audio-stream_${Date.now()}.mp3`, storageStream, {
|
||||
contentType: 'audio/mp3',
|
||||
})
|
||||
const event = new MyBackgroundTaskEvent(storageUploadPromise)
|
||||
globalThis.dispatchEvent(event)
|
||||
|
||||
return new Response(browserStream, {
|
||||
headers: {
|
||||
'Content-Type': 'audio/mp3',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.log('error', { error })
|
||||
return new Response(JSON.stringify({ error: error.message }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -420,6 +420,9 @@ const uiConfig = ui({
|
||||
require('@tailwindcss/typography'),
|
||||
require('tailwindcss-animate'),
|
||||
plugin(motionSafeTransition),
|
||||
function ({ addVariant }) {
|
||||
addVariant('not-disabled', '&:not(:disabled)')
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@@ -39,7 +39,13 @@ const InnerSideMenuCollapsible = forwardRef<
|
||||
ElementRef<typeof Collapsible_Shadcn_>,
|
||||
React.ComponentPropsWithoutRef<typeof Collapsible_Shadcn_>
|
||||
>(({ ...props }, ref) => {
|
||||
return <Collapsible_Shadcn_ ref={ref} {...props} className={cn('w-full px-2', props.className)} />
|
||||
return (
|
||||
<Collapsible_Shadcn_
|
||||
ref={ref}
|
||||
{...props}
|
||||
className={cn('w-full px-2 group', props.className)}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
const InnerSideMenuCollapsibleTrigger = forwardRef<
|
||||
@@ -73,10 +79,7 @@ const InnerSideMenuCollapsibleContent = forwardRef<
|
||||
<CollapsibleContent_Shadcn_
|
||||
ref={ref}
|
||||
{...props}
|
||||
className={cn(
|
||||
'w-full flex flex-col gap-0 transition-all data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down',
|
||||
props.className
|
||||
)}
|
||||
className={cn('w-full flex flex-col gap-0', props.className)}
|
||||
/>
|
||||
)
|
||||
})
|
||||
@@ -220,7 +223,7 @@ const InnerSideBarEmptyPanel = forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentPropsWithoutRef<'div'> & {
|
||||
title: string
|
||||
description?: string
|
||||
description?: string | React.ReactNode
|
||||
illustration?: React.ReactNode
|
||||
actions?: React.ReactNode
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from 'react'
|
||||
import { ChevronRightIcon } from 'lucide-react'
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import { ChevronRightIcon } from 'lucide-react'
|
||||
import * as React from 'react'
|
||||
import { cn } from '../../../lib/utils'
|
||||
|
||||
const Breadcrumb = React.forwardRef<
|
||||
@@ -64,7 +64,7 @@ const BreadcrumbPage = React.forwardRef<HTMLSpanElement, React.ComponentPropsWit
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn('no-underline', className)}
|
||||
className={cn('no-underline text-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -108,10 +108,10 @@ BreadcrumbEllipsis.displayName = 'BreadcrumbEllipsis'
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbEllipsis,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
}
|
||||
@@ -47,7 +47,7 @@ const SelectTrigger = React.forwardRef<
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between rounded-md border border-strong hover:border-stronger bg-alternative dark:bg-muted hover:bg-selection text-xs ring-offset-background-control placeholder:text-foreground-muted focus:outline-none ring-border-control focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 transition-all duration-200',
|
||||
'flex w-full items-center justify-between rounded-md border border-strong hover:border-stronger bg-alternative dark:bg-muted hover:bg-selection text-xs ring-offset-background-control data-[placeholder]:text-foreground-lighter focus:outline-none ring-border-control focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 transition-all duration-200',
|
||||
'data-[state=open]:bg-selection data-[state=open]:border-stronger',
|
||||
SelectTriggerVariants({ size }),
|
||||
className
|
||||
|
||||
+3
-1
@@ -70,7 +70,9 @@
|
||||
"LIVE_SUPABASE_COM_SERVICE_ROLE_KEY",
|
||||
"VERCEL_URL",
|
||||
"HUBSPOT_PORTAL_ID",
|
||||
"HUBSPOT_ENTERPRISE_FORM_GUID"
|
||||
"HUBSPOT_ENTERPRISE_FORM_GUID",
|
||||
"EMAIL_REPORT_SLACK_WEBHOOK",
|
||||
"EMAIL_ABUSE_SERVICE_KEY"
|
||||
],
|
||||
"outputs": [".next/**", "!.next/cache/**", ".contentlayer/**"]
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user