Merge master resolve conflicts
No files matched your search
@@ -7,13 +7,13 @@ export default function AuthProviders({ type }: { type: string }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-12 gap-10 not-prose py-8">
|
||||
<div className="grid grid-cols-12 xs:gap-x-10 gap-y-10 not-prose py-8">
|
||||
{filterProviders.map((x) => (
|
||||
<Link
|
||||
href={`${x.href}`}
|
||||
key={x.name}
|
||||
passHref
|
||||
className="col-span-6 lg:col-span-4 xl:col-span-3"
|
||||
className="col-span-12 xs:col-span-6 lg:col-span-4 xl:col-span-3"
|
||||
>
|
||||
<IconPanel title={x.name} icon={x.logo} hasLightIcon={x.hasLightIcon} />
|
||||
</Link>
|
||||
|
||||
@@ -24,18 +24,18 @@ Vectors in Supabase are enabled via [pgvector](https://github.com/pgvector/pgvec
|
||||
|
||||
1. Go to the [Database](https://supabase.com/dashboard/project/_/database/tables) page in the Dashboard.
|
||||
2. Click on **Extensions** in the sidebar.
|
||||
3. Search for "vector" and enable the extension.
|
||||
3. Search for "pgvector" and enable the extension.
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="sql" label="SQL">
|
||||
|
||||
```sql
|
||||
-- Example: enable the "vector" extension.
|
||||
-- Example: enable the pgvector extension.
|
||||
create extension vector
|
||||
with
|
||||
schema extensions;
|
||||
|
||||
-- Example: disable the "vector" extension
|
||||
-- Example: disable the pgvector extension
|
||||
drop
|
||||
extension if exists vector;
|
||||
```
|
||||
|
||||
@@ -88,7 +88,7 @@ Note however that your app will usually have to be verified by Google before you
|
||||
|
||||
The only thing left to implement is the UI, but if you prefer to use something pre-built, we have a handy [Auth Widget](https://github.com/supabase/ui/#using-supabase-ui-auth), where you can enable/disable whichever auth providers you want to support.
|
||||
|
||||
For any support please get in touch at beta at [supabase.com](https://supabase.com) or for feature requests open an issue in the [backend](https://github.com/supabase/gotrue) or [frontend](https://github.com/supabase/gotrue-js) repos.
|
||||
For any support see [supabase.com/support](https://supabase.com/support) or for feature requests open an issue in the [backend](https://github.com/supabase/auth) or [frontend](https://github.com/supabase/auth-js) repos.
|
||||
|
||||
### Resources
|
||||
|
||||
|
||||
@@ -172,11 +172,11 @@ console.log(JSON.stringify(data, null, 2))
|
||||
|
||||
returns:
|
||||
|
||||
```js
|
||||
;[
|
||||
```json
|
||||
[
|
||||
{
|
||||
textarray: ['Harry', 'Larry', 'Moe'],
|
||||
},
|
||||
"textarray": ["Harry", "Larry", "Moe"]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
|
||||
@@ -10,11 +10,12 @@ The `pg-safeupdate` extension is a useful tool for protecting data integrity and
|
||||
|
||||
## Enable the extension
|
||||
|
||||
`pg-safeupdate` can be enabled on a per connection basis:
|
||||
`pg-safeupdate` can be enabled on a role basis (where `anon` is the role we want to modify in this example):
|
||||
|
||||
{/* prettier-ignore */}
|
||||
```sql
|
||||
load 'safeupdate';
|
||||
GRANT SET ON PARAMETER session_preload_libraries TO anon;
|
||||
ALTER ROLE anon SET session_preload_libraries = 'safeupdate';
|
||||
```
|
||||
|
||||
or for all connections:
|
||||
@@ -30,8 +31,6 @@ Let's take a look at an example to see how pg-safeupdate works. Suppose we have
|
||||
|
||||
{/* prettier-ignore */}
|
||||
```sql
|
||||
load 'safeupdate';
|
||||
|
||||
update employees set date_of_birth = '1987-01-28';
|
||||
```
|
||||
|
||||
@@ -52,3 +51,4 @@ update employees set date_of_birth = '1987-01-28' where id = 12345;
|
||||
## Resources
|
||||
|
||||
- Official [pg-safeupdate documentation](https://github.com/eradman/pg-safeupdate)
|
||||
- Using [pg-safeupdate with PostgREST](https://postgrest.org/en/v12/integrations/pg-safeupdate.html)
|
||||
@@ -174,7 +174,7 @@ limit 10;
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
The records in cron.job_run_details are not cleaned automatically which will take up disk space in your datbabase.
|
||||
The records in cron.job_run_details are not cleaned automatically which will take up disk space in your database.
|
||||
|
||||
</Admonition>
|
||||
|
||||
|
||||
@@ -33,18 +33,18 @@ This is particularly useful if you're building on top of OpenAI's [GPT-3](https:
|
||||
|
||||
1. Go to the [Database](https://supabase.com/dashboard/project/_/database/tables) page in the Dashboard.
|
||||
2. Click on **Extensions** in the sidebar.
|
||||
3. Search for "vector" and enable the extension.
|
||||
3. Search for "pgvector" and enable the extension.
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="sql" label="SQL">
|
||||
|
||||
```sql
|
||||
-- Example: enable the "vector" extension.
|
||||
-- Example: enable the pgvector extension.
|
||||
create extension vector
|
||||
with
|
||||
schema extensions;
|
||||
|
||||
-- Example: disable the "vector" extension
|
||||
-- Example: disable the pgvector extension
|
||||
drop
|
||||
extension if exists vector;
|
||||
```
|
||||
|
||||
@@ -28,7 +28,7 @@ For example, let's assume you have a `posts` table with the following columns:
|
||||
You can restrict updates to just the user who created it using [RLS](/docs/guides/auth#row-level-security), with the following policy:
|
||||
|
||||
```sql
|
||||
create a policy "Allow update for owners" on posts for
|
||||
create policy "Allow update for owners" on posts for
|
||||
update
|
||||
using ((select auth.uid()) = user_id);
|
||||
```
|
||||
@@ -127,7 +127,7 @@ supabase migration new create_posts_table
|
||||
);
|
||||
|
||||
-- Add row-level security
|
||||
create a policy "Allow update for owners" on posts for
|
||||
create policy "Allow update for owners" on posts for
|
||||
update
|
||||
using ((select auth.uid()) = user_id);
|
||||
|
||||
|
||||
@@ -40,11 +40,11 @@ Deno.serve(async (req: Request) => {
|
||||
const supabaseClient = createClient(
|
||||
Deno.env.get('SUPABASE_URL') ?? '',
|
||||
Deno.env.get('SUPABASE_ANON_KEY') ?? '',
|
||||
{ global: { headers: { Authorization: req.headers.get('Authorization')! } } }
|
||||
)
|
||||
|
||||
// Get the session or user object
|
||||
const { data } = await supabaseClient.auth.getUser()
|
||||
const token = authHeader.replace('Bearer ', '')
|
||||
const { data } = await supabaseClient.auth.getUser(token)
|
||||
const user = data.user
|
||||
|
||||
return new Response(JSON.stringify({ user }), {
|
||||
|
||||
@@ -535,7 +535,7 @@ See the Next.js docs to learn more about [opting out of data caching](https://ne
|
||||
|
||||
<CH.Code className="max-h-[30rem]">
|
||||
|
||||
```js app/login/action.js
|
||||
```js app/login/actions.js
|
||||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
@@ -596,7 +596,7 @@ export default function ErrorPage() {
|
||||
|
||||
<CH.Code className="max-h-[30rem]">
|
||||
|
||||
```ts app/login/action.ts
|
||||
```ts app/login/actions.ts
|
||||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
|
||||
@@ -336,9 +336,9 @@ export const GET = async ({ url, locals: { supabase } }) => {
|
||||
|
||||
```ts title=src/routes/auth/callback/+server.ts
|
||||
// src/routes/auth/callback/+server.ts
|
||||
import { redirect } from '@sveltejs/kit'
|
||||
import { redirect, type RequestHandler } from '@sveltejs/kit'
|
||||
|
||||
export const GET = async ({ url, locals: { supabase } }) => {
|
||||
export const GET: RequestHandler = async ({ url, locals: { supabase } }) => {
|
||||
const code = url.searchParams.get('code')
|
||||
|
||||
if (code) {
|
||||
|
||||
@@ -41,7 +41,7 @@ You can only read data from a Read Replica. This is in contrast to a Primary dat
|
||||
|
||||
<Admonition type="note" label="Read Replicas are available on the Team and Enterprise plans">
|
||||
|
||||
Read replicas are available to projects created on the Teams and Enterprise plans after 2024 March 20. They will be progressively rolled out to older projects, and eventually available on the Pro plan.
|
||||
Read replicas are available to projects created on the Teams and Enterprise plans on or after 2024 January 1. They will be progressively rolled out to older projects, and eventually available on the Pro plan.
|
||||
|
||||
If your project doesn't meet these criteria, you can join the [early access queue](https://forms.supabase.com/read-replica).
|
||||
|
||||
|
||||
@@ -206,7 +206,7 @@ If the tool doesn't exist, we build and open source it ourselves.
|
||||
- [postgres-meta](https://github.com/supabase/postgres-meta) is a RESTful API for managing your Postgres, allowing you to fetch tables, add roles, and run queries, etc.
|
||||
- [PostgreSQL](https://www.postgresql.org/) is an object-relational database system with over 30 years of active development that has earned it a strong reputation for reliability, feature robustness, and performance.
|
||||
|
||||
For the system to work cohesively, some services require additional configuration within the Postgres database. For example, the APIs and Auth system require several [default roles](/docs/guides/database/postgres-roles) and the `pgjwt` Postgres extension.
|
||||
For the system to work cohesively, some services require additional configuration within the Postgres database. For example, the APIs and Auth system require several [default roles](/docs/guides/database/postgres/roles) and the `pgjwt` Postgres extension.
|
||||
|
||||
You can find all the default extensions inside the [schema migration scripts repo](https://github.com/supabase/postgres/tree/develop/migrations). These scripts are mounted at `/docker-entrypoint-initdb.d` to run automatically when starting the database container.
|
||||
|
||||
|
||||
@@ -48,7 +48,6 @@ Margarita Sandomirskaia
|
||||
Marijana Pavlinić
|
||||
Mark Burggraf
|
||||
Monica El Khoury
|
||||
Nikita Kotlyarov
|
||||
Oli R
|
||||
Pamela Chia
|
||||
Paul Caselton
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
clispec: '001'
|
||||
info:
|
||||
id: cli
|
||||
version: 1.163.2
|
||||
version: 1.163.6
|
||||
title: Supabase CLI
|
||||
language: sh
|
||||
source: https://github.com/supabase/cli
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"~/*": ["./*"],
|
||||
"@ui/*": ["./../../packages/ui/src/*"] // handle ui package paths
|
||||
"@ui/*": ["./../../packages/ui/src/*"],
|
||||
"@ui-patterns/*": ["./../../packages/ui-patterns/*"]
|
||||
},
|
||||
"target": "ES2021",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
|
||||
@@ -107,7 +107,7 @@ export const DeleteAccountButton = () => {
|
||||
<DialogSection className="!pt-0">
|
||||
<span className="text-sm text-foreground flex flex-col gap-y-2">
|
||||
Before submitting an account deletion request, please ensure that your account is
|
||||
not part of any organization. This can be done by leaving leaving or deleting the
|
||||
not part of any organization. This can be done by leaving or deleting the
|
||||
organizations that you are a part of.
|
||||
</span>
|
||||
<Button
|
||||
|
||||
@@ -22,8 +22,11 @@ import {
|
||||
FormSectionLabel,
|
||||
} from 'components/ui/Forms'
|
||||
import { useAuthConfigQuery } from 'data/auth/auth-config-query'
|
||||
import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query'
|
||||
import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation'
|
||||
import { useCheckPermissions } from 'hooks'
|
||||
import { useCheckPermissions, useSelectedOrganization } from 'hooks'
|
||||
import { IS_PLATFORM } from 'lib/constants'
|
||||
import UpgradeToPro from 'components/ui/UpgradeToPro'
|
||||
|
||||
const schema = object({
|
||||
JWT_EXP: number()
|
||||
@@ -35,7 +38,13 @@ const schema = object({
|
||||
.required('Must have a Reuse Interval value'),
|
||||
MFA_MAX_ENROLLED_FACTORS: number()
|
||||
.min(0, 'Must be be a value more than 0')
|
||||
.max(30, 'Must be a value less than 30'),
|
||||
.max(30, 'Must be a value no greater than 30'),
|
||||
DB_MAX_POOL_SIZE: number()
|
||||
.min(1, 'Must be 1 or larger')
|
||||
.max(200, 'Must be a value no greater than 200'),
|
||||
API_MAX_REQUEST_DURATION: number()
|
||||
.min(5, 'Must be 5 or larger')
|
||||
.max(30, 'Must be a value no greater than 30'),
|
||||
})
|
||||
|
||||
const AdvancedAuthSettingsForm = () => {
|
||||
@@ -52,17 +61,33 @@ const AdvancedAuthSettingsForm = () => {
|
||||
const formId = 'auth-config-advanced-form'
|
||||
const canUpdateConfig = useCheckPermissions(PermissionAction.UPDATE, 'custom_config_gotrue')
|
||||
|
||||
const organization = useSelectedOrganization()
|
||||
const { data: subscription, isSuccess: isSuccessSubscription } = useOrgSubscriptionQuery({
|
||||
orgSlug: organization?.slug,
|
||||
})
|
||||
|
||||
const isTeamsEnterprisePlan =
|
||||
isSuccessSubscription && subscription.plan.id !== 'free' && subscription.plan.id !== 'pro'
|
||||
const promptTeamsEnterpriseUpgrade = IS_PLATFORM && !isTeamsEnterprisePlan
|
||||
|
||||
const INITIAL_VALUES = {
|
||||
SITE_URL: authConfig?.SITE_URL,
|
||||
JWT_EXP: authConfig?.JWT_EXP,
|
||||
REFRESH_TOKEN_ROTATION_ENABLED: authConfig?.REFRESH_TOKEN_ROTATION_ENABLED || false,
|
||||
SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: authConfig?.SECURITY_REFRESH_TOKEN_REUSE_INTERVAL,
|
||||
MFA_MAX_ENROLLED_FACTORS: authConfig?.MFA_MAX_ENROLLED_FACTORS || 10,
|
||||
DB_MAX_POOL_SIZE: authConfig?.DB_MAX_POOL_SIZE || 10,
|
||||
API_MAX_REQUEST_DURATION: authConfig?.API_MAX_REQUEST_DURATION || 10,
|
||||
}
|
||||
|
||||
const onSubmit = (values: any, { resetForm }: any) => {
|
||||
const payload = { ...values }
|
||||
|
||||
if (!isTeamsEnterprisePlan) {
|
||||
delete payload.DB_MAX_POOL_SIZE
|
||||
delete payload.API_MAX_REQUEST_DURATION
|
||||
}
|
||||
|
||||
updateAuthConfig(
|
||||
{ projectRef: projectRef!, config: payload },
|
||||
{
|
||||
@@ -167,10 +192,59 @@ const AdvancedAuthSettingsForm = () => {
|
||||
size="small"
|
||||
label="Maximum number of per-user MFA factors"
|
||||
descriptionText="How many MFA factors can be enrolled at once per user."
|
||||
actions={<span className="mr-3 text-foreground-lighter">factors</span>}
|
||||
disabled={!canUpdateConfig}
|
||||
/>
|
||||
</FormSectionContent>
|
||||
</FormSection>
|
||||
|
||||
<FormSection
|
||||
header={<FormSectionLabel>Max Direct Database Connections</FormSectionLabel>}
|
||||
>
|
||||
<FormSectionContent loading={isLoading}>
|
||||
{promptTeamsEnterpriseUpgrade && (
|
||||
<UpgradeToPro
|
||||
primaryText="Upgrade to Teams or Enterprise"
|
||||
secondaryText="Max Direct Database Connections settings are only available on the Teams plan and up."
|
||||
buttonText="Upgrade to Teams"
|
||||
projectRef={projectRef!}
|
||||
organizationSlug={organization!.slug}
|
||||
/>
|
||||
)}
|
||||
|
||||
<InputNumber
|
||||
id="DB_MAX_POOL_SIZE"
|
||||
size="small"
|
||||
label="Max direct database connections used by Auth"
|
||||
descriptionText="Auth will take up no more than this number of connections from the total number of available connections to serve requests. These connections are not reserved, so when unused they are released."
|
||||
actions={<span className="mr-3 text-foreground-lighter">connections</span>}
|
||||
disabled={!canUpdateConfig || !isTeamsEnterprisePlan}
|
||||
/>
|
||||
</FormSectionContent>
|
||||
</FormSection>
|
||||
|
||||
<FormSection header={<FormSectionLabel>Max Request Duration</FormSectionLabel>}>
|
||||
<FormSectionContent loading={isLoading}>
|
||||
{promptTeamsEnterpriseUpgrade && (
|
||||
<UpgradeToPro
|
||||
primaryText="Upgrade to Teams or Enterprise"
|
||||
secondaryText="Max Request Duration settings are only available on the Teams plan and up."
|
||||
buttonText="Upgrade to Teams"
|
||||
projectRef={projectRef!}
|
||||
organizationSlug={organization!.slug}
|
||||
/>
|
||||
)}
|
||||
|
||||
<InputNumber
|
||||
id="API_MAX_REQUEST_DURATION"
|
||||
size="small"
|
||||
label="Maximum time allowed for an Auth request to last"
|
||||
descriptionText="Number of seconds to wait for an Auth request to complete before canceling it. In certain high-load situations setting a larger or smaller value can be used to control load-shedding. Recommended: 10 seconds."
|
||||
actions={<span className="mr-3 text-foreground-lighter">seconds</span>}
|
||||
disabled={!canUpdateConfig || !isTeamsEnterprisePlan}
|
||||
/>
|
||||
</FormSectionContent>
|
||||
</FormSection>
|
||||
</FormPanel>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -178,5 +178,43 @@ const RECOVERY: FormSchema = {
|
||||
[Learn more](https://supabase.com/docs/guides/auth/social-login/auth-apple#configure-your-services-id)`,
|
||||
},
|
||||
}
|
||||
const REAUTHENTICATION: FormSchema = {
|
||||
$schema: JSON_SCHEMA_VERSION,
|
||||
id: 'REAUTHENTICATION',
|
||||
type: 'object',
|
||||
title: 'Reauthentication',
|
||||
properties: {
|
||||
MAILER_SUBJECTS_REAUTHENTICATION: {
|
||||
title: 'Subject heading',
|
||||
type: 'string',
|
||||
},
|
||||
MAILER_TEMPLATES_REAUTHENTICATION_CONTENT: {
|
||||
title: 'Message body',
|
||||
descriptionOptional: 'HTML body of your email',
|
||||
type: 'code',
|
||||
description: `
|
||||
- \`{{ .Token }}\` : The 6-digit numeric email OTP
|
||||
- \`{{ .SiteURL }}\` : The URL of the site
|
||||
- \`{{ .Email }}\` : The user's email address
|
||||
- \`{{ .Data }}\` : The user's \`user_metadata\`
|
||||
`,
|
||||
},
|
||||
},
|
||||
validationSchema: object().shape({
|
||||
MAILER_SUBJECTS_REAUTHENTICATION: string().required('"Subject heading is required.'),
|
||||
}),
|
||||
misc: {
|
||||
iconKey: 'email-icon2',
|
||||
helper: `To complete setup, add this authorisation callback URL to your app's configuration in the Apple Developer Console.
|
||||
[Learn more](https://supabase.com/docs/guides/auth/social-login/auth-apple#configure-your-services-id)`,
|
||||
},
|
||||
}
|
||||
|
||||
export const TEMPLATES_SCHEMAS = [CONFIRMATION, INVITE, MAGIC_LINK, EMAIL_CHANGE, RECOVERY]
|
||||
export const TEMPLATES_SCHEMAS = [
|
||||
CONFIRMATION,
|
||||
INVITE,
|
||||
MAGIC_LINK,
|
||||
EMAIL_CHANGE,
|
||||
RECOVERY,
|
||||
REAUTHENTICATION,
|
||||
]
|
||||
@@ -14,6 +14,7 @@ const UNHEALTHY_STATUES: Status[] = [
|
||||
'INIT_FAILED',
|
||||
'UNKNOWN',
|
||||
'MIGRATIONS_FAILED',
|
||||
'FUNCTIONS_FAILED',
|
||||
]
|
||||
const WAITING_STATUSES: Status[] = [
|
||||
'COMING_UP',
|
||||
|
||||
@@ -3,7 +3,7 @@ import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { extensions } from 'shared-data'
|
||||
import { Badge, IconExternalLink, IconLoader, Modal, Toggle } from 'ui'
|
||||
import { Badge, IconExternalLink, IconLoader, Toggle } from 'ui'
|
||||
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
||||
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
@@ -72,7 +72,7 @@ const ExtensionCard = ({ extension }: ExtensionCardProps) => {
|
||||
title={extension.name}
|
||||
className="h-5 m-0 text-sm truncate cursor-pointer text-foreground"
|
||||
>
|
||||
{extension.name}
|
||||
{extension.altName || extension.name}
|
||||
</h3>
|
||||
<p className="text-sm text-foreground-light">
|
||||
{extension?.installed_version ?? extension.default_version}
|
||||
|
||||
@@ -26,7 +26,7 @@ const Extensions = () => {
|
||||
})
|
||||
|
||||
const formattedExtensions = (data ?? []).map((ext) => {
|
||||
if (ext.name === 'vector') return { ...ext, name: 'pgvector' }
|
||||
if (ext.name === 'vector') return { ...ext, altName: 'pgvector' }
|
||||
else return ext
|
||||
})
|
||||
|
||||
@@ -34,7 +34,7 @@ const Extensions = () => {
|
||||
filterString.length === 0
|
||||
? formattedExtensions
|
||||
: formattedExtensions.filter((ext) =>
|
||||
ext.name.toLowerCase().includes(filterString.toLowerCase())
|
||||
(ext?.altName ?? ext.name).toLowerCase().includes(filterString.toLowerCase())
|
||||
)
|
||||
const extensionsWithoutHidden = extensions.filter((ext) => !HIDDEN_EXTENSIONS.includes(ext.name))
|
||||
const [enabledExtensions, disabledExtensions] = partition(
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { AutoApiService } from 'data/config/project-api-query'
|
||||
import { useParams } from 'common'
|
||||
import Snippets from 'components/interfaces/Docs/Snippets'
|
||||
import type { AutoApiService } from 'data/config/project-api-query'
|
||||
import { useProjectPostgrestConfigQuery } from 'data/config/project-postgrest-config-query'
|
||||
|
||||
import CodeSnippet from './CodeSnippet'
|
||||
import PublicSchemaNotEnabledAlert from './PublicSchemaNotEnabledAlert'
|
||||
|
||||
interface Props {
|
||||
autoApiService: AutoApiService
|
||||
@@ -8,16 +12,22 @@ interface Props {
|
||||
}
|
||||
|
||||
export default function Introduction({ autoApiService, selectedLang }: Props) {
|
||||
const { ref: projectRef } = useParams()
|
||||
|
||||
const { data: config } = useProjectPostgrestConfigQuery({ projectRef })
|
||||
|
||||
const isPublicSchemaEnabled = config?.db_schema
|
||||
.split(',')
|
||||
.map((name) => name.trim())
|
||||
.includes('public')
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="doc-section doc-section--client-libraries">
|
||||
<article className="code">
|
||||
<CodeSnippet
|
||||
selectedLang={selectedLang}
|
||||
snippet={Snippets.init(autoApiService.endpoint)}
|
||||
/>
|
||||
</article>
|
||||
</div>
|
||||
</>
|
||||
<div className="doc-section doc-section--client-libraries">
|
||||
<article className="code">
|
||||
<CodeSnippet selectedLang={selectedLang} snippet={Snippets.init(autoApiService.endpoint)} />
|
||||
|
||||
{!isPublicSchemaEnabled && <PublicSchemaNotEnabledAlert />}
|
||||
</article>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,13 +3,22 @@ import Link from 'next/link'
|
||||
|
||||
import CodeSnippet from 'components/interfaces/Docs/CodeSnippet'
|
||||
import GeneratingTypes from 'components/interfaces/Docs/GeneratingTypes'
|
||||
import { useProjectPostgrestConfigQuery } from 'data/config/project-postgrest-config-query'
|
||||
import PublicSchemaNotEnabledAlert from '../../PublicSchemaNotEnabledAlert'
|
||||
|
||||
interface IntroductionProps {
|
||||
selectedLang: 'bash' | 'js'
|
||||
}
|
||||
|
||||
const Introduction = ({ selectedLang }: IntroductionProps) => {
|
||||
const { ref } = useParams()
|
||||
const { ref: projectRef } = useParams()
|
||||
|
||||
const { data: config } = useProjectPostgrestConfigQuery({ projectRef })
|
||||
|
||||
const isPublicSchemaEnabled = config?.db_schema
|
||||
.split(',')
|
||||
.map((name) => name.trim())
|
||||
.includes('public')
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -20,6 +29,8 @@ const Introduction = ({ selectedLang }: IntroductionProps) => {
|
||||
All views and tables in the <code>public</code> schema and accessible by the active
|
||||
database role for a request are available for querying.
|
||||
</p>
|
||||
|
||||
{!isPublicSchemaEnabled && <PublicSchemaNotEnabledAlert />}
|
||||
</article>
|
||||
</div>
|
||||
|
||||
@@ -56,7 +67,7 @@ const Introduction = ({ selectedLang }: IntroductionProps) => {
|
||||
If you still want to use GraphQL, you can. Supabase provides you with a full Postgres
|
||||
database, so as long as your middleware can connect to the database then you can still
|
||||
use the tools you love. You can find the database connection details{' '}
|
||||
<Link href={`/project/${ref}/settings/database`}>in the settings.</Link>
|
||||
<Link href={`/project/${projectRef}/settings/database`}>in the settings.</Link>
|
||||
</p>
|
||||
</article>
|
||||
<article className="code">
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_, Button } from 'ui'
|
||||
import { Info } from 'lucide-react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import Link from 'next/link'
|
||||
import { useParams } from 'common'
|
||||
|
||||
const PublicSchemaNotEnabledAlert = () => {
|
||||
const { ref: projectRef } = useParams()
|
||||
|
||||
return (
|
||||
<Alert_Shadcn_ variant="default">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle_Shadcn_ className="!-mt-4">
|
||||
<ReactMarkdown>The `public` schema for this project is not exposed</ReactMarkdown>
|
||||
</AlertTitle_Shadcn_>
|
||||
<AlertDescription_Shadcn_ className="grid gap-3 !-mt-7">
|
||||
<ReactMarkdown>
|
||||
You will not be able to query tables and views in the `public` schema via supabase-js or
|
||||
HTTP clients.
|
||||
</ReactMarkdown>
|
||||
|
||||
<div className="!-mt-4 inline-block">
|
||||
<Button asChild type="default">
|
||||
<Link
|
||||
href={`/project/${projectRef}/settings/api#postgrest-config`}
|
||||
className="!no-underline !hover:bg-surface-100 !text-foreground"
|
||||
>
|
||||
View schema settings
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</AlertDescription_Shadcn_>
|
||||
</Alert_Shadcn_>
|
||||
)
|
||||
}
|
||||
|
||||
export default PublicSchemaNotEnabledAlert
|
||||
@@ -1,10 +1,11 @@
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import SVG from 'react-inlinesvg'
|
||||
import { Button, IconActivity, IconArchive, IconCode, IconExternalLink, IconKey } from 'ui'
|
||||
import { Button } from 'ui'
|
||||
|
||||
import Panel from 'components/ui/Panel'
|
||||
import { useIsFeatureEnabled } from 'hooks'
|
||||
import { Auth, EdgeFunctions, Realtime, SqlEditor, Storage, TableEditor } from 'icons'
|
||||
import APIKeys from './APIKeys'
|
||||
import GetStartedHero from './GetStartedHero'
|
||||
|
||||
@@ -45,37 +46,13 @@ const NewProjectPanel = () => {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
asChild
|
||||
type="default"
|
||||
icon={
|
||||
<SVG
|
||||
src={`${router.basePath}/img/table-editor.svg`}
|
||||
style={{ width: `${14}px`, height: `${14}px` }}
|
||||
preProcessor={(code) =>
|
||||
code.replace(/svg/, 'svg class="m-auto text-color-inherit"')
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Button asChild type="default" icon={<TableEditor strokeWidth={1.5} />}>
|
||||
<Link href={`/project/${ref}/editor`}>Table Editor</Link>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
type="default"
|
||||
icon={
|
||||
<SVG
|
||||
src={`${router.basePath}/img/sql-editor.svg`}
|
||||
style={{ width: `${14}px`, height: `${14}px` }}
|
||||
preProcessor={(code) =>
|
||||
code.replace(/svg/, 'svg class="m-auto text-color-inherit"')
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Button asChild type="default" icon={<SqlEditor strokeWidth={1.5} />}>
|
||||
<Link href={`/project/${ref}/sql/new`}>SQL editor</Link>
|
||||
</Button>
|
||||
<Button asChild type="default" icon={<IconExternalLink size={14} />}>
|
||||
<Button asChild type="default" icon={<ExternalLink size={14} />}>
|
||||
<Link
|
||||
href="https://supabase.com/docs/guides/database"
|
||||
target="_blank"
|
||||
@@ -105,7 +82,7 @@ const NewProjectPanel = () => {
|
||||
<Panel.Content className="flex flex-col space-y-4 md:px-3">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="rounded bg-surface-300 p-1.5 text-foreground-light shadow-sm">
|
||||
<IconKey strokeWidth={2} size={16} />
|
||||
<Auth size={16} strokeWidth={1.5} />
|
||||
</div>
|
||||
<h5>Authentication</h5>
|
||||
</div>
|
||||
@@ -121,7 +98,7 @@ const NewProjectPanel = () => {
|
||||
|
||||
<Button
|
||||
className="translate-y-[1px]"
|
||||
icon={<IconExternalLink size={14} />}
|
||||
icon={<ExternalLink size={14} />}
|
||||
type="default"
|
||||
asChild
|
||||
>
|
||||
@@ -141,7 +118,7 @@ const NewProjectPanel = () => {
|
||||
<Panel.Content className="flex flex-col space-y-4 md:px-3">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="rounded bg-surface-300 p-1.5 text-foreground-light shadow-sm">
|
||||
<IconArchive strokeWidth={2} size={16} />
|
||||
<Storage size={16} strokeWidth={1.5} />
|
||||
</div>
|
||||
<h5>Storage</h5>
|
||||
</div>
|
||||
@@ -157,7 +134,7 @@ const NewProjectPanel = () => {
|
||||
|
||||
<Button
|
||||
className="translate-y-[1px]"
|
||||
icon={<IconExternalLink size={14} />}
|
||||
icon={<ExternalLink size={14} />}
|
||||
type="default"
|
||||
asChild
|
||||
>
|
||||
@@ -177,7 +154,7 @@ const NewProjectPanel = () => {
|
||||
<Panel.Content className="flex flex-col space-y-4 md:px-3">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="rounded bg-surface-300 p-1.5 text-foreground-light shadow-sm">
|
||||
<IconCode strokeWidth={2} size={16} />
|
||||
<EdgeFunctions size={16} strokeWidth={1.5} />
|
||||
</div>
|
||||
<h5>Edge Functions</h5>
|
||||
</div>
|
||||
@@ -193,7 +170,7 @@ const NewProjectPanel = () => {
|
||||
</Button>
|
||||
<Button
|
||||
className="translate-y-[1px]"
|
||||
icon={<IconExternalLink size={14} />}
|
||||
icon={<ExternalLink size={14} />}
|
||||
type="default"
|
||||
asChild
|
||||
>
|
||||
@@ -212,7 +189,7 @@ const NewProjectPanel = () => {
|
||||
<Panel.Content className="flex flex-col space-y-4 md:px-3">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="rounded bg-surface-300 p-1.5 text-foreground-light shadow-sm">
|
||||
<IconActivity strokeWidth={2} size={16} />
|
||||
<Realtime size={16} strokeWidth={1.5} />
|
||||
</div>
|
||||
<h5>Realtime</h5>
|
||||
</div>
|
||||
@@ -222,9 +199,12 @@ const NewProjectPanel = () => {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button type="default" asChild>
|
||||
<Link href={`/project/${ref}/realtime/inspector`}>Explore Realtime</Link>
|
||||
</Button>
|
||||
<Button
|
||||
className="translate-y-[1px]"
|
||||
icon={<IconExternalLink size={14} />}
|
||||
icon={<ExternalLink size={14} />}
|
||||
type="default"
|
||||
asChild
|
||||
>
|
||||
@@ -265,12 +245,7 @@ const NewProjectPanel = () => {
|
||||
<Button asChild type="default">
|
||||
<Link href={`/project/${ref}/settings/api`}>View API settings</Link>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
className="translate-y-[1px]"
|
||||
type="default"
|
||||
icon={<IconExternalLink />}
|
||||
>
|
||||
<Button asChild className="translate-y-[1px]" type="default" icon={<ExternalLink />}>
|
||||
<Link
|
||||
href="https://supabase.com/docs/guides/database/api"
|
||||
target="_blank"
|
||||
|
||||
@@ -132,6 +132,7 @@ const ProjectLinker = ({
|
||||
connection: {
|
||||
foreign_project_id: selectedForeignProject?.id,
|
||||
supabase_project_ref: selectedSupabaseProject?.ref,
|
||||
integration_id: '0',
|
||||
metadata: {
|
||||
...projectDetails,
|
||||
},
|
||||
|
||||
@@ -15,7 +15,6 @@ import { useSelectedOrganization } from 'hooks'
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
import { EMPTY_ARR } from 'lib/void'
|
||||
import { useSidePanelsStateSnapshot } from 'state/side-panels'
|
||||
import * as Sentry from '@sentry/nextjs'
|
||||
|
||||
const VERCEL_ICON = (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="white" viewBox="0 0 512 512" className="w-6">
|
||||
@@ -101,18 +100,6 @@ const SidePanelVercelProjectLinker = () => {
|
||||
|
||||
const onCreateConnections = useCallback(
|
||||
(vars: any) => {
|
||||
// TODO(kamil): Remove once "missing connection name" bug has been squashed.
|
||||
if (!vars.connection?.metadata?.name) {
|
||||
Sentry.captureMessage('Vercel connection is missing name', {
|
||||
contexts: {
|
||||
connection: vars.connection,
|
||||
source: {
|
||||
file: 'Organization/IntegrationSettings/SidePanelVercelProjectLinker.tsx',
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
createConnections({
|
||||
...vars,
|
||||
connection: {
|
||||
|
||||
@@ -16,31 +16,52 @@ import { useProjectPostgrestConfigQuery } from 'data/config/project-postgrest-co
|
||||
import { useProjectPostgrestConfigUpdateMutation } from 'data/config/project-postgrest-config-update-mutation'
|
||||
import { useSchemasQuery } from 'data/database/schemas-query'
|
||||
import { useCheckPermissions } from 'hooks'
|
||||
import { Form, IconAlertCircle, Input, InputNumber } from 'ui'
|
||||
import MultiSelect from 'ui-patterns/MultiSelect'
|
||||
import { AlertCircle, Info } from 'lucide-react'
|
||||
import {
|
||||
AlertDescription_Shadcn_,
|
||||
AlertTitle_Shadcn_,
|
||||
Alert_Shadcn_,
|
||||
Button,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
} from 'ui'
|
||||
import { MultiSelectV2 } from 'ui-patterns/MultiSelect/MultiSelectV2'
|
||||
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query'
|
||||
import Link from 'next/link'
|
||||
|
||||
const PostgrestConfig = () => {
|
||||
const { ref: projectRef } = useParams()
|
||||
const { project } = useProjectContext()
|
||||
const { data: schemas } = useSchemasQuery({
|
||||
|
||||
const { data: schemas, isLoading: isLoadingSchemas } = useSchemasQuery({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
})
|
||||
|
||||
const { data: config, isError } = useProjectPostgrestConfigQuery({ projectRef })
|
||||
|
||||
const { mutate: updatePostgrestConfig, isLoading: isUpdating } =
|
||||
useProjectPostgrestConfigUpdateMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Successfully saved settings')
|
||||
},
|
||||
})
|
||||
|
||||
const canUpdatePostgrestConfig = useCheckPermissions(
|
||||
PermissionAction.UPDATE,
|
||||
'custom_config_postgrest'
|
||||
)
|
||||
|
||||
const { data: extensions } = useDatabaseExtensionsQuery({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
})
|
||||
|
||||
const formId = 'project-postgres-config'
|
||||
const initialValues = { db_schema: '', max_rows: '', db_extra_search_path: '' }
|
||||
const initialValues = { db_schema: '', max_rows: '', db_extra_search_path: '', db_pool: '' }
|
||||
|
||||
const updateConfig = async (updatedConfig: typeof initialValues) => {
|
||||
if (!projectRef) return console.error('Project ref is required')
|
||||
@@ -49,10 +70,10 @@ const PostgrestConfig = () => {
|
||||
dbSchema: updatedConfig.db_schema,
|
||||
maxRows: updatedConfig.max_rows,
|
||||
dbExtraSearchPath: updatedConfig.db_extra_search_path,
|
||||
dbPool: updatedConfig.db_pool || null,
|
||||
})
|
||||
}
|
||||
|
||||
const permanentSchema = ['public', 'storage']
|
||||
const hiddenSchema = ['auth', 'pgbouncer', 'hooks', 'extensions']
|
||||
const schema =
|
||||
schemas
|
||||
@@ -65,15 +86,22 @@ const PostgrestConfig = () => {
|
||||
id: x.id,
|
||||
value: x.name,
|
||||
name: x.name,
|
||||
disabled: indexOf(permanentSchema, x.name) >= 0 ? true : false,
|
||||
disabled: false,
|
||||
}
|
||||
}) ?? []
|
||||
|
||||
const isPublicSchemaEnabled = config?.db_schema
|
||||
.split(',')
|
||||
.map((name) => name.trim())
|
||||
.includes('public')
|
||||
|
||||
const isGraphqlExtensionEnabled =
|
||||
(extensions ?? []).find((ext) => ext.name === 'pg_graphql')?.installed_version !== null
|
||||
|
||||
return (
|
||||
<Form id={formId} initialValues={initialValues} validate={() => {}} onSubmit={updateConfig}>
|
||||
{({ handleReset, resetForm, values, initialValues }: any) => {
|
||||
const hasChanges = JSON.stringify(values) !== JSON.stringify(initialValues)
|
||||
|
||||
// [Alaister] although this "technically" is breaking the rules of React hooks
|
||||
// it won't error because the hooks are always rendered in the same order
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
@@ -83,7 +111,9 @@ const PostgrestConfig = () => {
|
||||
db_schema: config.db_schema,
|
||||
max_rows: config.max_rows,
|
||||
db_extra_search_path: config.db_extra_search_path ?? '',
|
||||
db_pool: config.db_pool || null,
|
||||
}
|
||||
|
||||
resetForm({ values, initialValues: values })
|
||||
}
|
||||
}, [config])
|
||||
@@ -112,51 +142,81 @@ const PostgrestConfig = () => {
|
||||
>
|
||||
{isError ? (
|
||||
<div className="flex items-center justify-center py-8 space-x-2">
|
||||
<IconAlertCircle size={16} strokeWidth={1.5} />
|
||||
<AlertCircle size={16} strokeWidth={1.5} />
|
||||
<p className="text-sm text-foreground-light">Failed to retrieve API settings</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<FormSection header={<FormSectionLabel>Exposed schemas</FormSectionLabel>}>
|
||||
<FormSectionContent loading={false}>
|
||||
{schema.length >= 1 && (
|
||||
<MultiSelect
|
||||
disabled={!canUpdatePostgrestConfig}
|
||||
options={schema}
|
||||
descriptionText={
|
||||
<>
|
||||
{isLoadingSchemas ? (
|
||||
<div className="col-span-12 flex flex-col gap-2 lg:col-span-7">
|
||||
<ShimmeringLoader />
|
||||
<ShimmeringLoader className="w-3/4" />
|
||||
</div>
|
||||
) : (
|
||||
<FormSectionContent loading={false}>
|
||||
{schema.length >= 1 && (
|
||||
<div className="grid gap-2">
|
||||
<MultiSelectV2
|
||||
options={schema}
|
||||
disabled={!canUpdatePostgrestConfig}
|
||||
value={
|
||||
values?.db_schema
|
||||
? values.db_schema.replace(/ /g, '').split(',')
|
||||
: []
|
||||
}
|
||||
placeholder="Choose a schema to expose"
|
||||
searchPlaceholder="Search for a schema"
|
||||
onChange={(event) => {
|
||||
let updatedValues: any = values
|
||||
updatedValues.db_schema = event.join(', ')
|
||||
resetForm({ values: updatedValues, initialValues: updatedValues })
|
||||
updateConfig({ ...updatedValues })
|
||||
}}
|
||||
/>
|
||||
<p className="text-foreground-lighter text-sm">
|
||||
The schemas to expose in your API. Tables, views and stored procedures
|
||||
in these schemas will get API endpoints.
|
||||
<code className="text-xs">public</code> and{' '}
|
||||
<code className="text-xs">storage</code> are protected by default.
|
||||
</>
|
||||
}
|
||||
emptyMessage={
|
||||
<>
|
||||
<IconAlertCircle strokeWidth={2} />
|
||||
<div className="flex flex-col mt-2 text-center">
|
||||
<p className="text-sm align-center">
|
||||
No schema available to choose
|
||||
</p>
|
||||
<p className="text-xs opacity-50">
|
||||
New schemas you create will appear here
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
// value must be passed as array of strings
|
||||
value={(values?.db_schema ?? '').replace(/ /g, '').split(',')}
|
||||
// onChange returns array of strings
|
||||
onChange={(event) => {
|
||||
let updatedValues: any = values
|
||||
updatedValues.db_schema = event.join(', ')
|
||||
resetForm({ values: updatedValues, initialValues: updatedValues })
|
||||
updateConfig({ ...updatedValues })
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</FormSectionContent>
|
||||
</p>
|
||||
|
||||
{!isPublicSchemaEnabled && (
|
||||
<Alert_Shadcn_ variant="default">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle_Shadcn_>
|
||||
The public schema for this project is not exposed
|
||||
</AlertTitle_Shadcn_>
|
||||
<AlertDescription_Shadcn_>
|
||||
<p>
|
||||
You will not be able to query tables and views in the{' '}
|
||||
<code>public</code> schema via supabase-js or HTTP clients.
|
||||
</p>
|
||||
{isGraphqlExtensionEnabled && (
|
||||
<div className="grid gap-3 mt-2">
|
||||
<div>
|
||||
Tables in the <code>public</code> schema are still exposed
|
||||
over our GraphQL endpoints.
|
||||
</div>
|
||||
<p>
|
||||
<Button asChild type="default">
|
||||
<Link
|
||||
href={`/project/${projectRef}/database/extensions`}
|
||||
className="!no-underline !hover:bg-surface-100 !text-foreground"
|
||||
>
|
||||
Disable the pg_graphql extension
|
||||
</Link>
|
||||
</Button>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</AlertDescription_Shadcn_>
|
||||
</Alert_Shadcn_>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</FormSectionContent>
|
||||
)}
|
||||
</FormSection>
|
||||
|
||||
<FormSection header={<FormSectionLabel>Extra search path</FormSectionLabel>}>
|
||||
<FormSectionContent loading={false}>
|
||||
<Input
|
||||
@@ -177,6 +237,17 @@ const PostgrestConfig = () => {
|
||||
/>
|
||||
</FormSectionContent>
|
||||
</FormSection>
|
||||
<FormSection header={<FormSectionLabel>Pool size</FormSectionLabel>}>
|
||||
<FormSectionContent loading={false}>
|
||||
<InputNumber
|
||||
id="db_pool"
|
||||
size="small"
|
||||
disabled={!canUpdatePostgrestConfig}
|
||||
descriptionText="Number of maximum connections to keep open in the Data API server's database pool. Unset to let it be configured automatically based on compute size."
|
||||
placeholder="Configured automatically based on compute size"
|
||||
/>
|
||||
</FormSectionContent>
|
||||
</FormSection>
|
||||
</>
|
||||
)}
|
||||
</FormPanel>
|
||||
|
||||
@@ -169,7 +169,7 @@ const ServiceList = () => {
|
||||
<JWTSettings />
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<section id="postgrest-config">
|
||||
<PostgrestConfig />
|
||||
</section>
|
||||
</>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useParams } from 'common'
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
@@ -11,17 +12,16 @@ import {
|
||||
SidePanel,
|
||||
} from 'ui'
|
||||
|
||||
import { WarningIcon } from 'ui-patterns/Icons/StatusIcons'
|
||||
import { useEnablePhysicalBackupsMutation } from 'data/database/enable-physical-backups-mutation'
|
||||
import { useProjectDetailQuery } from 'data/projects/project-detail-query'
|
||||
import { Region, useReadReplicaSetUpMutation } from 'data/read-replicas/replica-setup-mutation'
|
||||
import { useReadReplicasQuery } from 'data/read-replicas/replicas-query'
|
||||
import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query'
|
||||
import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query'
|
||||
import { useSelectedOrganization, useSelectedProject } from 'hooks'
|
||||
import { AWS_REGIONS, AWS_REGIONS_DEFAULT, AWS_REGIONS_KEYS, BASE_PATH } from 'lib/constants'
|
||||
import { WarningIcon } from 'ui-patterns/Icons/StatusIcons'
|
||||
import { AVAILABLE_REPLICA_REGIONS, AWS_REGIONS_VALUES } from './InstanceConfiguration.constants'
|
||||
import { useBackupsQuery } from 'data/database/backups-query'
|
||||
import { useEnablePhysicalBackupsMutation } from 'data/database/enable-physical-backups-mutation'
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
|
||||
// [Joshen] FYI this is purely for AWS only, need to update to support Fly eventually
|
||||
|
||||
@@ -44,16 +44,17 @@ const DeployNewReplicaPanel = ({
|
||||
const [refetchInterval, setRefetchInterval] = useState<number | false>(false)
|
||||
|
||||
const { data } = useReadReplicasQuery({ projectRef })
|
||||
const { data: backups } = useBackupsQuery(
|
||||
{ projectRef },
|
||||
useProjectDetailQuery(
|
||||
{ ref: projectRef },
|
||||
{
|
||||
refetchInterval,
|
||||
refetchOnWindowFocus: false,
|
||||
onSuccess: (data) => {
|
||||
if (data.walg_enabled) setRefetchInterval(false)
|
||||
if (data.is_physical_backups_enabled) setRefetchInterval(false)
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const { data: addons, isSuccess } = useProjectAddonsQuery({ projectRef })
|
||||
const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: org?.slug })
|
||||
|
||||
@@ -83,7 +84,7 @@ const DeployNewReplicaPanel = ({
|
||||
|
||||
const reachedMaxReplicas = (data ?? []).filter((db) => db.identifier !== projectRef).length >= 2
|
||||
const isFreePlan = subscription?.plan.id === 'free'
|
||||
const isWalgEnabled = backups?.walg_enabled
|
||||
const isWalgEnabled = project?.is_physical_backups_enabled
|
||||
const currentComputeAddon = addons?.selected_addons.find(
|
||||
(addon) => addon.type === 'compute_instance'
|
||||
)
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
import { useParams } from 'common'
|
||||
import toast from 'react-hot-toast'
|
||||
import {
|
||||
AlertDescription_Shadcn_,
|
||||
AlertTitle_Shadcn_,
|
||||
Alert_Shadcn_,
|
||||
IconAlertTriangle,
|
||||
Modal,
|
||||
} from 'ui'
|
||||
|
||||
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
||||
import { useReadReplicaRemoveMutation } from 'data/read-replicas/replica-remove-mutation'
|
||||
import { Database, useReadReplicasQuery } from 'data/read-replicas/replicas-query'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { replicaKeys } from 'data/read-replicas/keys'
|
||||
import { useReadReplicaRemoveMutation } from 'data/read-replicas/replica-remove-mutation'
|
||||
import { useReadReplicasQuery } from 'data/read-replicas/replicas-query'
|
||||
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
||||
|
||||
interface DropAllReplicasConfirmationModalProps {
|
||||
visible: boolean
|
||||
@@ -38,20 +31,19 @@ const DropAllReplicasConfirmationModal = ({
|
||||
const replicas = databases.filter((db) => db.identifier !== projectRef)
|
||||
await Promise.all(
|
||||
replicas.map((db) =>
|
||||
removeReadReplica({ projectRef, identifier: db.identifier, skipInvalidateOnSuccess: true })
|
||||
removeReadReplica({
|
||||
projectRef,
|
||||
identifier: db.identifier,
|
||||
invalidateReplicaQueries: false,
|
||||
})
|
||||
)
|
||||
)
|
||||
toast.success(`Tearing down all read replicas`)
|
||||
|
||||
queryClient.setQueriesData<any>(replicaKeys.list(projectRef), (old: Database[]) => {
|
||||
return old.filter((db: Database) => db.identifier === projectRef)
|
||||
})
|
||||
queryClient.setQueriesData<any>(replicaKeys.loadBalancers(projectRef), (old: Database[]) => [])
|
||||
|
||||
setTimeout(async () => {
|
||||
await queryClient.invalidateQueries(replicaKeys.list(projectRef))
|
||||
await queryClient.invalidateQueries(replicaKeys.loadBalancers(projectRef))
|
||||
}, 5000)
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries(replicaKeys.list(projectRef)),
|
||||
queryClient.invalidateQueries(replicaKeys.loadBalancers(projectRef)),
|
||||
])
|
||||
|
||||
onSuccess()
|
||||
onCancel()
|
||||
|
||||
@@ -38,7 +38,11 @@ const DropReplicaConfirmationModal = ({
|
||||
if (!projectRef) return console.error('Project is required')
|
||||
if (selectedReplica === undefined) return toast.error('No replica selected')
|
||||
|
||||
await removeReadReplica({ projectRef, identifier: selectedReplica.identifier })
|
||||
await removeReadReplica({
|
||||
projectRef,
|
||||
identifier: selectedReplica.identifier,
|
||||
invalidateReplicaQueries: true,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useParams } from 'common'
|
||||
import { partition } from 'lodash'
|
||||
import { Globe2, Loader2, Network } from 'lucide-react'
|
||||
import { ChevronDown, Globe2, Loader2, Network } from 'lucide-react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import ReactFlow, { Background, Edge, ReactFlowProvider, useReactFlow } from 'reactflow'
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
IconChevronDown,
|
||||
} from 'ui'
|
||||
|
||||
import AlertError from 'components/ui/AlertError'
|
||||
@@ -24,11 +23,12 @@ import { timeout } from 'lib/helpers'
|
||||
import { useSubscriptionPageStateSnapshot } from 'state/subscription-page'
|
||||
import ComputeInstanceSidePanel from '../../Addons/ComputeInstanceSidePanel'
|
||||
import DeployNewReplicaPanel from './DeployNewReplicaPanel'
|
||||
import DropAllReplicasConfirmationModal from './DropAllReplicasConfirmationModal'
|
||||
import DropReplicaConfirmationModal from './DropReplicaConfirmationModal'
|
||||
import { REPLICA_STATUS } from './InstanceConfiguration.constants'
|
||||
import { addRegionNodes, generateNodes, getDagreGraphLayout } from './InstanceConfiguration.utils'
|
||||
import { LoadBalancerNode, PrimaryNode, RegionNode, ReplicaNode } from './InstanceNode'
|
||||
import MapView from './MapView'
|
||||
import DropAllReplicasConfirmationModal from './DropAllReplicasConfirmationModal'
|
||||
|
||||
// [Joshen] Just FYI, UI assumes single provider for primary + replicas
|
||||
// [Joshen] Idea to visualize grouping based on region: https://reactflow.dev/examples/layout/sub-flows
|
||||
@@ -38,7 +38,7 @@ const InstanceConfigurationUI = () => {
|
||||
const reactFlow = useReactFlow()
|
||||
const { resolvedTheme } = useTheme()
|
||||
const { ref: projectRef } = useParams()
|
||||
const numComingUp = useRef<number>()
|
||||
const numTransition = useRef<number>()
|
||||
const snap = useSubscriptionPageStateSnapshot()
|
||||
|
||||
const [view, setView] = useState<'flow' | 'map'>('flow')
|
||||
@@ -76,13 +76,21 @@ const InstanceConfigurationUI = () => {
|
||||
{
|
||||
refetchInterval: refetchInterval as any,
|
||||
refetchOnWindowFocus: false,
|
||||
onSuccess: async (data) => {
|
||||
const comingUpReplicas = data.filter((db) => db.status === 'COMING_UP')
|
||||
const hasTransientStatus = comingUpReplicas.length > 0
|
||||
onSuccess: async (res) => {
|
||||
const fixedStatues = [
|
||||
REPLICA_STATUS.ACTIVE_HEALTHY,
|
||||
REPLICA_STATUS.ACTIVE_UNHEALTHY,
|
||||
REPLICA_STATUS.INIT_READ_REPLICA_FAILED,
|
||||
]
|
||||
const replicasInTransition = res.filter((db) => !fixedStatues.includes(db.status))
|
||||
const hasTransientStatus = replicasInTransition.length > 0
|
||||
|
||||
// If any replica's status has changed, refetch databases
|
||||
if (numComingUp.current !== comingUpReplicas.length) {
|
||||
numComingUp.current = comingUpReplicas.length
|
||||
if (
|
||||
numTransition.current !== replicasInTransition.length ||
|
||||
res.length !== (data ?? []).length
|
||||
) {
|
||||
numTransition.current = replicasInTransition.length
|
||||
await refetchReplicas()
|
||||
setTimeout(() => refetchLoadBalancers(), 2000)
|
||||
}
|
||||
@@ -192,7 +200,7 @@ const InstanceConfigurationUI = () => {
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="default"
|
||||
icon={<IconChevronDown size={16} />}
|
||||
icon={<ChevronDown size={16} />}
|
||||
className="px-1 rounded-l-none border-l-0"
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -261,7 +269,7 @@ const InstanceConfigurationUI = () => {
|
||||
<DeployNewReplicaPanel
|
||||
visible={showNewReplicaPanel}
|
||||
selectedDefaultRegion={newReplicaRegion}
|
||||
onSuccess={() => setRefetchInterval(10000)}
|
||||
onSuccess={() => setRefetchInterval(5000)}
|
||||
onClose={() => {
|
||||
setNewReplicaRegion(undefined)
|
||||
setShowNewReplicaPanel(false)
|
||||
@@ -270,13 +278,13 @@ const InstanceConfigurationUI = () => {
|
||||
|
||||
<DropReplicaConfirmationModal
|
||||
selectedReplica={selectedReplicaToDrop}
|
||||
onSuccess={() => setRefetchInterval(10000)}
|
||||
onSuccess={() => setRefetchInterval(5000)}
|
||||
onCancel={() => setSelectedReplicaToDrop(undefined)}
|
||||
/>
|
||||
|
||||
<DropAllReplicasConfirmationModal
|
||||
visible={showDeleteAllModal}
|
||||
onSuccess={() => setRefetchInterval(10000)}
|
||||
onSuccess={() => setRefetchInterval(5000)}
|
||||
onCancel={() => setShowDeleteAllModal(false)}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useParams } from 'common'
|
||||
import dayjs from 'dayjs'
|
||||
import { Database, DatabaseBackup } from 'lucide-react'
|
||||
import { Database, DatabaseBackup, HelpCircle, Loader2 } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { Handle, NodeProps, Position } from 'reactflow'
|
||||
import {
|
||||
@@ -11,11 +11,11 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
IconHelpCircle,
|
||||
IconMoreVertical,
|
||||
TooltipContent_Shadcn_,
|
||||
TooltipTrigger_Shadcn_,
|
||||
Tooltip_Shadcn_,
|
||||
cn,
|
||||
} from 'ui'
|
||||
|
||||
import { formatDatabaseID } from 'data/read-replicas/replicas.utils'
|
||||
@@ -173,6 +173,15 @@ export const ReplicaNode = ({ data }: NodeProps<ReplicaNodeData>) => {
|
||||
const { ref } = useParams()
|
||||
const created = dayjs(inserted_at).format('DD MMM YYYY')
|
||||
|
||||
const isInTransition = (
|
||||
[
|
||||
REPLICA_STATUS.COMING_UP,
|
||||
REPLICA_STATUS.GOING_DOWN,
|
||||
REPLICA_STATUS.RESTORING,
|
||||
REPLICA_STATUS.INIT_READ_REPLICA,
|
||||
] as string[]
|
||||
).includes(status)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Handle
|
||||
@@ -186,8 +195,19 @@ export const ReplicaNode = ({ data }: NodeProps<ReplicaNodeData>) => {
|
||||
style={{ width: NODE_WIDTH / 2 - 10 }}
|
||||
>
|
||||
<div className="flex gap-x-3">
|
||||
<div className="w-8 h-8 bg-brand-400 border border-brand-500 rounded-md flex items-center justify-center">
|
||||
<DatabaseBackup size={16} />
|
||||
<div
|
||||
className={cn(
|
||||
'w-8 h-8 border rounded-md flex items-center justify-center',
|
||||
status === REPLICA_STATUS.ACTIVE_HEALTHY
|
||||
? 'bg-brand-400 border-brand-500'
|
||||
: 'bg-surface-100 border-foreground/20'
|
||||
)}
|
||||
>
|
||||
{isInTransition ? (
|
||||
<Loader2 className="animate-spin" size={16} />
|
||||
) : (
|
||||
<DatabaseBackup size={16} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-y-0.5">
|
||||
<div className="flex items-center gap-x-2">
|
||||
@@ -203,15 +223,22 @@ export const ReplicaNode = ({ data }: NodeProps<ReplicaNodeData>) => {
|
||||
<Badge variant="destructive">Init failed</Badge>
|
||||
<Tooltip_Shadcn_>
|
||||
<TooltipTrigger_Shadcn_>
|
||||
<IconHelpCircle />
|
||||
<HelpCircle size={16} />
|
||||
</TooltipTrigger_Shadcn_>
|
||||
<TooltipContent_Shadcn_ side="bottom" className="w-60 text-center">
|
||||
Replica failed to initialize. Please drop this replica, and spin up a new one.
|
||||
<TooltipContent_Shadcn_
|
||||
side="bottom"
|
||||
align="end"
|
||||
alignOffset={-70}
|
||||
className="w-60 text-center"
|
||||
>
|
||||
Replica failed to initialize. Please drop this replica and spin up a new one.
|
||||
</TooltipContent_Shadcn_>
|
||||
</Tooltip_Shadcn_>
|
||||
</>
|
||||
) : status === REPLICA_STATUS.COMING_UP ? (
|
||||
<Badge>Coming up</Badge>
|
||||
) : status === REPLICA_STATUS.GOING_DOWN ? (
|
||||
<Badge>Going down</Badge>
|
||||
) : status === REPLICA_STATUS.RESTORING ? (
|
||||
<Badge>Restoring</Badge>
|
||||
) : (
|
||||
|
||||
@@ -277,12 +277,14 @@ const _SQL_FILTER_COMMON = {
|
||||
export const SQL_FILTER_TEMPLATES: any = {
|
||||
postgres_logs: {
|
||||
..._SQL_FILTER_COMMON,
|
||||
database: (value: string) => `identifier = '${value}'`,
|
||||
'severity.error': `parsed.error_severity in ('ERROR', 'FATAL', 'PANIC')`,
|
||||
'severity.noError': `parsed.error_severity not in ('ERROR', 'FATAL', 'PANIC')`,
|
||||
'severity.log': `parsed.error_severity = 'LOG'`,
|
||||
},
|
||||
edge_logs: {
|
||||
..._SQL_FILTER_COMMON,
|
||||
database: (value: string) => `identifier = '${value}'`,
|
||||
'status_code.error': `response.status_code between 500 and 599`,
|
||||
'status_code.success': `response.status_code between 200 and 299`,
|
||||
'status_code.warning': `response.status_code between 400 and 499`,
|
||||
@@ -344,6 +346,7 @@ export const SQL_FILTER_TEMPLATES: any = {
|
||||
},
|
||||
supavisor_logs: {
|
||||
..._SQL_FILTER_COMMON,
|
||||
database: (value: string) => `m.project like '${value}%'`,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -712,3 +715,9 @@ export const TIER_QUERY_LIMITS: {
|
||||
TEAM: { text: '28 days', value: 28, unit: 'day', promptUpgrade: true },
|
||||
ENTERPRISE: { text: '90 days', value: 90, unit: 'day', promptUpgrade: false },
|
||||
}
|
||||
|
||||
export const LOG_ROUTES_WITH_REPLICA_SUPPORT = [
|
||||
'/project/[ref]/logs/edge-logs',
|
||||
'/project/[ref]/logs/pooler-logs',
|
||||
'/project/[ref]/logs/postgres-logs',
|
||||
]
|
||||
@@ -81,7 +81,7 @@ const genWhereStatement = (table: LogsTableName, filters: Filters) => {
|
||||
if (value !== undefined && typeof template === 'function') {
|
||||
return template(value)
|
||||
} else if (template === undefined) {
|
||||
// resolve unknwon filters (possibly from filter overrides)
|
||||
// resolve unknown filters (possibly from filter overrides)
|
||||
// no template, set a default
|
||||
if (typeof value === 'string') {
|
||||
return `${dotKey} = '${value}'`
|
||||
@@ -105,12 +105,6 @@ const genWhereStatement = (table: LogsTableName, filters: Filters) => {
|
||||
(typeof filters[rootKey] === 'string' && (filters[rootKey] as string).length === 0)
|
||||
) {
|
||||
return null
|
||||
} else if (rootKey === 'database') {
|
||||
return table === 'edge_logs'
|
||||
? `(request.host like '${filters[rootKey]}%')`
|
||||
: table === 'supavisor_logs'
|
||||
? `(m.project like '${filters[rootKey]}%')`
|
||||
: null
|
||||
} else if (typeof filters[rootKey] === 'object') {
|
||||
// join all statements with an OR
|
||||
const nestedStatements = getDotKeys(filters[rootKey] as Filters, rootKey)
|
||||
@@ -145,7 +139,7 @@ export const genDefaultQuery = (table: LogsTableName, filters: Filters) => {
|
||||
const orderBy = 'order by timestamp desc'
|
||||
switch (table) {
|
||||
case 'edge_logs':
|
||||
return `select id, timestamp, event_message, request.method, request.path, response.status_code
|
||||
return `select id, identifier, timestamp, event_message, request.method, request.path, response.status_code
|
||||
from ${table}
|
||||
${joins}
|
||||
${where}
|
||||
@@ -154,7 +148,7 @@ export const genDefaultQuery = (table: LogsTableName, filters: Filters) => {
|
||||
`
|
||||
|
||||
case 'postgres_logs':
|
||||
return `select postgres_logs.timestamp, id, event_message, parsed.error_severity from ${table}
|
||||
return `select identifier, postgres_logs.timestamp, id, event_message, parsed.error_severity from ${table}
|
||||
${joins}
|
||||
${where}
|
||||
${orderBy}
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import { Button, Checkbox, Form, Popover } from 'ui'
|
||||
import React, { useState } from 'react'
|
||||
import { Filters, FilterSet } from '.'
|
||||
import { Fragment, useState } from 'react'
|
||||
|
||||
interface Props {
|
||||
import { Button, Checkbox, Form, Popover } from 'ui'
|
||||
import { FilterSet, Filters } from '.'
|
||||
|
||||
interface LogsFilterPopoverProps {
|
||||
options: FilterSet
|
||||
filters: Filters
|
||||
onFiltersChange: (filters: Filters) => void
|
||||
buttonClassName: string
|
||||
}
|
||||
|
||||
const LogsFilterPopover: React.FC<Props> = ({
|
||||
const LogsFilterPopover = ({
|
||||
options,
|
||||
filters,
|
||||
onFiltersChange,
|
||||
buttonClassName,
|
||||
}) => {
|
||||
}: LogsFilterPopoverProps) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const handleReset = () => {
|
||||
@@ -51,7 +52,7 @@ const LogsFilterPopover: React.FC<Props> = ({
|
||||
<>
|
||||
<div className="space-y-4 py-6">
|
||||
{options.options.map((x, i: number) => (
|
||||
<React.Fragment key={x.key}>
|
||||
<Fragment key={x.key}>
|
||||
<Checkbox
|
||||
value="true"
|
||||
id={`${options.key}.${x.key}`}
|
||||
@@ -62,7 +63,7 @@ const LogsFilterPopover: React.FC<Props> = ({
|
||||
defaultChecked={(filters?.[options.key] as Filters)?.[x.key] as boolean}
|
||||
/>
|
||||
{i !== options.options.length - 1 && <Popover.Separator />}
|
||||
</React.Fragment>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 border-t border-default bg-studio py-2 px-3">
|
||||
@@ -70,7 +71,7 @@ const LogsFilterPopover: React.FC<Props> = ({
|
||||
Clear
|
||||
</Button>
|
||||
<Button loading={isSubmitting} type="primary" htmlType="submit">
|
||||
Save
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -24,7 +24,7 @@ import { useSelectedOrganization } from 'hooks'
|
||||
import useLogsPreview from 'hooks/analytics/useLogsPreview'
|
||||
import { useUpgradePrompt } from 'hooks/misc/useUpgradePrompt'
|
||||
import { useDatabaseSelectorStateSnapshot } from 'state/database-selector'
|
||||
import { LOGS_TABLES } from './Logs.constants'
|
||||
import { LOGS_TABLES, LOG_ROUTES_WITH_REPLICA_SUPPORT } from './Logs.constants'
|
||||
import UpgradePrompt from './UpgradePrompt'
|
||||
|
||||
/**
|
||||
@@ -110,9 +110,23 @@ export const LogsPreviewer = ({
|
||||
}, [its, subscription])
|
||||
|
||||
useEffect(() => {
|
||||
if (readReplicasEnabled && db !== undefined) {
|
||||
const database = databases?.find((d) => d.identifier === db)
|
||||
if (database !== undefined) state.setSelectedDatabaseId(db)
|
||||
if (readReplicasEnabled) {
|
||||
if (db !== undefined) {
|
||||
const database = databases?.find((d) => d.identifier === db)
|
||||
if (database !== undefined) state.setSelectedDatabaseId(db)
|
||||
} else if (
|
||||
state.selectedDatabaseId !== undefined &&
|
||||
state.selectedDatabaseId !== projectRef
|
||||
) {
|
||||
if (LOG_ROUTES_WITH_REPLICA_SUPPORT.includes(router.pathname)) {
|
||||
router.push({
|
||||
pathname: router.pathname,
|
||||
query: { ...router.query, db: state.selectedDatabaseId },
|
||||
})
|
||||
} else {
|
||||
state.setSelectedDatabaseId(projectRef)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [readReplicasEnabled, db, isSuccess])
|
||||
|
||||
|
||||
@@ -1,25 +1,15 @@
|
||||
import { Eye, EyeOff, RefreshCw, Search, Terminal } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
IconEye,
|
||||
IconEyeOff,
|
||||
IconRefreshCw,
|
||||
IconSearch,
|
||||
IconTerminal,
|
||||
Input,
|
||||
TooltipContent_Shadcn_,
|
||||
TooltipTrigger_Shadcn_,
|
||||
Tooltip_Shadcn_,
|
||||
} from 'ui'
|
||||
import { Button, Input, TooltipContent_Shadcn_, TooltipTrigger_Shadcn_, Tooltip_Shadcn_ } from 'ui'
|
||||
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import CSVButton from 'components/ui/CSVButton'
|
||||
import DatabaseSelector from 'components/ui/DatabaseSelector'
|
||||
import { Filters, LogSearchCallback, LogTemplate, PREVIEWER_DATEPICKER_HELPERS } from '.'
|
||||
import DatePickers from './Logs.DatePickers'
|
||||
import { FILTER_OPTIONS, LogsTableName } from './Logs.constants'
|
||||
import { FILTER_OPTIONS, LOG_ROUTES_WITH_REPLICA_SUPPORT, LogsTableName } from './Logs.constants'
|
||||
import LogsFilterPopover from './LogsFilterPopover'
|
||||
|
||||
interface PreviewFilterPanelProps {
|
||||
@@ -72,8 +62,7 @@ const PreviewFilterPanel = ({
|
||||
|
||||
// [Joshen] These are the routes tested that can show replica logs
|
||||
const showDatabaseSelector =
|
||||
project?.is_read_replicas_enabled &&
|
||||
['/project/[ref]/logs/edge-logs', '/project/[ref]/logs/pooler-logs'].includes(router.pathname)
|
||||
project?.is_read_replicas_enabled && LOG_ROUTES_WITH_REPLICA_SUPPORT.includes(router.pathname)
|
||||
|
||||
const hasEdits = search !== defaultSearchValue
|
||||
|
||||
@@ -99,11 +88,11 @@ const PreviewFilterPanel = ({
|
||||
{newCount > 1000 ? `${Math.floor(newCount / 100) / 10}K` : newCount}
|
||||
</p>
|
||||
</div>
|
||||
<div className="h-full w-full animate-ping rounded-full bg-green-800 opacity-60"></div>
|
||||
<div className="h-4 w-4 animate-ping rounded-full bg-green-800 opacity-60"></div>
|
||||
<div className="z-60 absolute top-0 right-0 h-full w-full rounded-full bg-green-900 opacity-80"></div>
|
||||
</div>
|
||||
)}
|
||||
<IconRefreshCw />
|
||||
<RefreshCw />
|
||||
</div>
|
||||
}
|
||||
loading={isLoading}
|
||||
@@ -147,7 +136,7 @@ const PreviewFilterPanel = ({
|
||||
}}
|
||||
icon={
|
||||
<div className="text-foreground-lighter">
|
||||
<IconSearch size={14} />
|
||||
<Search size={14} />
|
||||
</div>
|
||||
}
|
||||
value={search}
|
||||
@@ -205,7 +194,7 @@ const PreviewFilterPanel = ({
|
||||
<Button
|
||||
type="default"
|
||||
onClick={() => onToggleEventChart()}
|
||||
icon={isShowingEventChart ? <IconEye /> : <IconEyeOff />}
|
||||
icon={isShowingEventChart ? <Eye /> : <EyeOff />}
|
||||
>
|
||||
Chart
|
||||
</Button>
|
||||
@@ -217,7 +206,7 @@ const PreviewFilterPanel = ({
|
||||
<div className="flex items-center justify-center gap-x-2">
|
||||
<Tooltip_Shadcn_ delayDuration={100}>
|
||||
<TooltipTrigger_Shadcn_ asChild>
|
||||
<Button asChild className="px-1" type="default" icon={<IconTerminal />}>
|
||||
<Button asChild className="px-1" type="default" icon={<Terminal />}>
|
||||
<Link href={queryUrl} />
|
||||
</Button>
|
||||
</TooltipTrigger_Shadcn_>
|
||||
|
||||
@@ -103,9 +103,6 @@ const DeleteConfirmationDialogs = ({
|
||||
}
|
||||
toast.success(`Successfully deleted selected row(s)`)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Failed to delete row: ${error.message}`)
|
||||
},
|
||||
onSettled: () => {
|
||||
snap.closeConfirmationDialog()
|
||||
},
|
||||
|
||||
@@ -36,6 +36,9 @@ const FeedbackWidget = ({
|
||||
screenshot,
|
||||
setScreenshot,
|
||||
}: FeedbackWidgetProps) => {
|
||||
const FEEDBACK_STORAGE_KEY = 'feedback_content'
|
||||
const SCREENSHOT_STORAGE_KEY = 'screenshot'
|
||||
|
||||
const router = useRouter()
|
||||
const { ref, slug } = useParams()
|
||||
const inputRef = useRef<any>(null)
|
||||
@@ -45,6 +48,28 @@ const FeedbackWidget = ({
|
||||
const [isSavingScreenshot, setIsSavingScreenshot] = useState(false)
|
||||
const { mutateAsync: submitFeedback } = useSendFeedbackMutation()
|
||||
|
||||
useEffect(() => {
|
||||
const storedFeedback = localStorage.getItem(FEEDBACK_STORAGE_KEY)
|
||||
if (storedFeedback) {
|
||||
setFeedback(storedFeedback)
|
||||
}
|
||||
|
||||
const storedScreenshot = localStorage.getItem(SCREENSHOT_STORAGE_KEY)
|
||||
if (storedScreenshot) {
|
||||
setScreenshot(storedScreenshot)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(FEEDBACK_STORAGE_KEY, feedback)
|
||||
}, [feedback])
|
||||
|
||||
useEffect(() => {
|
||||
if (screenshot) {
|
||||
localStorage.setItem(SCREENSHOT_STORAGE_KEY, screenshot)
|
||||
}
|
||||
}, [screenshot])
|
||||
|
||||
useEffect(() => {
|
||||
inputRef?.current?.focus()
|
||||
}, [inputRef])
|
||||
@@ -53,6 +78,13 @@ const FeedbackWidget = ({
|
||||
setFeedback(e.target.value)
|
||||
}
|
||||
|
||||
const clearFeedback = () => {
|
||||
setFeedback('')
|
||||
setScreenshot(undefined)
|
||||
localStorage.removeItem(FEEDBACK_STORAGE_KEY)
|
||||
localStorage.removeItem(SCREENSHOT_STORAGE_KEY)
|
||||
}
|
||||
|
||||
const captureScreenshot = async () => {
|
||||
setIsSavingScreenshot(true)
|
||||
|
||||
@@ -66,7 +98,10 @@ const FeedbackWidget = ({
|
||||
// Give time for dropdown to close
|
||||
await timeout(100)
|
||||
toPng(document.body, { filter })
|
||||
.then((dataUrl: any) => setScreenshot(dataUrl))
|
||||
.then((dataUrl: any) => {
|
||||
localStorage.setItem(SCREENSHOT_STORAGE_KEY, dataUrl)
|
||||
setScreenshot(dataUrl)
|
||||
})
|
||||
.catch((error: any) => toast.error('Failed to capture screenshot'))
|
||||
.finally(() => {
|
||||
setIsSavingScreenshot(false)
|
||||
@@ -79,7 +114,11 @@ const FeedbackWidget = ({
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = function (event) {
|
||||
setScreenshot(event.target?.result as string)
|
||||
const dataUrl = event.target?.result
|
||||
if (typeof dataUrl === 'string') {
|
||||
setScreenshot(dataUrl)
|
||||
localStorage.setItem(SCREENSHOT_STORAGE_KEY, dataUrl)
|
||||
}
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
event.target.value = ''
|
||||
@@ -105,6 +144,9 @@ const FeedbackWidget = ({
|
||||
pathname: router.asPath,
|
||||
})
|
||||
setFeedback('')
|
||||
setScreenshot(undefined)
|
||||
localStorage.removeItem(FEEDBACK_STORAGE_KEY)
|
||||
localStorage.removeItem(SCREENSHOT_STORAGE_KEY)
|
||||
toast.success(
|
||||
'Feedback sent. Thank you!\n\nPlease be aware that we do not provide responses to feedback. If you require assistance or a reply, consider submitting a support ticket.'
|
||||
)
|
||||
@@ -133,6 +175,9 @@ const FeedbackWidget = ({
|
||||
Cancel
|
||||
</Button>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button type="default" onClick={clearFeedback}>
|
||||
Clear
|
||||
</Button>
|
||||
{screenshot !== undefined ? (
|
||||
<div
|
||||
style={{ backgroundImage: `url("${screenshot}")` }}
|
||||
|
||||
@@ -17,12 +17,18 @@ import {
|
||||
PopoverTrigger_Shadcn_,
|
||||
Popover_Shadcn_,
|
||||
ScrollArea,
|
||||
TooltipContent_Shadcn_,
|
||||
TooltipTrigger_Shadcn_,
|
||||
Tooltip_Shadcn_,
|
||||
cn,
|
||||
} from 'ui'
|
||||
|
||||
import { useReadReplicasQuery } from 'data/read-replicas/replicas-query'
|
||||
import { formatDatabaseID, formatDatabaseRegion } from 'data/read-replicas/replicas.utils'
|
||||
import { useDatabaseSelectorStateSnapshot } from 'state/database-selector'
|
||||
import { Check } from 'lucide-react'
|
||||
import { REPLICA_STATUS } from 'components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.constants'
|
||||
import { Markdown } from 'components/interfaces/Markdown'
|
||||
|
||||
interface DatabaseSelectorProps {
|
||||
variant?: 'regular' | 'connected-on-right' | 'connected-on-left' | 'connected-on-both'
|
||||
@@ -124,6 +130,33 @@ const DatabaseSelector = ({
|
||||
const region = formatDatabaseRegion(database.region)
|
||||
const id = formatDatabaseID(database.identifier)
|
||||
|
||||
if (database.status !== 'ACTIVE_HEALTHY') {
|
||||
const status = [
|
||||
REPLICA_STATUS.INIT_READ_REPLICA,
|
||||
REPLICA_STATUS.COMING_UP,
|
||||
].includes(database.status)
|
||||
? 'coming up'
|
||||
: 'not healthy'
|
||||
|
||||
return (
|
||||
<Tooltip_Shadcn_ key={database.identifier}>
|
||||
<TooltipTrigger_Shadcn_ asChild>
|
||||
<div className="px-2 py-1.5 w-full flex items-center justify-between">
|
||||
<p className="text-xs text-foreground-lighter">
|
||||
Read replica ({region} - {id})
|
||||
</p>
|
||||
</div>
|
||||
</TooltipTrigger_Shadcn_>
|
||||
<TooltipContent_Shadcn_ side="right" className="w-80">
|
||||
<Markdown
|
||||
className="text-xs text-foreground"
|
||||
content={`Replica unable to accept requests as its ${status}. [View infrastructure settings](/project/${projectRef}/settings/infrastructure) for more information.`}
|
||||
/>
|
||||
</TooltipContent_Shadcn_>
|
||||
</Tooltip_Shadcn_>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<CommandItem_Shadcn_
|
||||
key={database.identifier}
|
||||
@@ -146,7 +179,7 @@ const DatabaseSelector = ({
|
||||
? 'Primary database'
|
||||
: `Read replica (${region} - ${id})`}
|
||||
</p>
|
||||
{database.identifier === selectedDatabaseId && <IconCheck />}
|
||||
{database.identifier === selectedDatabaseId && <Check size={16} />}
|
||||
</div>
|
||||
</CommandItem_Shadcn_>
|
||||
)
|
||||
|
||||
@@ -13,6 +13,7 @@ export type ProjectPostgrestConfigResponse = {
|
||||
db_schema: string
|
||||
db_anon_role: string
|
||||
db_extra_search_path: string
|
||||
db_pool: number | null
|
||||
jwt_secret: string
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ export type ProjectPostgrestConfigUpdateVariables = {
|
||||
dbSchema: string
|
||||
maxRows: string
|
||||
dbExtraSearchPath: string
|
||||
dbPool: string | null
|
||||
}
|
||||
|
||||
export async function updateProjectPostgrestConfig({
|
||||
@@ -18,11 +19,13 @@ export async function updateProjectPostgrestConfig({
|
||||
dbSchema,
|
||||
maxRows,
|
||||
dbExtraSearchPath,
|
||||
dbPool,
|
||||
}: ProjectPostgrestConfigUpdateVariables) {
|
||||
const response = await patch(`${API_URL}/projects/${projectRef}/config/postgrest`, {
|
||||
db_schema: dbSchema,
|
||||
max_rows: maxRows,
|
||||
db_extra_search_path: dbExtraSearchPath,
|
||||
db_pool: dbPool,
|
||||
})
|
||||
|
||||
if (response.error) throw response.error
|
||||
|
||||
@@ -2,12 +2,18 @@ import { UseQueryOptions, useQuery } from '@tanstack/react-query'
|
||||
import { get } from 'data/fetchers'
|
||||
import type { ResponseError } from 'types'
|
||||
import { databaseExtensionsKeys } from './keys'
|
||||
import { components } from 'api-types'
|
||||
|
||||
export type DatabaseExtensionsVariables = {
|
||||
projectRef?: string
|
||||
connectionString?: string
|
||||
}
|
||||
|
||||
export type Extension = components['schemas']['PostgresExtension']
|
||||
export interface DatabaseExtension extends Extension {
|
||||
altName?: string
|
||||
}
|
||||
|
||||
export async function getDatabaseExtensions(
|
||||
{ projectRef, connectionString }: DatabaseExtensionsVariables,
|
||||
signal?: AbortSignal
|
||||
@@ -31,7 +37,7 @@ export async function getDatabaseExtensions(
|
||||
})
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
return data as DatabaseExtension[]
|
||||
}
|
||||
|
||||
export type DatabaseExtensionsData = Awaited<ReturnType<typeof getDatabaseExtensions>>
|
||||
|
||||
@@ -248,6 +248,7 @@ export type IntegrationConnectionsCreateVariables = {
|
||||
connection: {
|
||||
foreign_project_id: string
|
||||
supabase_project_ref: string
|
||||
integration_id: string
|
||||
metadata: any
|
||||
}
|
||||
orgSlug: string | undefined
|
||||
|
||||
@@ -80,7 +80,7 @@ union all
|
||||
(
|
||||
select
|
||||
'auth_users_exposed' as name,
|
||||
'WARN' as level,
|
||||
'ERROR' as level,
|
||||
'EXTERNAL' as facing,
|
||||
array['SECURITY'] as categories,
|
||||
'Detects if auth.users is exposed to anon or authenticated roles via a view or materialized view in the public schema, potentially compromising user data security.' as description,
|
||||
@@ -90,22 +90,22 @@ select
|
||||
) as detail,
|
||||
'https://supabase.com/docs/guides/database/database-linter?lint=0002_auth_users_exposed' as remediation,
|
||||
jsonb_build_object(
|
||||
'schema', 'public',
|
||||
'schema', n.nspname,
|
||||
'name', c.relname,
|
||||
'type', 'view',
|
||||
'exposed_to', array_remove(array_agg(DISTINCT case when pg_catalog.has_table_privilege('anon', c.oid, 'SELECT') then 'anon' when pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') then 'authenticated' end), null)
|
||||
) as metadata,
|
||||
format('auth_users_exposed_%s_%s', 'public', c.relname) as cache_key
|
||||
format('auth_users_exposed_%s_%s', n.nspname, c.relname) as cache_key
|
||||
from
|
||||
-- Identify the oid for auth.users
|
||||
pg_catalog.pg_class auth_users_pg_class
|
||||
pg_catalog.pg_class auth_users_pg_class
|
||||
join pg_catalog.pg_namespace auth_users_pg_namespace
|
||||
on auth_users_pg_class.relnamespace = auth_users_pg_namespace.oid
|
||||
and auth_users_pg_class.relname = 'users'
|
||||
and auth_users_pg_namespace.nspname = 'auth'
|
||||
-- Depends on auth.users
|
||||
on auth_users_pg_class.relnamespace = auth_users_pg_namespace.oid
|
||||
and auth_users_pg_class.relname = 'users'
|
||||
and auth_users_pg_namespace.nspname = 'auth'
|
||||
-- Depends on auth.users
|
||||
join pg_catalog.pg_depend d
|
||||
on d.refobjid = auth_users_pg_class.oid
|
||||
on d.refobjid = auth_users_pg_class.oid
|
||||
join pg_catalog.pg_rewrite r
|
||||
on r.oid = d.objid
|
||||
join pg_catalog.pg_class c
|
||||
@@ -116,7 +116,6 @@ from
|
||||
on d.refobjid = pg_class_auth_users.oid
|
||||
where
|
||||
d.deptype = 'n'
|
||||
and n.nspname = 'public'
|
||||
and (
|
||||
pg_catalog.has_table_privilege('anon', c.oid, 'SELECT')
|
||||
or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT')
|
||||
@@ -132,7 +131,7 @@ where
|
||||
-- Standard View, accessible to anon or authenticated that is security_definer
|
||||
(
|
||||
c.relkind = 'v' -- v for view
|
||||
-- Exclude security invoker views
|
||||
-- Exclude security invoker views
|
||||
and not (
|
||||
lower(coalesce(c.reloptions::text,'{}'))::text[]
|
||||
&& array[
|
||||
@@ -147,7 +146,7 @@ where
|
||||
-- Standard View, security invoker, but no RLS enabled on auth.users
|
||||
(
|
||||
c.relkind in ('v') -- v for view
|
||||
-- is security invoker
|
||||
-- is security invoker
|
||||
and (
|
||||
lower(coalesce(c.reloptions::text,'{}'))::text[]
|
||||
&& array[
|
||||
@@ -157,11 +156,13 @@ where
|
||||
'security_invoker=on'
|
||||
]
|
||||
)
|
||||
and not pg_class_auth_users.relrowsecurity
|
||||
and not pg_class_auth_users.relrowsecurity
|
||||
)
|
||||
)
|
||||
group by
|
||||
c.relname, c.oid)
|
||||
n.nspname,
|
||||
c.relname,
|
||||
c.oid)
|
||||
union all
|
||||
(
|
||||
with policies as (
|
||||
@@ -219,18 +220,38 @@ where
|
||||
'_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'
|
||||
)
|
||||
and (
|
||||
-- Example: auth.uid()
|
||||
(
|
||||
-- Example: auth.uid()
|
||||
qual ~ '(auth)\\.(uid|jwt|role|email)\\(\\)'
|
||||
-- Example: select auth.uid()
|
||||
and lower(qual) !~ 'select\\s+(auth)\\.(uid|jwt|role|email)\\(\\)'
|
||||
qual like '%auth.uid()%'
|
||||
and lower(qual) not like '%select auth.uid()%'
|
||||
)
|
||||
or
|
||||
(
|
||||
-- Example: auth.uid()
|
||||
with_check ~ '(auth)\\.(uid|jwt|role|email)\\(\\)'
|
||||
-- Example: select auth.uid()
|
||||
and lower(with_check) !~ 'select\\s+(auth)\\.(uid|jwt|role|email)\\(\\)'
|
||||
or (
|
||||
qual like '%auth.jwt()%'
|
||||
and lower(qual) not like '%select auth.jwt()%'
|
||||
)
|
||||
or (
|
||||
qual like '%auth.role()%'
|
||||
and lower(qual) not like '%select auth.role()%'
|
||||
)
|
||||
or (
|
||||
qual like '%auth.email()%'
|
||||
and lower(qual) not like '%select auth.email()%'
|
||||
)
|
||||
or (
|
||||
with_check like '%auth.uid()%'
|
||||
and lower(with_check) not like '%select auth.uid()%'
|
||||
)
|
||||
or (
|
||||
with_check like '%auth.jwt()%'
|
||||
and lower(with_check) not like '%select auth.jwt()%'
|
||||
)
|
||||
or (
|
||||
with_check like '%auth.role()%'
|
||||
and lower(with_check) not like '%select auth.role()%'
|
||||
)
|
||||
or (
|
||||
with_check like '%auth.email()%'
|
||||
and lower(with_check) not like '%select auth.email()%'
|
||||
)
|
||||
))
|
||||
union all
|
||||
@@ -377,6 +398,7 @@ from
|
||||
) act(cmd)
|
||||
where
|
||||
c.relkind = 'r' -- regular tables
|
||||
and p.polpermissive -- policy is permissive
|
||||
and n.nspname not in (
|
||||
'_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'
|
||||
)
|
||||
@@ -395,7 +417,7 @@ union all
|
||||
(
|
||||
select
|
||||
'policy_exists_rls_disabled' as name,
|
||||
'INFO' as level,
|
||||
'ERROR' as level,
|
||||
'EXTERNAL' as facing,
|
||||
array['SECURITY'] as categories,
|
||||
'Detects cases where row level security (RLS) policies have been created, but RLS has not been enabled for the underlying table.' as description,
|
||||
@@ -539,7 +561,7 @@ union all
|
||||
(
|
||||
select
|
||||
'security_definer_view' as name,
|
||||
'WARN' as level,
|
||||
'ERROR' as level,
|
||||
'EXTERNAL' as facing,
|
||||
array['SECURITY'] as categories,
|
||||
'Detects views that are SECURITY DEFINER meaning that they ignore row level security (RLS) policies.' as description,
|
||||
@@ -568,17 +590,23 @@ from
|
||||
and dep.deptype = 'e'
|
||||
where
|
||||
c.relkind = 'v'
|
||||
and n.nspname = 'public'
|
||||
and (
|
||||
pg_catalog.has_table_privilege('anon', c.oid, 'SELECT')
|
||||
or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT')
|
||||
)
|
||||
and n.nspname not in (
|
||||
'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgsodium', 'pgsodium_masks', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'storage', 'supabase_functions', 'supabase_migrations', 'vault'
|
||||
)
|
||||
and dep.objid is null -- exclude views owned by extensions
|
||||
and not (
|
||||
lower(coalesce(c.reloptions::text,'{}'))::text[]
|
||||
&& array[
|
||||
'security_invoker=1',
|
||||
'security_invoker=true',
|
||||
'security_invoker=yes',
|
||||
'security_invoker=on'
|
||||
]
|
||||
))
|
||||
and not (
|
||||
lower(coalesce(c.reloptions::text,'{}'))::text[]
|
||||
&& array[
|
||||
'security_invoker=1',
|
||||
'security_invoker=true',
|
||||
'security_invoker=yes',
|
||||
'security_invoker=on'
|
||||
]
|
||||
))
|
||||
union all
|
||||
(
|
||||
select
|
||||
@@ -648,12 +676,18 @@ from
|
||||
on c.relnamespace = n.oid
|
||||
where
|
||||
c.relkind = 'r' -- regular tables
|
||||
and n.nspname = 'public'
|
||||
-- RLS is disabled
|
||||
and not c.relrowsecurity)
|
||||
and not c.relrowsecurity
|
||||
and (
|
||||
pg_catalog.has_table_privilege('anon', c.oid, 'SELECT')
|
||||
or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT')
|
||||
)
|
||||
and n.nspname not in (
|
||||
'_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'
|
||||
))
|
||||
union all
|
||||
(
|
||||
select
|
||||
select
|
||||
'extension_in_public' as name,
|
||||
'WARN' as level,
|
||||
'EXTERNAL' as facing,
|
||||
@@ -679,6 +713,10 @@ where
|
||||
-- plpgsql is installed by default in public and outside user control
|
||||
-- confirmed safe
|
||||
pe.extname not in ('plpgsql')
|
||||
-- Scoping this to public is not optimal. Ideally we would use the postgres
|
||||
-- search path. That currently isn't available via SQL. In other lints
|
||||
-- we have used has_schema_privilege('anon', 'extensions', 'USAGE') but that
|
||||
-- is not appropriate here as it would evaluate true for the extensions schema
|
||||
and pe.extnamespace::regnamespace::text = 'public')
|
||||
union all
|
||||
(
|
||||
@@ -726,13 +764,13 @@ where
|
||||
'_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'
|
||||
)
|
||||
and (
|
||||
-- Example: auth.jwt() -> 'user_metadata'
|
||||
-- False positives are possible, but it isn't practical to string match
|
||||
-- If false positive rate is too high, this expression can iterate
|
||||
qual like '%auth.jwt()%user_metadata%'
|
||||
or qual like '%current_setting(%request.jwt.claims%)%user_metadata%'
|
||||
or with_check like '%auth.jwt()%user_metadata%'
|
||||
or with_check like '%current_setting(%request.jwt.claims%)%user_metadata%'
|
||||
-- Example: auth.jwt() -> 'user_metadata'
|
||||
-- False positives are possible, but it isn't practical to string match
|
||||
-- If false positive rate is too high, this expression can iterate
|
||||
qual like '%auth.jwt()%user_metadata%'
|
||||
or qual like '%current_setting(%request.jwt.claims%)%user_metadata%'
|
||||
or with_check like '%auth.jwt()%user_metadata%'
|
||||
or with_check like '%current_setting(%request.jwt.claims%)%user_metadata%'
|
||||
))`.trim()
|
||||
|
||||
// Array of all lint rules we handle right now.
|
||||
|
||||
@@ -4,12 +4,11 @@ import { toast } from 'react-hot-toast'
|
||||
import { post } from 'data/fetchers'
|
||||
import type { ResponseError } from 'types'
|
||||
import { replicaKeys } from './keys'
|
||||
import type { Database } from './replicas-query'
|
||||
|
||||
export type ReadReplicaRemoveVariables = {
|
||||
projectRef: string
|
||||
identifier: string
|
||||
skipInvalidateOnSuccess?: boolean
|
||||
invalidateReplicaQueries: boolean
|
||||
}
|
||||
|
||||
export async function removeReadReplica({ projectRef, identifier }: ReadReplicaRemoveVariables) {
|
||||
@@ -40,18 +39,13 @@ export const useReadReplicaRemoveMutation = ({
|
||||
(vars) => removeReadReplica(vars),
|
||||
{
|
||||
async onSuccess(data, variables, context) {
|
||||
const { projectRef, identifier, skipInvalidateOnSuccess } = variables
|
||||
const { projectRef, invalidateReplicaQueries } = variables
|
||||
|
||||
if (skipInvalidateOnSuccess === false) {
|
||||
// [Joshen] Just FYI, will remove this once API changes to remove the need for optimistic rendering
|
||||
queryClient.setQueriesData<any>(replicaKeys.list(projectRef), (old: any) => {
|
||||
return old.filter((db: Database) => db.identifier !== identifier)
|
||||
})
|
||||
|
||||
setTimeout(async () => {
|
||||
await queryClient.invalidateQueries(replicaKeys.list(projectRef))
|
||||
await queryClient.invalidateQueries(replicaKeys.loadBalancers(projectRef))
|
||||
}, 5000)
|
||||
if (invalidateReplicaQueries) {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries(replicaKeys.list(projectRef)),
|
||||
queryClient.invalidateQueries(replicaKeys.loadBalancers(projectRef)),
|
||||
])
|
||||
}
|
||||
|
||||
await onSuccess?.(data, variables, context)
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
import { toast } from 'react-hot-toast'
|
||||
import { Button, toast as UiToast } from 'ui'
|
||||
|
||||
import { Query, SupaRow } from 'components/grid'
|
||||
import { Markdown } from 'components/interfaces/Markdown'
|
||||
import { executeSql } from 'data/sql/execute-sql-query'
|
||||
import { sqlKeys } from 'data/sql/keys'
|
||||
import type { Table } from 'data/tables/table-query'
|
||||
@@ -78,7 +81,52 @@ export const useTableRowDeleteMutation = ({
|
||||
},
|
||||
async onError(data, variables, context) {
|
||||
if (onError === undefined) {
|
||||
toast.error(`Failed to delete table row: ${data.message}`)
|
||||
const { table, rows } = variables
|
||||
const isFkError = data.message.includes('violates foreign key constraint')
|
||||
const isMultipleRows = rows.length > 1
|
||||
|
||||
if (isFkError) {
|
||||
const sourceTable = table.name
|
||||
const referencingTable = data.message.split('on table ')[2].replaceAll('"', '')
|
||||
const fkName = data.message
|
||||
.split('foreign key constraint')[1]
|
||||
.split('on table')[0]
|
||||
.replaceAll('"', '')
|
||||
const initialMessage = isMultipleRows
|
||||
? `Unable to delete rows as one of them is currently referenced by a foreign key constraint from the table \`${referencingTable}\`.`
|
||||
: `Unable to delete row as it is currently referenced by a foreign key constraint from the table \`${referencingTable}\`.`
|
||||
const resolutionCTA = `Set an on delete behavior on the foreign key relation \`${fkName}\` in the \`${referencingTable}\` table to automatically respond when row(s) are being deleted in the \`${sourceTable}\` table.`
|
||||
|
||||
UiToast({
|
||||
variant: 'default',
|
||||
style: { flexDirection: 'column' },
|
||||
title: (
|
||||
<Markdown content={initialMessage} className="text-foreground [&>p]:m-0" />
|
||||
) as any,
|
||||
description: <Markdown content={resolutionCTA} className="[&>p]:m-0" />,
|
||||
action: (
|
||||
<div className="w-full flex gap-x-2 !mx-0 mt-3">
|
||||
{/* [Joshen] Ideally we also are able to add this CTA but we can't guarantee this info without an on-demand fetch */}
|
||||
{/* <Button asChild key="cta-1" type="default">
|
||||
<Link href={`/project/${projectRef}/editor`}>
|
||||
View "{referencingTable}" table
|
||||
</Link>
|
||||
</Button> */}
|
||||
<Button asChild type="outline" icon={<ExternalLink />}>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href="https://supabase.com/docs/guides/database/postgres/cascade-deletes"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
})
|
||||
} else {
|
||||
toast.error(`Failed to delete table row: ${data.message}`)
|
||||
}
|
||||
} else {
|
||||
onError(data, variables, context)
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { PostgresTable } from '@supabase/postgres-meta'
|
||||
import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query'
|
||||
// import { get } from 'lib/common/fetch'
|
||||
import { API_URL } from 'lib/constants'
|
||||
import { tableKeys } from './keys'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import { get, handleError } from 'data/fetchers'
|
||||
import { tableKeys } from './keys'
|
||||
|
||||
export type TableVariables = {
|
||||
id?: number
|
||||
|
||||
@@ -29,6 +29,17 @@ function useTable(id?: number) {
|
||||
}
|
||||
)
|
||||
|
||||
const partitionTableResult = useTableQuery(
|
||||
{
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
id: entity?.id,
|
||||
},
|
||||
{
|
||||
enabled: entity?.type === ENTITY_TYPE.PARTITIONED_TABLE,
|
||||
}
|
||||
)
|
||||
|
||||
const viewResult = useViewQuery(
|
||||
{
|
||||
projectRef: project?.ref,
|
||||
@@ -65,6 +76,8 @@ function useTable(id?: number) {
|
||||
switch (entity?.type) {
|
||||
case ENTITY_TYPE.TABLE:
|
||||
return tableResult
|
||||
case ENTITY_TYPE.PARTITIONED_TABLE:
|
||||
return partitionTableResult
|
||||
case ENTITY_TYPE.VIEW:
|
||||
return viewResult
|
||||
case ENTITY_TYPE.MATERIALIZED_VIEW:
|
||||
|
||||
@@ -120,6 +120,7 @@ const handleGetAll = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
MAILER_SUBJECTS_RECOVERY: 'Reset Your Password',
|
||||
MAILER_SUBJECTS_EMAIL_CHANGE: 'Confirm Email Change',
|
||||
MAILER_SUBJECTS_MAGIC_LINK: 'Your Magic Link',
|
||||
MAILER_SUBJECTS_REAUTHENTICATION: 'Confirm Reauthentication',
|
||||
MAILER_TEMPLATES_INVITE: null,
|
||||
MAILER_TEMPLATES_INVITE_CONTENT:
|
||||
'<h2>You have been invited</h2>\n\n<p>You have been invited to create a user on {{ .SiteURL }}. Follow this link to accept the invite:</p>\n<p><a href="{{ .ConfirmationURL }}">Accept the invite</a></p>',
|
||||
@@ -166,6 +167,9 @@ const handleGetAll = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
SMS_VONAGE_FROM: null,
|
||||
SMS_TEXTLOCAL_API_KEY: null,
|
||||
SMS_TEXTLOCAL_SENDER: null,
|
||||
MAILER_TEMPLATES_REAUTHENTICATION: null,
|
||||
MAILER_TEMPLATES_REAUTHENTICATION_CONTENT:
|
||||
'<h2>Confirm reauthentication</h2> <p>Enter the code: {{ .Token }}</p>',
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,6 @@ import { passwordStrength } from 'lib/helpers'
|
||||
import { getInitialMigrationSQLFromGitHubRepo } from 'lib/integration-utils'
|
||||
import { useIntegrationInstallationSnapshot } from 'state/integration-installation'
|
||||
import type { NextPageWithLayout } from 'types'
|
||||
import * as Sentry from '@sentry/nextjs'
|
||||
|
||||
const VercelIntegration: NextPageWithLayout = () => {
|
||||
return (
|
||||
@@ -204,11 +203,12 @@ const CreateProject = () => {
|
||||
const projectDetails = vercelProjects?.find((x: any) => x.id === foreignProjectId)
|
||||
|
||||
try {
|
||||
const connection = {
|
||||
const { id: connectionId } = await createConnections({
|
||||
organizationIntegrationId: organizationIntegration?.id,
|
||||
connection: {
|
||||
foreign_project_id: foreignProjectId,
|
||||
supabase_project_ref: newProjectRef,
|
||||
integration_id: '0',
|
||||
metadata: {
|
||||
...projectDetails,
|
||||
supabaseConfig: {
|
||||
@@ -219,26 +219,8 @@ const CreateProject = () => {
|
||||
},
|
||||
},
|
||||
orgSlug: selectedOrganization?.slug,
|
||||
}
|
||||
})
|
||||
|
||||
// TODO(kamil): Remove once "missing connection name" bug has been squashed.
|
||||
if (!projectDetails?.name) {
|
||||
Sentry.captureMessage('Vercel connection is missing name', {
|
||||
contexts: {
|
||||
connection: connection,
|
||||
data: {
|
||||
projectDetails,
|
||||
vercelProjects,
|
||||
foreignProjectId,
|
||||
},
|
||||
source: {
|
||||
file: 'deploy-button/new-project.tsx',
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const { id: connectionId } = await createConnections(connection)
|
||||
await syncEnvs({ connectionId })
|
||||
} catch (error) {
|
||||
console.error('An error occurred during createConnections:', error)
|
||||
|
||||
@@ -19,7 +19,6 @@ import { BASE_PATH, PROJECT_STATUS } from 'lib/constants'
|
||||
import { EMPTY_ARR } from 'lib/void'
|
||||
import { useIntegrationInstallationSnapshot } from 'state/integration-installation'
|
||||
import type { NextPageWithLayout, Organization } from 'types'
|
||||
import * as Sentry from '@sentry/nextjs'
|
||||
|
||||
const VERCEL_ICON = (
|
||||
<img src={`${BASE_PATH}/img/icons/vercel-icon.svg`} alt="Vercel Icon" className="w-4" />
|
||||
@@ -129,18 +128,6 @@ const VercelIntegration: NextPageWithLayout = () => {
|
||||
|
||||
const onCreateConnections = useCallback(
|
||||
(vars: any) => {
|
||||
// TODO(kamil): Remove once "missing connection name" bug has been squashed.
|
||||
if (!vars.connection?.metadata?.name) {
|
||||
Sentry.captureMessage('Vercel connection is missing name', {
|
||||
contexts: {
|
||||
connection: vars.connection,
|
||||
source: {
|
||||
file: 'marketplace/choose-project.tsx',
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
createConnections({
|
||||
...vars,
|
||||
connection: {
|
||||
|
||||
@@ -24,8 +24,14 @@ const JoinOrganizationPage = () => {
|
||||
const [error, setError] = useState<any>()
|
||||
const [tokenValidationInfo, setTokenValidationInfo] = useState<TokenInfo>(undefined)
|
||||
const [tokenInfoLoaded, setTokenInfoLoaded] = useState(false)
|
||||
const { token_does_not_exist, email_match, expired_token, organization_name, invite_id } =
|
||||
tokenValidationInfo || {}
|
||||
const {
|
||||
token_does_not_exist,
|
||||
sso_mismatch,
|
||||
email_match,
|
||||
expired_token,
|
||||
organization_name,
|
||||
invite_id,
|
||||
} = tokenValidationInfo || {}
|
||||
|
||||
const loginRedirectLink = `/?returnTo=${encodeURIComponent(`/join?token=${token}&slug=${slug}`)}`
|
||||
|
||||
@@ -93,6 +99,7 @@ const JoinOrganizationPage = () => {
|
||||
|
||||
const isError =
|
||||
error ||
|
||||
!!(tokenInfoLoaded && sso_mismatch) ||
|
||||
!!(tokenInfoLoaded && token_does_not_exist) ||
|
||||
(tokenInfoLoaded && !email_match) ||
|
||||
(tokenInfoLoaded && expired_token)
|
||||
@@ -111,6 +118,14 @@ const JoinOrganizationPage = () => {
|
||||
|
||||
const message = error ? (
|
||||
<p>There was an error requesting details for this invitation. ({error.message})</p>
|
||||
) : sso_mismatch ? (
|
||||
<>
|
||||
<p>SSO providers do not match.</p>
|
||||
<p className="text-foreground-lighter">
|
||||
This invitation comes from a different identity provider than the one you are currently
|
||||
logged in with. Please use the same SSO provider instead.
|
||||
</p>
|
||||
</>
|
||||
) : token_does_not_exist ? (
|
||||
<>
|
||||
<p>The invite token is invalid.</p>
|
||||
|
||||
@@ -4,7 +4,7 @@ description: Build an Open Source Project over 10 days. 5 prize categories.
|
||||
author: ant_wilson
|
||||
image: oss-hackathon/thumbnail.png
|
||||
thumb: oss-hackathon/thumbnail.png
|
||||
launchweek: 8
|
||||
launchweek: 11
|
||||
categories:
|
||||
- launch-week
|
||||
tags:
|
||||
|
||||
@@ -5,7 +5,7 @@ author: samrose
|
||||
image: nix-postgres.png
|
||||
thumb: nix-postgres.png
|
||||
categories:
|
||||
- product
|
||||
- engineering
|
||||
tags:
|
||||
- supabase-engineering
|
||||
date: '2024-04-25'
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
---
|
||||
title: 'Postgres Bloat Minimization'
|
||||
description: 'Understanding and minimizing Postgres table bloat'
|
||||
author: pavel
|
||||
image: postgres-bloat.png
|
||||
thumb: postgres-bloat.png
|
||||
categories:
|
||||
- engineering
|
||||
tags:
|
||||
- supabase-engineering
|
||||
- planetpg
|
||||
date: '2024-04-26'
|
||||
toc_depth: 3
|
||||
---
|
||||
|
||||
### How data of Postgres tables stored
|
||||
|
||||
By default, all table data in Postgres are physically stored using the “heap” method. So every database is a set of 1Gb files (”segments”) and each file is logically split into 8Kb pages. Actual table rows are put into any page with enough free space.
|
||||
|
||||
When the row data is updated, a new version of a whole row is constructed and written (to any free space). The old one remains because, at the time of the update, the transaction is not completed and can be rolled back in the future. When the transaction is completed we’ll have two or several versions of the same row in the table. Cleaning old ones is by an asynchronous process called vacuum (and autovacuum).
|
||||
|
||||
### How does the vacuum work?
|
||||
|
||||
Vacuum goes through all table files looking for row versions that were updated or deleted by already completed transactions and frees the space on the pages.
|
||||
|
||||
Then it updates the table’s free-space-map to reflect that some page has a certain amount of free space for row inserts.
|
||||
|
||||
It also updates the visibility map for a page. It marks that all remaining rows are visible. So index scans can skip visibility checks, which is not so for the modified page before vacuuming. This significantly increases the speed of queries using indexes.
|
||||
|
||||
In many cases vacuum runs automatically, cleans everything, and requires little care. But in some scenarios, we need to go deeper and tune the autovacuum parameters or run the vacuum manually.
|
||||
|
||||
### Cleaning relation files
|
||||
|
||||
Vacuum marks space in a relation file as free to use for future row inserts or updates. And it’s not a problem unless we insert many rows, delete many rows at once, and then don’t make any inserts or updates. Space remains reserved in a file but we don’t use it.
|
||||
|
||||
In this case, we could free actual filesystem space by running more aggressive mode:
|
||||
|
||||
```sql
|
||||
VACUUM FULL mytable;
|
||||
```
|
||||
|
||||
It will rebuild the table from live rows and they will be placed compactly so that filesystem space will be freed. The downside is that it needs an exclusive lock and you won’t be able to modify the table while `VACUUM FULL` does it's work. It’s wise to execute that process when the database is least accessed e.g. at night.
|
||||
|
||||
An alternative way that doesn’t need full locks is using pg_repack extension:
|
||||
|
||||
```sh
|
||||
pg_repack --table mytable test
|
||||
```
|
||||
|
||||
pg_repack operates similarly to VACUUM FULL for database `test` and table `mytable`.
|
||||
|
||||
### Table bloating and autovaccuum
|
||||
|
||||
To see database bloat via the [Supabase CLI](https://supabase.com/docs/guides/cli/getting-started) run:
|
||||
|
||||
```sh
|
||||
$ supabase inspect db bloat
|
||||
```
|
||||
|
||||
or:
|
||||
|
||||
```sql
|
||||
-- number of dead rows
|
||||
SELECT
|
||||
n_dead_tup
|
||||
FROM
|
||||
pg_stat_user_tables
|
||||
WHERE
|
||||
relname = ‘mytable’;
|
||||
|
||||
-- number of live rows
|
||||
SELECT
|
||||
count(*)
|
||||
FROM
|
||||
mytable;
|
||||
```
|
||||
|
||||
If the numbers differ by more than 2x, chances are that the autovacuum didn’t start or hasn’t completed for a table. There could be several legitimate reasons for this.
|
||||
|
||||
You can see information for the last successful autovacuum by running:
|
||||
|
||||
```sh
|
||||
$ supabase inspect db vacuum-stats
|
||||
```
|
||||
|
||||
or:
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
last_vacuum,
|
||||
last_autovacuum
|
||||
FROM
|
||||
pg_stat_user_tables
|
||||
WHERE
|
||||
relname = ‘mytable’;
|
||||
```
|
||||
|
||||
Let’s turn on autovacuum logging so that all autovacuum events land in the log:
|
||||
|
||||
```sql
|
||||
ALTER TABLE mytable SET log_autovacuum_min_duration to 0;
|
||||
```
|
||||
|
||||
There are two ways we can encounter this situation:
|
||||
|
||||
- autovacuum hasn’t started
|
||||
- autovacuum did start, (possibly multiple times) but never succeeded
|
||||
|
||||
### Autovacuum hasn’t started for a table
|
||||
|
||||
Autovacuum starts based on several configuration parameters like timeout and pattern of access to a particular table. Maybe it’s even legitimate that it hasn’t started.
|
||||
|
||||
- `autovacuum_vacuum_threshold` - number of rows updated or deleted in a table to invoke autovacuum
|
||||
- `autovacuum_vacuum_insert_threshold` - number of rows inserted into a table to invoke autovacuum
|
||||
- `autovacuum_vacuum_scale_factor` - a fraction of table modified by updates or deletes to invoke autovacuum
|
||||
- `autovacuum_vacuum_insert_scale_factor` - a fraction of table modified by inserts to invoke autovacuum
|
||||
|
||||
With all these parameters set, the autovacuum will start if the number of rows updated or deleted exceeds:
|
||||
|
||||
```
|
||||
autovacuum_vacuum_scale_factor * size_of_table + autovacuum_vacuum_threshold
|
||||
```
|
||||
|
||||
The same logic applies for inserts. Default scale factors are 20% of a table, which could be too high for big tables. If we want autovacuum to occur on large tables more frequently and take less time each run, decrease the default values for these tables e.g.:
|
||||
|
||||
```jsx
|
||||
ALTER TABLE mytable SET autovacuum_vacuum_scale_factor to 0.05;
|
||||
```
|
||||
|
||||
- `autovacuum_naptime` (default 1 min) - Each 1-minute autovacuum daemon will see the state of all tables in the database and decide whether to start autovacuum for a table. Most often this parameter does not need to be modified.
|
||||
|
||||
To see global vacuum settings for your cluster let’s run:
|
||||
|
||||
```jsx
|
||||
SELECT * from pg_settings where category like 'Autovacuum';
|
||||
```
|
||||
|
||||
To see current settings for a table (that overrides global settings) run:
|
||||
|
||||
```jsx
|
||||
SELECT relname, reloptions FROM pg_class WHERE relname='mytable';
|
||||
```
|
||||
|
||||
### Autovacuum started but couldn’t succeed
|
||||
|
||||
The most common reason autovacuum doesn’t succeed is long-running open transactions that access old row versions. In that case, Postgres recognizes that the row versions are still needed so any row versions created after that point can’t be marked as dead. One common cause for this problem is interactive sessions that were left open on accident. When tuples can’t be marked as dead, the database begins to bloat.
|
||||
|
||||
To see all open transactions run:
|
||||
|
||||
```jsx
|
||||
SELECT xact_start, state FROM pg_stat_activity;
|
||||
```
|
||||
|
||||
To close transactions found to be idling:
|
||||
|
||||
```jsx
|
||||
SELECT pg_cancel_backend(pid) FROM pg_stat_activity WHERE xact_start = '<value from previous query>';
|
||||
```
|
||||
|
||||
For automatically closing idle transactions in a session:
|
||||
|
||||
```jsx
|
||||
SET idle_in_transaction_session_timeout TO '10000s';
|
||||
```
|
||||
|
||||
The same parameter could be set per role or database as needed.
|
||||
|
||||
Another, less likely, possibility is that autovacuum can’t succeed due to locks. If some of your processes take the `SHARE UPDATE EXCLUSIVE` lock e.g. `ALTER TABLE` clause, this lock will prevent vacuum from processing a table. Lock conflicts in your ordinary transactions could cause `SHARE UPDATE EXCLUSIVE` to be taken for a long time. A good recipe when this happens is to cancel all the open transactions and run VACUUM for a table manually (or wait until the next autovacuum comes for it).
|
||||
|
||||
```jsx
|
||||
SELECT pg_cancel_backend(pid) FROM pg_stat_activity WHERE state = 'active';
|
||||
VACUUM mytable;
|
||||
```
|
||||
|
||||
### Other vacuum optimizations
|
||||
|
||||
There could be too few autovacuum workers or each worker could operate slowly due to the low setting of `autovacuum_work_mem`.
|
||||
|
||||
- `autovacuum_max_workers` (default 3) - Number of parallel workers doing autovacuum for tables. When you have enough cores, increasing the default value is worthwhile. Note that this will decrease the number of possible backends or regular parallel workers running at a time.
|
||||
- `autovacuum_work_mem` (default equal to `maintenance_work_mem` or 64Mb) - work memory that is used per autovacuum worker. If you see in your logs that autovacuum for some tables starts but takes a long time, that time may be decreased by increasing this value. This parameter can only be modified in the config file.
|
||||
|
||||
### Conclusion
|
||||
|
||||
Vacuum and autovacuum are efficient ways to maintain the tables without bloat. They have several parameters that allow efficient tuning. Some insight into what database does can help prevent the cases where autovacuum becomes problematic and bloat increases i.e.:
|
||||
|
||||
- Long open transactions
|
||||
- Stuck locks
|
||||
- Insufficient resources allocated to vacuuming
|
||||
- Space not freed on a filesystem level after massive table modifications
|
||||
|
||||
For more information about bloat and vacuuming, here are some direct references to resources:
|
||||
|
||||
[Full official documentation for vacuum](https://www.postgresql.org/docs/current/routine-vacuuming.html#VACUUM-BASICS)
|
||||
|
||||
[Per-table vacuum parameters](https://www.postgresql.org/docs/current/sql-createtable.html#SQL-CREATETABLE-STORAGE-PARAMETERS)
|
||||
|
||||
[Global autovacuum parameters (most of them need modifying postgresql.conf, but could be overridden by per-table parameters)](https://www.postgresql.org/docs/current/runtime-config-autovacuum.html)
|
||||
|
||||
[Supabase CLI reference](https://supabase.com/docs/reference/cli/global-flags)
|
||||
@@ -0,0 +1,125 @@
|
||||
---
|
||||
title: 'Open Source Hackathon 2024 winners'
|
||||
description: Announcing the winners of the Open Source Hackathon 2024!
|
||||
author: tyler_shukert
|
||||
image: oss-hackathon-winners/thumbnail.png
|
||||
thumb: oss-hackathon-winners/thumbnail.png
|
||||
launchweek: 11
|
||||
categories:
|
||||
- launch-week
|
||||
tags:
|
||||
- launch-week
|
||||
- hackathon
|
||||
date: '2024-04-30'
|
||||
toc_depth: 2
|
||||
---
|
||||
|
||||
The [Supabase GA week](https://supabase.com/ga-week) brought us so many cool updates, but the fun isn’t over yet, because we are now announcing the winners of the [Supabase Open Source Hackathon 2024](https://supabase.com/blog/supabase-oss-hackathon)!
|
||||
|
||||
We have enjoyed trying out every single submission we have received! You can find all of the submissions on [madewithsupabase.com](https://www.madewithsupabase.com/).
|
||||
|
||||
Now, without further ado, let us announce the winners of the hackathon!
|
||||
|
||||
## Best overall project
|
||||
|
||||
### Winner
|
||||
|
||||
[vdbs - vision database sql](https://github.com/xavimondev/vdbs) - by [@xavimonp](https://twitter.com/xavimonp)
|
||||
|
||||
vdbs stands for "vision database SQL". It allows you to convert your database diagrams into SQL Schema using the capabilities of Vision API. Once the SQL is ready, you can either copy and paste it right in your Supabase project or run the generated npm command to create the migration file.
|
||||
|
||||

|
||||
|
||||
### Runner Up
|
||||
|
||||
[Complete Components](https://github.com/gSUz92nc/Complete-Components/) - by [@gSUz92nc](https://github.com/gSUz92nc)
|
||||
|
||||
Complete Components is an open-source project that uses AI to assist developers in quickly creating and integrating HTML components with Supabase as the backend and Tailwind CSS for styling. It aims to streamline the development process by leveraging AI to generate tailored HTML components and seamlessly integrate them with Supabase.
|
||||
|
||||

|
||||
|
||||
## Best use of AI
|
||||
|
||||
### Winner
|
||||
|
||||
[ClosetAi | Your virtual closet, endless possibilities.](https://github.com/ineffablesam/closet-ai#closet-ai--revamp-your-style-effortlessly) - by [@samuelP09301972](https://twitter.com/samuelP09301972), [@teamnextvitap](https://twitter.com/teamnextvitap), and two others
|
||||
|
||||
Closet AI: the ultimate style companion. Upload images, choose your desired topwear or bottomwear, and watch as our cutting-edge AI replaces your outfit instantly! Revolutionize your wardrobe with the power of AI and Supabase!
|
||||
|
||||

|
||||
|
||||
### Runner Up
|
||||
|
||||
[Plot Twist](https://github.com/NeoFoxxo/plottwist) - by [@NeoFoxMusic](https://twitter.com/NeoFoxMusic), [@bonndubz](https://twitter.com/bonndubz), [@Nidhish18224246](https://twitter.com/Nidhish18224246) and [@SlaviKaraslavov](https://twitter.com/SlaviKaraslavov)
|
||||
|
||||
Generate interactive stories where your choices shape the narrative. Start with a prompt, and let our custom AI generate the story and choices you can make. Want to see what you would do if you were stranded on an island? Or if you were a cat who is a pro skateboarder? Provide the idea and you can experience these scenarios and affect the story!
|
||||
|
||||

|
||||
|
||||
## Most fun / best easter egg
|
||||
|
||||
### Winner
|
||||
|
||||
[NamePLAT](https://github.com/Git-Prakhar/npat-game) - by [@Abhishek_cpp](https://twitter.com/Abhishek_cpp) and [@BlackStarGames\_](https://twitter.com/BlackStarGames_)
|
||||
|
||||
Name Place Animal Thing (NamePLAT) is a fun and challenging game where players compete to quickly identify a name (country) a place, an animal, and a thing that starts with a given letter, but the twist is you get to choose between 4 Images and the fastest finger gets most points. The game is designed to test players' knowledge, speed, and creativity.
|
||||
|
||||

|
||||
|
||||
### Runner Up
|
||||
|
||||
[Supapaused](https://github.com/jensen/supapaused) - by [@karljensen](https://twitter.com/karljensen)
|
||||
|
||||
Supapaused is an app that allows Supabase users to track their paused Supabase instances. You can view when the projects were paused in a timeline.
|
||||
|
||||

|
||||
|
||||
## Most technically impressive
|
||||
|
||||
### Winner
|
||||
|
||||
[Retorithoughts](https://github.com/Jcampillo1207/Retoritoughts) - by [@Chema12071](https://twitter.com/Chema12071), [@alexf505](https://twitter.com/alexf505) and [@Patuqueson](https://twitter.com/Patuqueson)
|
||||
|
||||
Retorithoughts is a trivia game that challenges players to determine the chronological order of historical events. It's fun and an educational tool that tests and expands your knowledge of history.
|
||||
|
||||

|
||||
|
||||
### Runner Up
|
||||
|
||||
[Data Loom](https://github.com/sangonz193/data-loom) - by [@sangonz193](https://github.com/sangonz193)
|
||||
|
||||
Data Loom aims to provide a hassle-free and secure way to share files between devices. The platform leverages WebRTC technology, ensuring that your files are transferred directly and securely, with no intermediary server access.
|
||||
|
||||

|
||||
|
||||
## Most visually pleasing
|
||||
|
||||
### Winner
|
||||
|
||||
[SupaWriter](https://github.com/phibersoft/supawriter.git) - by [@phibersoft](https://github.com/phibersoft)
|
||||
|
||||
SupaWriter is a ten-finger typing game that helps you improve your typing speed and accuracy. It also features a leaderboard to see how you are performing compared to others.
|
||||
|
||||

|
||||
|
||||
### Runner Up
|
||||
|
||||
[Echoes of Creation](https://github.com/laznic/echoes-of-creation) - by [@laznic](https://twitter.com/laznic) and [@janireijonen](https://twitter.com/janireijonen)
|
||||
|
||||
Echoes of Creation is a digital experience that portrays the excitement and struggles artists have with their creative processes.
|
||||
|
||||

|
||||
|
||||
## The Prizes
|
||||
|
||||

|
||||
|
||||
The winner of the best overall project will receive an Apple AirPods, and the winners and the runner-ups in other categories will each receive a Supabase swag kit.
|
||||
|
||||
## Getting Started Guides
|
||||
|
||||
If you're inspired to build, check out some of the latest resources:
|
||||
|
||||
- [Quick Start Guides](https://supabase.com/docs/guides/getting-started)
|
||||
- [AI & Vectors Guide](https://supabase.com/docs/guides/ai)
|
||||
- [Edge Functions Guide](https://supabase.com/docs/guides/functions)
|
||||
|
After Width: | Height: | Size: 316 KiB |
|
After Width: | Height: | Size: 304 KiB |
|
After Width: | Height: | Size: 175 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 155 KiB |
|
After Width: | Height: | Size: 562 KiB |
|
After Width: | Height: | Size: 486 KiB |
|
After Width: | Height: | Size: 260 KiB |
|
After Width: | Height: | Size: 273 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 287 KiB |
|
After Width: | Height: | Size: 32 KiB |
@@ -3,7 +3,8 @@
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"~/*": ["./*"],
|
||||
"@ui/*": ["./../../packages/ui/src/*"] // handle ui package paths
|
||||
"@ui/*": ["./../../packages/ui/src/*"],
|
||||
"@ui-patterns/*": ["./../../packages/ui-patterns/*"]
|
||||
},
|
||||
"downlevelIteration": true,
|
||||
"target": "ES2021",
|
||||
|
||||
@@ -76,7 +76,7 @@ services:
|
||||
|
||||
auth:
|
||||
container_name: supabase-auth
|
||||
image: supabase/gotrue:v2.145.0
|
||||
image: supabase/gotrue:v2.149.0
|
||||
depends_on:
|
||||
db:
|
||||
# Disable this if you are using an external Postgres database
|
||||
@@ -354,7 +354,7 @@ services:
|
||||
# Comment out everything below this point if you are using an external Postgres database
|
||||
db:
|
||||
container_name: supabase-db
|
||||
image: supabase/postgres:15.1.0.147
|
||||
image: supabase/postgres:15.1.1.41
|
||||
healthcheck:
|
||||
test: pg_isready -U postgres -h localhost
|
||||
interval: 5s
|
||||
|
||||
@@ -28,10 +28,14 @@ Deno.serve(async (req: Request) => {
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// First get the token from the Authorization header
|
||||
const token = req.headers.get('Authorization').replace('Bearer ', '')
|
||||
|
||||
// Now we can get the session or user object
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabaseClient.auth.getUser()
|
||||
} = await supabaseClient.auth.getUser(token)
|
||||
|
||||
// And we can run queries in the context of our authenticated user
|
||||
const { data, error } = await supabaseClient.from('users').select('*')
|
||||
|
||||
@@ -20,10 +20,14 @@ Deno.serve(async (req) => {
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// First get the token from the Authorization header
|
||||
const token = req.headers.get('Authorization').replace('Bearer ', '')
|
||||
|
||||
// Now we can get the session or user object
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabaseClient.auth.getUser()
|
||||
} = await supabaseClient.auth.getUser(token)
|
||||
if (!user) throw new Error('no user')
|
||||
console.log(user.id)
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ const schemas = [
|
||||
{ path: 'id', type: 'string' },
|
||||
{ path: 'timestamp', type: 'datetime' },
|
||||
{ path: 'event_message', type: 'string' },
|
||||
{ path: 'identifier', type: 'string' },
|
||||
{ path: 'metadata.request.cf.asOrganization', type: 'string' },
|
||||
{ path: 'metadata.request.cf.asn', type: 'number' },
|
||||
{ path: 'metadata.request.cf.botManagement.corporateProxy', type: 'boolean' },
|
||||
@@ -181,6 +182,7 @@ const schemas = [
|
||||
{ path: 'event_message', type: 'string' },
|
||||
{ path: 'id', type: 'string' },
|
||||
{ path: 'timestamp', type: 'datetime' },
|
||||
{ path: 'identifier', type: 'string' },
|
||||
{ path: 'metadata.host', type: 'string' },
|
||||
{ path: 'metadata.parsed.backend_type', type: 'string' },
|
||||
{ path: 'metadata.parsed.command_tag', type: 'string' },
|
||||
|
||||
@@ -109,6 +109,16 @@ export const pricing: Pricing = {
|
||||
},
|
||||
usage_based: false,
|
||||
},
|
||||
{
|
||||
title: 'Branching',
|
||||
plans: {
|
||||
free: false,
|
||||
pro: '$0.32 per branch, per day',
|
||||
team: '$0.32 per branch, per day',
|
||||
enterprise: 'Custom',
|
||||
},
|
||||
usage_based: true,
|
||||
},
|
||||
{
|
||||
title: 'Bandwidth',
|
||||
tooltips: {
|
||||
|
||||
@@ -42,7 +42,7 @@ const PortalToast = () => (
|
||||
? 'max-w-[380px]'
|
||||
: isConsentToast
|
||||
? 'max-w-none sm:max-w-[800px]'
|
||||
: 'max-w-[260px]'
|
||||
: 'max-w-[280px]'
|
||||
}`}
|
||||
>
|
||||
{message}
|
||||
|
||||