Merge branch 'master' into poc/studio-v2

This commit is contained in:
Francesco Sansalvadore authored and GitHub committed 2026-03-23 18:30:42 +01:00
commit 366eb751f1
278 files changed
+64400 -41823

No files matched your search

+16 -4
View File
@@ -117,10 +117,6 @@ await waitForApiResponse(page, 'pg-meta', ref, 'tables')
// ✅ Acceptable - waiting for client-side debounce
await page.getByRole('textbox').fill('search term')
await page.waitForTimeout(300) // Allow debounce to complete
// ✅ Acceptable - waiting for clipboard API
await page.evaluate(() => navigator.clipboard.readText())
await page.waitForTimeout(500)
```
## Test Structure
@@ -274,6 +270,22 @@ import {
} from '../utils/wait-for-response.js'
```
### Use the existing assertions utilities
#### Clipboard assertions
```ts
// ❌ Avoid - brittle hard coded timeout
await page.evaluate(() => navigator.clipboard.readText())
await page.waitForTimeout(500)
// ✅ Good - this utility function uses Playwright auto-retries mechanisms
await expectClipboardValue({
page,
value: 'expectedValue'
})
```
## API Mocking
### Mock APIs for isolated testing
@@ -14,7 +14,10 @@ export async function GET(_request: Request, { params }: { params: Promise<{ slu
try {
const content = await fs.readFile(filePath, 'utf-8')
return new NextResponse(content, {
headers: { 'Content-Type': 'text/markdown; charset=utf-8' },
headers: {
'Content-Type': 'text/markdown; charset=utf-8',
'Cache-Control': 'public, max-age=86400, stale-while-revalidate=3600',
},
})
} catch {
return new NextResponse('Not found', { status: 404 })
@@ -146,18 +146,27 @@ const urlTransform: (lints: Array<{ path: string }>) => UrlTransformFunction = (
const getLints = async () => {
const octokit = new Octokit({ request: { fetch: fetchRevalidatePerDay } })
const response = await octokit.request('GET /repos/{owner}/{repo}/contents/{path}', {
owner: org,
repo: repo,
path: docsDir,
ref: branch,
headers: {
'X-GitHub-Api-Version': '2022-11-28',
},
})
let response: Awaited<ReturnType<typeof octokit.request>>
try {
response = await octokit.request('GET /repos/{owner}/{repo}/contents/{path}', {
owner: org,
repo: repo,
path: docsDir,
ref: branch,
headers: {
'X-GitHub-Api-Version': '2022-11-28',
},
})
} catch (err) {
throw new Error(
`Failed to fetch database advisors lint list from GitHub (network error): ${err}`
)
}
if (response.status >= 400) {
throw Error(`Could not get contents of repo ${org}/${repo}`)
throw new Error(
`Failed to fetch ${org}/${repo}/contents/${docsDir} docs from GitHub: ${response.status}`
)
}
if (!Array.isArray(response.data)) {
@@ -170,12 +179,21 @@ const getLints = async () => {
const lints = await Promise.all(
lintsList.map(async ({ path }) => {
const fileResponse = await fetchRevalidatePerDay(
`https://raw.githubusercontent.com/${org}/${repo}/${branch}/${path}`
)
let fileResponse: Response
try {
fileResponse = await fetchRevalidatePerDay(
`https://raw.githubusercontent.com/${org}/${repo}/${branch}/${path}`
)
} catch (err) {
throw new Error(
`Failed to fetch database advisors lint file ${path} from GitHub (network error): ${err}`
)
}
if (fileResponse.status >= 400) {
throw Error(`Could not get contents of file ${org}/${repo}/${path}`)
if (response.status >= 400) {
throw new Error(
`Failed to fetch ${org}/${repo}/${branch}/${path} docs from GitHub: ${response.status}`
)
}
const content = await fileResponse.text()
@@ -360,10 +360,22 @@ const getContent = async (params: Params) => {
const repoPath = `${org}/${repo}/${tag}/${docsDir}/${remoteFile}`
editLink = `${org}/${repo}/blob/${tag}/${docsDir}/${remoteFile}`
const response = await fetch(`https://raw.githubusercontent.com/${repoPath}`, {
cache: 'force-cache',
next: { tags: [REVALIDATION_TAGS.WRAPPERS] },
})
let response: Response
try {
response = await fetch(`https://raw.githubusercontent.com/${repoPath}`, {
cache: 'force-cache',
next: { tags: [REVALIDATION_TAGS.WRAPPERS] },
})
} catch (err) {
throw new Error(`Failed to fetch wrappers docs from GitHub (network error): ${err}`)
}
if (!response.ok) {
throw new Error(
`Failed to fetch wrappers docs from GitHub: ${response.status} ${response.statusText}`
)
}
const rawContent = await response.text()
assetsBaseUrl = `https://raw.githubusercontent.com/${org}/${repo}/${tag}/docs/assets/`
@@ -64,7 +64,7 @@ const pageMap = [
title: 'Computed Fields',
subtitle: 'Using Postgres Computed Fields with GraphQL.',
},
remoteFile: 'computed-fields.md',
remoteFile: 'computed_fields.md',
},
{
slug: 'configuration',
+9 -1
View File
@@ -30,7 +30,15 @@ function AiTools({ className }: { className?: string }) {
try {
const res = await fetch(mdUrl)
const text = await res.text()
let text: string
if (res.ok) {
text = await res.text()
} else {
// Default to HTML content within the article when no .md file is available.
text = document.getElementById('sb-docs-guide-main-article')?.innerHTML ?? ''
}
await navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
@@ -773,6 +773,12 @@ export const auth: NavMenuConstant = {
],
},
{
name: 'Custom OAuth/OIDC Providers',
url: '/guides/auth/custom-oauth-providers',
enabled: allAuthProvidersEnabled,
},
{
name: 'Anonymous Sign-Ins',
url: '/guides/auth/auth-anonymous',
@@ -1547,6 +1553,11 @@ export const graphql: NavMenuConstant = {
{ name: 'API', url: '/guides/graphql/api', items: [] },
{ name: 'Views', url: '/guides/graphql/views', items: [] },
{ name: 'Functions', url: '/guides/graphql/functions', items: [] },
{
name: 'Computed Fields',
url: '/guides/graphql/computed-fields',
items: [],
},
{
name: 'Configuration & Customization',
url: '/guides/graphql/configuration',
@@ -6,6 +6,12 @@ Supabase Auth works with many popular Auth methods, including Social and Phone A
<AuthProviders type="social" />
<Admonition type="tip">
You can also add any OAuth2 or OIDC-compatible identity provider using [Custom OAuth/OIDC Providers](/docs/guides/auth/custom-oauth-providers).
</Admonition>
### Phone Auth
<AuthProviders type="phone" />
@@ -372,7 +372,7 @@ Email sending depends on two settings: Email Provider and Auth Hook status.
When `email_action_type` is `email_change`, the hook payload can include one or two OTPs and their hashes. This depends on your [Secure Email Change](/dashboard/project/_/auth/providers?provider=Email) setting.
- Secure Email Change enabled: two OTPs are generated, one for the current email (`user.email`) and one for the new email (`user.email_new`). You must send two emails.
- Secure Email Change enabled: two OTPs are generated, one for the current email (`user.email`) and one for the new email (`user.new_email`). You must send two emails.
- Secure Email Change disabled: only one OTP is generated for the new email. You send a single email.
<Admonition type="caution" title="Counterintuitive field naming">
@@ -380,7 +380,7 @@ When `email_action_type` is `email_change`, the hook payload can include one or
The token hash field names are reversed due to backward compatibility. Pay careful attention to which token/hash pair goes with which email address:
- `token_hash_new` → use with the **current** email address (`user.email`) and `token`
- `token_hash` → use with the **new** email address (`user.email_new`) and `token_new`
- `token_hash` → use with the **new** email address (`user.new_email`) and `token_new`
Do not assume the `_new` suffix refers to the new email address.
@@ -391,7 +391,7 @@ Do not assume the `_new` suffix refers to the new email address.
When Secure Email Change is enabled (both token/hash pairs present):
- Send to **current** email address (`user.email`): use `token` with `token_hash_new`
- Send to **new** email address (`user.email_new`): use `token_new` with `token_hash`
- Send to **new** email address (`user.new_email`): use `token_new` with `token_hash`
When Secure Email Change is **disabled** (only one token/hash pair present):
@@ -0,0 +1,320 @@
---
id: 'custom-oauth-providers'
title: 'Custom OAuth/OIDC Providers'
description: 'Add any OAuth2 or OIDC-compatible identity provider to your Supabase project'
---
Custom OAuth/OIDC providers let you integrate any standards-compliant identity provider with Supabase Auth, beyond the ones Supabase supports out of the box.
Each custom provider uses a `custom:` prefix in its identifier (for example, `custom:my-idp` or `custom:github-enterprise`). This prefix distinguishes custom providers from built-in providers.
There are two provider types:
- **OAuth2**: for generic OAuth2 providers where you supply the authorization, token, and userinfo endpoints manually.
- **OIDC**: for providers that support [OpenID Connect](https://openid.net/connect/) discovery. You supply only the issuer URL and endpoints are resolved automatically.
<Admonition type="note">
You can add up to 3 custom providers per project. If you need more, [contact support](/dashboard/support/new).
</Admonition>
## Creating a provider
The create form displays a read-only **Callback URL**. Copy this URL and configure it as the redirect/callback URI in your external identity provider before completing setup.
### OAuth2 provider
Use an OAuth2 provider when your identity provider does not support OpenID Connect discovery. You must supply the authorization, token, and userinfo endpoint URLs explicitly.
<Tabs
scrollable
size="small"
type="underlined"
defaultActiveId="dashboard"
queryGroup="setup-method"
>
<TabPanel id="dashboard" label="Dashboard">
1. Go to [Auth Providers](/dashboard/project/_/auth/providers) in the Dashboard.
2. Click **New Provider**. Select **Manual configuration** as the configuration method.
3. Enter a unique identifier (must start with `custom:`, for example `custom:my-oauth-provider`).
4. Enter the provider's **Client ID** and **Client Secret**.
5. Enter the **Authorization URL**, **Token URL**, and **UserInfo URL**.
6. Click **Create and enable provider**.
</TabPanel>
<TabPanel id="js" label="JavaScript">
```js
const { data, error } = await supabase.auth.admin.customProviders.createProvider({
provider_type: 'oauth2',
identifier: 'custom:my-oauth-provider',
name: 'My OAuth Provider',
client_id: 'your-client-id',
client_secret: 'your-client-secret',
authorization_url: 'https://provider.example.com/oauth/authorize',
token_url: 'https://provider.example.com/oauth/token',
userinfo_url: 'https://provider.example.com/oauth/userinfo',
scopes: ['profile', 'email'],
})
```
</TabPanel>
</Tabs>
### OIDC provider
Use an OIDC provider when your identity provider supports OpenID Connect. Supply the `issuer` URL and the discovery document, JWKS, and endpoints are resolved automatically.
<Tabs
scrollable
size="small"
type="underlined"
defaultActiveId="dashboard"
queryGroup="setup-method"
>
<TabPanel id="dashboard" label="Dashboard">
1. Go to [Auth Providers](/dashboard/project/_/auth/providers) in the Dashboard.
2. Click **New Provider**. Select **Auto-discovery (OIDC)** as the configuration method.
3. Enter a unique identifier (must start with `custom:`, for example `custom:my-regional-provider`).
4. Enter the provider's **Client ID** and **Client Secret**.
5. Enter the **Issuer URL**. The discovery document and endpoints are resolved automatically.
6. Click **Create and enable provider**.
</TabPanel>
<TabPanel id="js" label="JavaScript">
```js
const { data, error } = await supabase.auth.admin.customProviders.createProvider({
provider_type: 'oidc',
identifier: 'custom:my-regional-provider',
name: 'Regional Provider',
client_id: 'your-client-id',
client_secret: 'your-client-secret',
issuer: 'https://auth.example.com',
scopes: ['openid', 'profile', 'email'],
})
```
</TabPanel>
</Tabs>
OIDC providers have the following automatic behavior:
- The discovery document is fetched from `{issuer}/.well-known/openid-configuration` (or from `discovery_url` if set).
- The `openid` scope is always included. It is automatically added if missing from the `scopes` array.
- ID tokens are verified against the provider's JWKS (fetched from the discovery document's `jwks_uri`).
## Provider identifiers
Every custom provider identifier must start with the `custom:` prefix. Identifiers are 2–50 characters, lowercase alphanumeric with hyphens and colons allowed. Examples:
- `custom:my-provider`
- `custom:github-enterprise`
## User sign-in
Once a custom provider is created and enabled, users sign in via the standard OAuth authorize endpoint:
```
GET https://your-project.supabase.co/auth/v1/authorize?provider=custom:my-provider
```
Or using the Supabase client libraries:
<Tabs
scrollable
size="small"
type="underlined"
defaultActiveId="js"
queryGroup="language"
>
<TabPanel id="js" label="JavaScript">
```js
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'custom:my-provider',
})
```
</TabPanel>
<TabPanel id="flutter" label="Flutter">
```dart
await supabase.auth.signInWithOAuth(
OAuthProvider('custom:my-provider'),
);
```
</TabPanel>
<TabPanel id="swift" label="Swift">
```swift
try await supabase.auth.signInWithOAuth(
provider: "custom:my-provider",
redirectTo: URL(string: "my-custom-scheme://my-app-host")
)
```
</TabPanel>
<TabPanel id="kotlin" label="Kotlin">
```kotlin
supabase.auth.signInWith(CustomProvider("custom:my-provider"))
```
</TabPanel>
</Tabs>
## Managing providers
### List providers
<Tabs
scrollable
size="small"
type="underlined"
defaultActiveId="dashboard"
queryGroup="setup-method"
>
<TabPanel id="dashboard" label="Dashboard">
Go to [Auth Providers](/dashboard/project/_/auth/providers) in the Dashboard. All custom providers are listed under **Custom OAuth Providers**.
</TabPanel>
<TabPanel id="js" label="JavaScript">
```js
// List all custom providers
const { data, error } = await supabase.auth.admin.customProviders.listProviders()
// Filter by provider type
const { data, error } = await supabase.auth.admin.customProviders.listProviders({
type: 'oidc',
})
```
</TabPanel>
</Tabs>
### Update a provider
Update any provider fields except `provider_type` and `identifier`. Only provided fields are changed (partial update). To rotate a client secret, update only the `client_secret` field.
<Tabs
scrollable
size="small"
type="underlined"
defaultActiveId="dashboard"
queryGroup="setup-method"
>
<TabPanel id="dashboard" label="Dashboard">
1. Go to [Auth Providers](/dashboard/project/_/auth/providers) in the Dashboard.
2. Click the three-dot menu (⋮) next to the provider and select **Update**.
3. Modify the fields you want to change.
4. Click **Update provider**.
</TabPanel>
<TabPanel id="js" label="JavaScript">
```js
const { data, error } = await supabase.auth.admin.customProviders.updateProvider(
'custom:my-provider',
{
name: 'Updated Provider Name',
scopes: ['profile', 'email', 'groups'],
enabled: false,
}
)
```
</TabPanel>
</Tabs>
### Delete a provider
<Tabs
scrollable
size="small"
type="underlined"
defaultActiveId="dashboard"
queryGroup="setup-method"
>
<TabPanel id="dashboard" label="Dashboard">
1. Go to [Auth Providers](/dashboard/project/_/auth/providers) in the Dashboard.
2. Click the three-dot menu (⋮) next to the provider and select **Delete**.
3. Confirm the deletion.
</TabPanel>
<TabPanel id="js" label="JavaScript">
```js
const { data, error } =
await supabase.auth.admin.customProviders.deleteProvider('custom:my-provider')
```
</TabPanel>
</Tabs>
## Advanced configuration
### PKCE
PKCE (Proof Key for Code Exchange) is enabled by default (`pkce_enabled: true`) for all custom providers. The auth server automatically generates a code challenge and verifier during the authorization flow, protecting against authorization code interception attacks. This is handled entirely server-side, no client-side PKCE logic is needed.
To disable PKCE for a specific provider, set `pkce_enabled: false` when creating or updating it. This is not recommended unless the identity provider does not support PKCE.
### Authorization params
Extra query parameters appended to the provider's authorization URL during the OAuth flow. All values must be strings.
```json
{
"prompt": "consent",
"access_type": "offline",
"login_hint": "user@example.com"
}
```
The following reserved parameters are managed by the auth server and cannot be overridden: `client_id`, `client_secret`, `redirect_uri`, `response_type`, `state`, `code_challenge`, `code_challenge_method`, `code_verifier`, `nonce`.
### Multi-platform apps
If your app uses different client IDs for different platforms (for example, web vs mobile), use `acceptable_client_ids` to list additional client IDs that should be accepted for audience validation in OIDC ID tokens:
```js
const { data, error } = await supabase.auth.admin.customProviders.createProvider({
provider_type: 'oidc',
identifier: 'custom:multi-platform-app',
name: 'Multi-Platform App',
client_id: 'web-client-id',
client_secret: 'your-client-secret',
issuer: 'https://app.example.com',
scopes: ['openid', 'profile', 'email'],
acceptable_client_ids: ['ios-client-id', 'android-client-id'],
})
```
### Email-optional providers
By default, providers must return an email address. Set `email_optional` to `true` when creating or updating a provider to allow sign-in without an email. This applies to both OAuth2 and OIDC providers.
### OIDC-specific options
| Field | Type | Default | Description |
| ------------------ | -------- | ------- | ------------------------------------------------------------------------------------- |
| `discovery_url` | `string` | `null` | Override the discovery document URL if the provider uses a non-standard location. |
| `skip_nonce_check` | `bool` | `false` | Skip nonce validation on ID tokens. Use only for providers that do not support nonce. |
## Error reference
| Error code | HTTP status | Description |
| ---------------------------- | ----------- | ------------------------------------------------------------------------------------------ |
| `validation_failed` | 400 | Invalid parameters: missing required fields, bad format, reserved params, or invalid URLs. |
| `conflict` | 400 | A provider with the same identifier already exists. |
| `over_custom_provider_quota` | 400 | Maximum number of custom providers reached. |
| `custom_provider_not_found` | 404 | No provider exists with the given identifier. |
@@ -97,7 +97,7 @@ With Deep Linking, you can configure this redirect to open a specific page. This
export default function Auth() {
// Handle linking into app from email app.
const url = Linking.useURL();
const url = Linking.useLinkingURL();
if (url) createSessionFromUrl(url);
return (
@@ -45,6 +45,12 @@ Supabase supports a suite of social providers. Follow these guides to configure
</NavData>
</div>
<Admonition type="tip">
Need to integrate with a provider not listed here? You can add any OAuth2 or OIDC-compatible provider using [Custom OAuth/OIDC Providers](/docs/guides/auth/custom-oauth-providers).
</Admonition>
## Provider tokens
You can use the provider token and provider refresh token returned to make API calls to the OAuth provider. For example, you can use the Google provider token to access Google APIs on behalf of your user.
@@ -6,71 +6,90 @@ description: 'Things to do before making your app publicly available'
After developing your project and deciding it's production ready, you should run through this checklist to ensure that your project:
- is secure
- won't falter under the expected load
- remains available whilst in production
- [Is secure](#security)
- [Won't falter under the expected load](#performance)
- [Remains available whilst in production](#availability)
## Security
- Ensure RLS is enabled
<Admonition type="tip">
Check and review issues in your database using [Security Advisor](/dashboard/project/_/database/security-advisor).
</Admonition>
- Ensure you have enabled row level security (RLS) on all tables from the [**Database > Tables**](/dashboard/project/_/database/tables) section of the Supabase Dashboard.
- Tables that do not have RLS enabled with reasonable policies allow any client to access and modify their data. This is usually not what you want.
- [Learn more about RLS](/docs/guides/database/postgres/row-level-security).
- Enable replication on tables containing sensitive data by enabling Row Level Security (RLS) and setting row security policies:
- Go to the Authentication > Policies page in the Supabase Dashboard to enable RLS and create security policies.
- Go to the Database > Publications page in the Supabase Dashboard to manage replication tables.
- Turn on [SSL Enforcement](/docs/guides/platform/ssl-enforcement) (see: [dashboard](/dashboard/project/_/auth/policies))
- Enable [Network Restrictions](/docs/guides/platform/network-restrictions) for your database (see: [dashboard](/dashboard/project/_/database/settings#network-restrictions)).
- Ensure that your Supabase Account is protected with multi-factor authentication (MFA).
- If using a GitHub sign-in, [enable 2FA on GitHub](https://docs.github.com/en/authentication/securing-your-account-with-two-factor-authentication-2fa/configuring-two-factor-authentication). Since your GitHub account gives you administrative rights to your Supabase org, you should protect it with a strong password and 2FA using a U2F key or a TOTP app.
- Enable replication on tables containing sensitive data by enabling RLS and setting row security policies:
- Go to the [**Authentication > Policies**](/dashboard/project/_/auth/policies) section of the Supabase Dashboard to enable RLS and create security policies.
- Go to the [**Database > Publications**](/dashboard/project/_/database/publications) section of the Supabase Dashboard to manage replication tables.
- Turn on [SSL Enforcement](/docs/guides/platform/ssl-enforcement) from the [**Database > Settings > SSL Configuration**](/dashboard/project/_/database/settings#ssl-configuration) section of the dashboard.
- Enable [Network Restrictions](/docs/guides/platform/network-restrictions) for the database from the [**Database > Settings > Network Restrictions**](/dashboard/project/_/database/settings#network-restrictions) section of the dashboard.
- Ensure that you protect your Supabase Account with multi-factor authentication (MFA).
- If using a GitHub sign-in, [enable 2FA on GitHub](https://docs.github.com/en/authentication/securing-your-account-with-two-factor-authentication-2fa/configuring-two-factor-authentication).
- Since your GitHub account gives you administrative rights to your Supabase org, you should protect it with a strong password and 2FA using a U2F key or a TOTP app.
- If using email+password sign-in, set up [MFA for your Supabase account](/docs/guides/platform/multi-factor-authentication#enable-mfa).
- Enable [MFA enforcement on your organization](/docs/guides/platform/network-restrictions). This ensures all users must have a valid MFA backed session to interact with organization and project resources.
- Consider [adding multiple owners on your Supabase org](/dashboard/org/_/team). This ensures that if one of the owners is unreachable or loses access to their account, you still have Owner access to your org.
- Ensure email confirmations are [enabled](/dashboard/project/_/auth/providers) in the `Settings > Auth` page.
- Ensure that you've [set the expiry](/dashboard/project/_/auth/providers) for one-time passwords (OTPs) to a reasonable value that you are comfortable with. We recommend setting this to 3600 seconds (1 hour) or lower.
- Increase the length of the OTP if you need a higher level of entropy.
- Consider enabling [MFA enforcement on your organization](/docs/guides/platform/mfa/org-mfa-enforcement). This ensures all users must have a valid MFA-backed session to interact with organization and project resources.
- Consider adding multiple owners on your Supabase org from [the **Organization > Team**](/dashboard/org/_/team) section of the Supabase Dashboard. This ensures that if one of the owners is unreachable or loses access to their account, you still have Owner access to your org.
- Enable email confirmations in the [**Authentication > Providers**](/dashboard/project/_/auth/providers) section of the dashboard.
- Set the expiry in the [**Authentication > Providers**](/dashboard/project/_/auth/providers) section of the dashboard for one-time passwords (OTPs) to a reasonable value that you are comfortable with.
- We recommend setting this to 3600 seconds (1 hour) or lower.
- Increase the length of the OTP if you need a higher level of entropy.
- If your application requires a higher level of security, consider setting up [multi-factor authentication](/docs/guides/auth/auth-mfa) (MFA) for your users.
- Use a custom SMTP server for auth emails so that your users can see that the mails are coming from a trusted domain (preferably the same domain that your app is hosted on). Grab SMTP credentials from any major email provider such as SendGrid, AWS SES, etc.
- Consider how _you_ might abuse your service as an attacker, and take steps to mitigate it.
- Review these [common cybersecurity threats](https://auth0.com/docs/security/prevent-threats).
- Check and review issues in your database using [Security Advisor](/dashboard/project/_/database/security-advisor).
## Performance
<Admonition type="tip">
Check and review issues in your database using [Performance Advisor](/dashboard/project/_/database/performance-advisor).
</Admonition>
- Ensure that you have suitable indices to cater to your common query patterns
- [Learn more about indexes in Postgres](https://www.enterprisedb.com/postgres-tutorials/overview-postgresql-indexes).
- `pg_stat_statements` can help you [identify hot or slow queries](https://www.virtual-dba.com/blog/postgresql-performance-identifying-hot-and-slow-queries/).
- Perform load testing (preferably on a staging env)
- Tools like [k6](https://k6.io/) can simulate traffic from many different users.
- Upgrade your database if you require more resources. If you need anything beyond what is listed, contact enterprise@supabase.io.
- Upgrade your database if you require more resources. If you need anything beyond what is listed in [the compute and disk table](/docs/guides/platform/compute-and-disk), contact [enterprise@supabase.io](mailto:enterprise@supabase.io).
- If you are expecting a surge in traffic (for a big launch) and are on a Team or Enterprise Plan, [contact support](/dashboard/support/new) with more details about your launch and we'll help keep an eye on your project.
- If you expect your database size to be > 4 GB, [enable](/dashboard/project/_/settings/addons?panel=pitr) the Point in Time Recovery (PITR) add-on. Daily backups can take up resources from your database when the backup is in progress. PITR is more resource efficient, since only the changes to the database are backed up.
- Check and review issues in your database using [Performance Advisor](/dashboard/project/_/database/performance-advisor).
- If you expect your database size to be > 4 GB, enable the Point in Time Recovery (PITR) add-on in the [**Settings > Add-ons**](/dashboard/project/_/settings/addons?panel=pitr) section of the dashboard.
## Availability
- Use your own SMTP credentials so that you have full control over the deliverability of your transactional auth emails (see Settings > Auth)
- you can grab SMTP credentials from any major email provider such as SendGrid, AWS SES, etc. You can refer to our [SMTP guide](/docs/guides/auth/auth-smtp) for more details.
- The default rate limit for auth emails when using a custom SMTP provider is 30 new users per hour, if doing a major public announcement you will likely require more than this.
- Applications on the Free Plan that exhibit extremely low activity in a 7 day period may be paused by Supabase to save on server resources.
- You can restore paused projects from the Supabase dashboard.
- Upgrade to Pro to guarantee that your project will not be paused for inactivity.
- Use your own SMTP credentials so that you have full control over the deliverability of your transactional auth emails in the [**Authentication > Emails > SMTP Settings**](/dashboard/project/_/auth/smtp) section of the dashboard.
- You can grab SMTP credentials from any major email provider such as SendGrid, AWS SES, etc. Read our [SMTP guide](/docs/guides/auth/auth-smtp) for more setup details.
- The default rate limit for auth emails when using a custom SMTP provider is _30 new users per hour_. If you are doing a major public announcement, you will likely require more than this.
- We may pause applications on the Free Plan that exhibit low activity in a 7-day period to save on server resources.
- You can restore paused projects from [the Supabase dashboard](/dashboard/project/_).
- Upgrade to Pro to guarantee that we won't pause your project for inactivity.
- Database backups are not available for download for Free Plan projects.
- You can set up your own backup systems using tools like [pg_dump](https://www.postgresql.org/docs/current/app-pgdump) or [wal-g](https://github.com/wal-g/wal-g).
- Nightly backups for Pro Plan projects are available on the Supabase dashboard for up to 7 days.
- Point-in-Time Recovery (PITR) allows a project to be backed up at much shorter intervals. This provides users an option to restore to any chosen point of up to seconds in granularity. If a lower RPO is required, enable PITR.
- Read [the Database Backups guide](/docs/guides/platform/backups) for more options and retention details.
- If you need a lower recovery point objective (RPO), enable Point-in-Time Recovery (PITR). PITR allows you to back up a project at shorter intervals. This provides users an option to restore to any chosen point in time with second-level granularity.
- Supabase Projects use disks that offer 99.8-99.9% durability by default.
- Use Read Replicas if you require availability resilience to a disk failure event
- Use [Read Replicas](/docs/guides/platform/read-replicas) if you require availability resilience to a disk failure event
- Use PITR if you require durability resilience to a disk failure event
- Upgrading to the Supabase Pro Plan will give you [access to our support team](/dashboard/support/new).
- Upgrading to the Supabase Pro Plan gives you [access to our support team](/dashboard/support/new).
## Rate limiting, resource allocation, & abuse prevention
<Admonition type="caution" label="Shared Responsibility Model">
Running databases is a shared responsibility between you and Supabase. There are some things that we can take care of for you, and some things that you are responsible for.
Read more details in our [Shared Responsibility Model guide](/docs/guides/platform/shared-responsibility-model).
</Admonition>
- Supabase employs a number of safeguards against bursts of incoming traffic to prevent abuse and help maximize stability across the platform
- If you're on a Team or Enterprise Plan and expect high load events, such as production launches, heavy load testing, or prolonged high resource usage, open a ticket via the [support form](https://supabase.help) for help. Provide at least 2 weeks notice.
### Auth rate limits
- The table below shows the rate limit quotas on the following authentication endpoints. You can configure the auth rate limits for your project [here](/dashboard/project/_/auth/rate-limits).
- The table below shows the rate limit quotas on the following authentication endpoints. You can configure the authentication rate limits for your project in the [**Authentication > Rate Limits**](/dashboard/project/_/auth/rate-limits) section of the dashboard.
| Endpoint | Path | Limited By | Rate Limit |
| ------------------------------------------------ | -------------------------------------------------------------- | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -87,39 +106,13 @@ After developing your project and deciding it's production ready, you should run
### Realtime limits
- Review the [Realtime limits](/docs/guides/realtime/limits).
- If you need limits increased you can always [contact support](/dashboard/support/new).
- If you need limits increased, [contact support](/dashboard/support/new).
### Abuse prevention
- Supabase provides CAPTCHA protection on the signup, sign-in and password reset endpoints. Refer to [our guide](/docs/guides/auth/auth-captcha) on how to protect against abuse using this method.
- Supabase provides CAPTCHA protection on the signup, sign-in and password reset endpoints. Read [the Auth CAPTCHA guide](/docs/guides/auth/auth-captcha) for more details on how to protect against abuse using this method.
### Email link validity
- When working with enterprise systems, email scanners may scan and make a `GET` request to the reset password link or sign up link in your email. Since links in Supabase Auth are single use, a user who opens an email post-scan to click on a link will receive an error. To get around this problem,
consider altering the email template to replace the original magic link with a link to a domain you control. The domain can present the user with a "Sign-in" button which redirect the user to the original magic link URL when clicked.
- When using a custom SMTP service, some services might have link tracking enabled which may overwrite or disform the email confirmation links sent by Supabase Auth. To prevent this from happening, we recommend that you disable link tracking when using a custom SMTP service.
## Subscribe to Supabase status page
Stay informed about Supabase service status by subscribing to the [Status Page](https://status.supabase.com/). We recommend setting up Slack notifications through an RSS feed to ensure your team receives timely updates about service status changes.
### Setting up Slack notifications
1. Install the RSS app in Slack:
- Visit the [RSS app page](https://slack.com/marketplace/A0F81R7U7-rss) in the Slack marketplace
- Click `Add to Slack` if not already installed
- Otherwise you will get straight to next step, no need to reinstall the app
2. Configure the Supabase status feed:
- Create a channel (e.g., `#supabase-status-alerts`) for status updates
- On the [RSS app page](https://slack.com/marketplace/A0F81R7U7-rss) go to _Add a Feed_ section and set Feed URL to `https://status.supabase.com/history.rss`
- Select your designated channel and click "Subscribe to this feed"
Once configured, your team will receive automatic notifications in Slack whenever the Supabase Status Page is updated.
For detailed setup instructions, see the [Add RSS feeds to Slack](https://slack.com/intl/en-nz/help/articles/218688467-Add-RSS-feeds-to-Slack).
## Next steps
This checklist is always growing so be sure to check back frequently, and also feel free to suggest additions and amendments by making a PR on [GitHub](https://github.com/supabase/supabase).
- When working with enterprise systems, email scanners may scan and make a `GET` request to the reset password link or sign-up link in your email. Since links in Supabase Auth are single-use, a user who opens an email post-scan to click on a link will receive an error. To get around this problem, consider altering the email template to replace the original magic link with a link to a domain you control. The domain can present the user with a "Sign-in" button, which redirects the user to the original magic link URL when clicked.
- When using a custom SMTP service, some services might have link tracking enabled which may overwrite or deform the email confirmation links sent by Supabase Auth. To prevent this from happening, we recommend that you disable link tracking when using a custom SMTP service.
@@ -162,16 +162,16 @@ meta="name=src/routes/+page.server.ts"
Change the email template to support a server-side authentication flow.
Before we proceed, let's change the email template to support sending a token hash:
Before proceeding, change the email template to support sending a token hash:
- Go to the [**Auth** > **Emails**](/dashboard/project/_/auth/templates) page in the project dashboard.
- Select the **Confirm signup** template.
- Change `{{ .ConfirmationURL }}` to `{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email`.
- Repeat the previous step for **Magic link** template.
<Admonition type="tip">
<Admonition type="tip" label="Did you know?">
**Did you know?** You can also customize emails sent out to new users, including the email's looks, content, and query parameters. Check out the [settings of your project](/dashboard/project/_/auth/templates).
You can also customize emails sent out to new users, including the email's looks, content, and query parameters. Check out the [settings of your project](/dashboard/project/_/auth/templates).
</Admonition>
@@ -78,6 +78,12 @@ You can also install the CLI as dev dependency via [npm](https://www.npmjs.com/p
npm install supabase --save-dev
```
<Admonition type="note">
Global installation using `npm install -g supabase` is not supported. For global CLI usage, install via [Homebrew](/docs/guides/local-development/cli/getting-started?queryGroups=platform&platform=macos), [Scoop](/docs/guides/local-development/cli/getting-started?queryGroups=platform&platform=windows), or the [standalone binary](/docs/guides/local-development/cli/getting-started?queryGroups=platform&platform=linux).
</Admonition>
</TabPanel>
</Tabs>
@@ -236,47 +236,325 @@ const OLD_PROJECT_SERVICE_KEY = 'old-project-service-key-xxx'
const NEW_PROJECT_URL = 'https://yyy.supabase.co'
const NEW_PROJECT_SERVICE_KEY = 'new-project-service-key-yyy'
;(async () => {
const oldSupabaseRestClient = createClient(OLD_PROJECT_URL, OLD_PROJECT_SERVICE_KEY, {
db: {
schema: 'storage',
},
})
const oldSupabaseClient = createClient(OLD_PROJECT_URL, OLD_PROJECT_SERVICE_KEY)
const newSupabaseClient = createClient(NEW_PROJECT_URL, NEW_PROJECT_SERVICE_KEY)
const oldSupabase = createClient(OLD_PROJECT_URL, OLD_PROJECT_SERVICE_KEY)
const newSupabase = createClient(NEW_PROJECT_URL, NEW_PROJECT_SERVICE_KEY)
// make sure you update max_rows in postgrest settings if you have a lot of objects
// or paginate here
const { data: oldObjects, error } = await oldSupabaseRestClient.from('objects').select()
if (error) {
console.log('error getting objects from old bucket')
function createLoadingAnimation(message) {
const readline = require('readline')
const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
let i = 0
let timer
let stopped = false
const animate = () => {
if (stopped) return
process.stdout.write(`\r${frames[i]} ${message}`)
i = (i + 1) % frames.length
timer = setTimeout(animate, 80)
}
animate()
return {
stop: (finalMessage = '') => {
stopped = true
clearTimeout(timer)
readline.clearLine(process.stdout, 0)
readline.cursorTo(process.stdout, 0)
process.stdout.write(`✓ ${finalMessage || message}\n`)
},
}
}
/**
* Lists all files in a bucket, handling nested folders recursively.
*/
async function listAllFiles(bucket, path = '') {
const loader = createLoadingAnimation(`Listing files in '${bucket}${path ? '/' + path : ''}'...`)
try {
const { data, error } = await oldSupabase.storage.from(bucket).list(path, { limit: 1000 })
if (error) {
loader.stop(`Error listing files in '${bucket}${path ? '/' + path : ''}'`)
throw new Error(`❌ Error listing files in bucket '${bucket}': ${error.message}`)
}
if (!data || data.length === 0) {
loader.stop(`No files found in '${bucket}${path ? '/' + path : ''}'`)
return []
}
let files = []
for (const item of data) {
if (!item.metadata) {
loader.stop(`Found folder '${item.name}' in '${bucket}${path ? '/' + path : ''}'`)
const subFiles = await listAllFiles(bucket, `${path}${item.name}/`)
files = files.concat(subFiles)
} else {
files.push({ fullPath: `${path}${item.name}`, metadata: item.metadata })
}
}
loader.stop(`Found ${files.length} files in '${bucket}${path ? '/' + path : ''}'`)
return files
} catch (error) {
loader.stop()
throw error
}
}
for (const objectData of oldObjects) {
console.log(`moving ${objectData.id}`)
try {
const { data, error: downloadObjectError } = await oldSupabaseClient.storage
.from(objectData.bucket_id)
.download(objectData.name)
if (downloadObjectError) {
throw downloadObjectError
}
/**
* Creates a bucket in the new Supabase project if it doesn't exist.
*/
async function ensureBucketExists(bucketName, options = {}) {
const { data: existingBucket, error: getBucketError } =
await newSupabase.storage.getBucket(bucketName)
const { _, error: uploadObjectError } = await newSupabaseClient.storage
.from(objectData.bucket_id)
.upload(objectData.name, data, {
upsert: true,
contentType: objectData.metadata.mimetype,
cacheControl: objectData.metadata.cacheControl,
})
if (uploadObjectError) {
throw uploadObjectError
}
} catch (err) {
console.log('error moving ', objectData)
console.log(err)
if (getBucketError && !getBucketError.message.includes('not found')) {
throw new Error(`❌ Error checking if bucket '${bucketName}' exists: ${getBucketError.message}`)
}
if (!existingBucket) {
console.log(`🪣 Creating bucket '${bucketName}' in new project...`)
const { error } = await newSupabase.storage.createBucket(bucketName, options)
if (error) throw new Error(`❌ Failed to create bucket '${bucketName}': ${error.message}`)
console.log(`✅ Created bucket '${bucketName}'`)
} else {
console.log(`ℹ️ Bucket '${bucketName}' already exists in new project`)
}
}
/**
* Migrates a single file from the old project to the new one.
*/
async function migrateFile(sourceBucketName, targetBucketName, file) {
const loader = createLoadingAnimation(
`Migrating ${file.fullPath} in bucket '${sourceBucketName}' to '${targetBucketName}'...`
)
try {
const { data, error: downloadError } = await oldSupabase.storage
.from(sourceBucketName)
.download(file.fullPath)
if (downloadError) {
loader.stop(`Failed to migrate ${file.fullPath}: Download error`)
throw new Error(`Download failed: ${downloadError.message}`)
}
// Preserve all available metadata from the original file
const uploadOptions = {
upsert: true,
contentType: file.metadata?.mimetype,
cacheControl: file.metadata?.cacheControl,
}
const { error: uploadError } = await newSupabase.storage
.from(targetBucketName)
.upload(file.fullPath, data, uploadOptions)
if (uploadError) {
loader.stop(`Failed to migrate ${file.fullPath}: Upload error`)
throw new Error(`Upload failed: ${uploadError.message}`)
}
loader.stop(
`Migrated ${file.fullPath} in bucket '${sourceBucketName}' to '${targetBucketName}'`
)
return { success: true, path: file.fullPath }
} catch (err) {
console.error(
`❌ Error migrating ${file.fullPath} in bucket '${targetBucketName}':`,
err.message
)
return { success: false, path: file.fullPath, error: err.message }
}
}
function chunkArray(array, size) {
const chunks = []
for (let i = 0; i < array.length; i += size) {
chunks.push(array.slice(i, i + size))
}
return chunks
}
/**
* Migrates all buckets and files from the old Supabase project to the new one.
* Processes files in parallel within batches for efficiency.
*/
async function migrateBuckets() {
console.log('🔄 Starting Supabase Storage migration...')
console.log(`📦 Source project: ${OLD_PROJECT_URL}`)
console.log(`📦 Target project: ${NEW_PROJECT_URL}`)
const readline = require('readline').createInterface({
input: process.stdin,
output: process.stdout,
})
console.log(
'\n⚠️ WARNING: This migration may overwrite files in the target project if they have the same paths.'
)
console.log('⚠️ It is recommended to back up your target project before proceeding.')
const answer = await new Promise((resolve) => {
readline.question('Do you want to proceed with the migration? (yes/no): ', resolve)
})
readline.close()
if (answer.toLowerCase() !== 'yes') {
console.log('Migration canceled by user.')
return { canceled: true }
}
console.log('\n📦 Fetching all buckets from old project...')
const { data: oldBuckets, error: bucketListError } = await oldSupabase.storage.listBuckets()
if (bucketListError) throw new Error(`❌ Error fetching buckets: ${bucketListError.message}`)
console.log(`✅ Found ${oldBuckets.length} buckets to migrate.`)
const { data: existingBuckets, error: existingBucketsError } =
await newSupabase.storage.listBuckets()
if (existingBucketsError)
throw new Error(`❌ Error fetching existing buckets: ${existingBucketsError.message}`)
const existingBucketNames = existingBuckets.map((b) => b.name)
const conflictingBuckets = oldBuckets.filter((b) => existingBucketNames.includes(b.name))
let conflictStrategy = 2
if (conflictingBuckets.length > 0) {
console.log('\n⚠️ The following buckets already exist in the target project:')
conflictingBuckets.forEach((b) => console.log(` - ${b.name}`))
const conflictAnswer = await new Promise((resolve) => {
const rl = require('readline').createInterface({
input: process.stdin,
output: process.stdout,
})
rl.question(
'\nHow do you want to handle existing buckets?\n' +
'1. Skip existing buckets\n' +
'2. Merge files (may overwrite existing files)\n' +
'3. Rename buckets in target (add suffix "_migrated")\n' +
'4. Cancel migration\n' +
'Enter your choice (1-4): ',
(answer) => {
rl.close()
resolve(answer)
}
)
})
if (conflictAnswer === '4') {
console.log('Migration canceled by user.')
return { canceled: true }
}
conflictStrategy = parseInt(conflictAnswer)
if (isNaN(conflictStrategy) || conflictStrategy < 1 || conflictStrategy > 3) {
console.log('Invalid choice. Migration canceled.')
return { canceled: true }
}
}
})()
const migrationStats = {
totalBuckets: oldBuckets.length,
processedBuckets: 0,
skippedBuckets: 0,
totalFiles: 0,
successfulFiles: 0,
failedFiles: 0,
failedFilesList: [],
}
for (const bucket of oldBuckets) {
const bucketName = bucket.name
console.log(`\n📁 Processing bucket: ${bucketName}`)
let targetBucketName = bucketName
if (existingBucketNames.includes(bucketName)) {
if (conflictStrategy === 1) {
console.log(`⏩ Skipping bucket '${bucketName}' as it already exists in target project`)
migrationStats.skippedBuckets++
continue
} else if (conflictStrategy === 3) {
targetBucketName = `${bucketName}_migrated`
console.log(`🔄 Renaming bucket to '${targetBucketName}' in target project`)
} else {
console.log(`🔄 Merging files into existing bucket '${bucketName}' in target project`)
}
}
// Preserve bucket configuration when creating in the new project
if (targetBucketName !== bucketName || !existingBucketNames.includes(bucketName)) {
await ensureBucketExists(targetBucketName, {
public: bucket.public,
fileSizeLimit: bucket.file_size_limit,
allowedMimeTypes: bucket.allowed_mime_types,
})
}
const files = await listAllFiles(bucketName)
console.log(`✅ Found ${files.length} files in bucket '${bucketName}'.`)
migrationStats.totalFiles += files.length
const batches = chunkArray(files, 10)
for (let i = 0; i < batches.length; i++) {
console.log(`\n🚀 Processing batch ${i + 1}/${batches.length} (${batches[i].length} files)`)
const results = await Promise.all(
batches[i].map((file) => migrateFile(bucketName, targetBucketName, file))
)
const batchSuccesses = results.filter((r) => r.success).length
const batchFailures = results.filter((r) => !r.success)
migrationStats.successfulFiles += batchSuccesses
migrationStats.failedFiles += batchFailures.length
migrationStats.failedFilesList.push(...batchFailures.map((f) => f.path))
console.log(
`✅ Completed batch ${i + 1}/${batches.length}: ${batchSuccesses} succeeded, ${batchFailures.length} failed`
)
}
migrationStats.processedBuckets++
console.log(`✅ Completed bucket '${bucketName}' migration`)
}
console.log('\n📊 Migration Summary:')
console.log(
`Buckets: ${migrationStats.processedBuckets}/${migrationStats.totalBuckets} processed, ${migrationStats.skippedBuckets} skipped`
)
console.log(
`Files: ${migrationStats.successfulFiles} succeeded, ${migrationStats.failedFiles} failed (${migrationStats.totalFiles} total)`
)
if (migrationStats.failedFiles > 0) {
console.log('\n⚠️ Failed files:')
migrationStats.failedFilesList.forEach((path) => console.log(` - ${path}`))
return migrationStats
}
return migrationStats
}
migrateBuckets()
.then((stats) => {
if (stats.failedFiles > 0) {
console.log(`\n⚠️ Migration completed with ${stats.failedFiles} failed files.`)
process.exit(1)
} else {
console.log('\n🎉 Migration completed successfully!')
process.exit(0)
}
})
.catch((err) => {
console.error('❌ Fatal error during migration:', err.message)
process.exit(1)
})
```
@@ -60,7 +60,7 @@ height={625}
| Action | Description | More information |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Configure connection limit | Adjust the "Max concurrent connections" setting to increase or decrease the connection limit for your project | [Realtime Settings Guide](/docs/guides/realtime/settings) |
| Upgrade plan | Increase available client connections. Connection limits vary by plan: Free (200), Pro (500), Pro no spend cap (10,000), Team (10,000), Enterprise (10,000+) | [Pricing and Plans](/docs/guides/platform/pricing) |
| Upgrade plan | Increase available client connections. Connection limits vary by plan: Free (200), Pro (500), Pro no spend cap (10,000), Team (10,000), Enterprise (10,000+) | [Pricing and Plans](/pricing) |
| Review quotas | Understand connection limits and other Realtime quotas for your plan | [Realtime Quotas Reference](/docs/guides/realtime/quotas) |
| Understand connection quota | Learn how the concurrent connections quota works and how to configure it for your plan | [Concurrent Peak Connections Quota Troubleshooting](/docs/troubleshooting/realtime-concurrent-peak-connections-quota-jdDqcp) |
| Fix silent disconnections | Fix connection issues in background applications using heartbeat callbacks and Web Workers | [Handling Silent Disconnections in Background Apps](/docs/troubleshooting/realtime-handling-silent-disconnections-in-backgrounded-applications-592794) |
@@ -1,93 +1,139 @@
---
title = "Edge Function 401 error response"
topics = [ "functions" ]
keywords = [ "401", "error" ]
database_id = "12e09c3f-a48b-4aea-9df3-90a25ef0021e"
keywords = ["401", "error", "JWT", "authorization"]
[[errors]]
http_status_code = 401
message = "Invalid JWT"
---
The function rejected the request for lacking the appropriate authorization headers.
A 401 response from an Edge Function means either:
## Context for the error
- The function failed the [legacy auth verification check](/docs/guides/functions/development-tips#skipping-authorization-checks)
- Your function's logic deliberately returned a 401 response
#### The JWT verification check
## Quick triage
By default, edge functions are configured to check requests for a valid [legacy key](/docs/guides/api/api-keys#overview).
Check the response body returned by the request
#### How the check causes 401 errors
The check fails and return a 401 permission error if:
- The request includes an invalid legacy key, or
- The request uses the newer asymmetric keys instead of a legacy key.
#### What this check actually does
This validation provides limited security value. It only confirms that the request includes a legacy token associated with your project, such as the anon key.
It does not validate the user's identity or permissions beyond that.
Because the security check is weak, it was deprecated when used with the new asymmetric keys.
## Solving the error
### Step 1: Identifying the error
<Admonition type="note">
If the tests return a 401 but don't match the criteria below, the error is coming from your app logic, not the JWT check.
</Admonition>
### Inspecting the return message
When an edge function fails due to a platform 401 error, it returns the error:
### Case 1: `"Invalid JWT"` or `"Missing authorization header"`
```json
{
"code": 401,
"message": "Invalid JWT"
}
{ "code": 401, "message": "Invalid JWT" }
```
### Inspecting the logs
```json
{ "code": 401, "message": "Missing authorization header" }
```
Your code may return a 401 error due to its own logic
Both of these messages come from the [legacy auth verification check](/docs/guides/functions/development-tips#skipping-authorization-checks)
Go to: [Built-in JWT check failures](#built-in-jwt-check-failures)
### Case 2: Custom message or empty body
If the response body contains a message you coded, or nothing at all, then your function code _did_ execute and returned a 401 itself.
Go to: [Your function returned a 401](#your-function-returned-a-401)
### Case 3: Not sure
Run this query in [Log Explorer](/dashboard/project/_/logs/explorer?q=SELECT%0A++++cast%28timestamp+AS+datetime%29++AS+timestamp%2C%0A++++req.pathname+++++++++++++++++AS+function_name%2C%0A%0A++++CASE%0A++++++++WHEN+metadata.execution_id+IS+NOT+NULL%0A++++++++++++THEN+%27your_code_returned_401%27%0A++++++++WHEN+metadata.execution_id+IS+NULL%0A+++++++++AND+%28new_auth.prefix+IS+NOT+NULL+OR+legacy_payload.algorithm+<>+%27HS256%27%29%0A++++++++++++THEN+%27incompatible_keys%27%0A++++++++WHEN+metadata.execution_id+IS+NULL%0A++++++++AND+%0A++++++++++++%28%0A++++++++++++++++%28legacy_auth_data.invalid+IS+NOT+NULL+OR+new_auth.error+IS+NOT+NULL%29%0A++++++++++++++++++++OR%0A++++++++++++++++legacy_payload.algorithm+%3D+%27HS256%27%0A++++++++++++%29%0A++++++++++++THEN+%27invalid_key%27%0A++++++++WHEN+metadata.execution_id+IS+NULL%0A+++++++++AND+legacy_auth_data+++++++IS+NULL%0A+++++++++AND+new_auth.prefix+IS+NULL%0A++++++++++++THEN+%27missing_auth_header%27%0A++++END+AS+cause%0A%0AFROM+function_edge_logs%0A%0A++++--+unnesting+metadata%0A++++CROSS+JOIN+UNNEST%28metadata%29++++++++++AS+metadata%0A++++CROSS+JOIN+UNNEST%28metadata.request%29++AS+req%0A++++CROSS+JOIN+UNNEST%28metadata.response%29+AS+res%0A++++--+unnesting+auth+details%0A++++LEFT+JOIN+UNNEST%28req.sb%29++++++++++++++++++++AS+sb%0A++++LEFT+JOIN+UNNEST%28sb.apikey%29+++++++++++++++++AS+apikey%0A++++LEFT+JOIN+UNNEST%28apikey.authorization%29++++++AS+new_auth%0A++++LEFT+JOIN+UNNEST%28sb.jwt%29++++++++++++++++++++AS+legacy_jwt%0A++++LEFT+JOIN+UNNEST%28legacy_jwt.authorization%29++AS+legacy_auth_data%0A++++LEFT+JOIN+UNNEST%28legacy_auth_data.payload%29++AS+legacy_payload%0A%0AWHERE+res.status_code+%3D+401%0AORDER+BY+timestamp+DESC%0ALIMIT+200) to classify recent 401s:
```sql
select
cast(timestamp as datetime) as timestamp,
req.pathname as function_name,
case
when metadata.execution_id is not null then 'your_code_returned_401'
when metadata.execution_id is null
and (
new_auth.prefix is not null
or legacy_payload.algorithm != 'HS256'
) then 'incompatible_keys'
when metadata.execution_id is null
and (
(legacy_auth_data.invalid is not null or new_auth.error is not null)
or legacy_payload.algorithm = 'HS256'
) then 'invalid_key'
when metadata.execution_id is null
and legacy_auth_data is null
and new_auth.prefix is null then 'missing_auth_header'
end as cause
from
function_edge_logs
-- unnesting metadata
cross join UNNEST(metadata) as metadata
cross join UNNEST(metadata.request) as req
cross join UNNEST(metadata.response) as res
-- unnesting auth details
left join UNNEST(req.sb) as sb
left join UNNEST(sb.apikey) as apikey
left join UNNEST(apikey.authorization) as new_auth
left join UNNEST(sb.jwt) as legacy_jwt
left join UNNEST(legacy_jwt.authorization) as legacy_auth_data
left join UNNEST(legacy_auth_data.payload) as legacy_payload
where res.status_code = 401
order by timestamp desc
limit 50;
```
Depending on the output, you can use this table to find the appropriate debugging section:
| Value | Go to |
| ------------------------ | ------------------------------------------------------------- |
| `your_code_returned_401` | [Your function returned a 401](#your-function-returned-a-401) |
| `incompatible_keys` | [Incompatible key format](#incompatible-key-format) |
| `invalid_key` | [Invalid key](#invalid-key) |
| `missing_auth_header` | [Missing Authorization header](#missing-authorization-header) |
---
## Your function returned a 401
Your function ran, and somewhere in your code, its logic returned a 401.
**Example:**
```js
return new Response(JSON.stringify(data), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 401, // app logic returning a 401
status: 401, // <-- you set this
})
```
A 401 status code alone doesn't confirm a JWT check failure by itself. The log must also lack an `execution_id`.
**How to fix:**
![image](/docs/img/troubleshooting/401_in_the_logs.png)
1. Search your function code for `401`. Look for explicit status codes on `Response` objects.
2. Trace the condition that triggered it. If you're interacting with a third-party API in your code, that service may be returning 401 that you're forwarding in the response object.
3. Add logging before the return so future occurrences leave a trace:
Instead of manually reviewing the logs, you can run the below query in the [log explorer](</dashboard/project/_/logs/explorer?q=SELECT+DISTINCT%0A++++req.pathname+AS+function_name,%0A++++res.status_code%0A++FROM+function_edge_logs%0A++CROSS+JOIN+UNNEST(metadata)+AS+metadata+%0A++CROSS+JOIN+UNNEST(metadata.request)+AS+req+%0A++CROSS+JOIN+UNNEST(metadata.response)+AS+res+%0AWHERE+status_code+=+401+AND+metadata.execution_id+IS+NULL%0ALIMIT+10;&its=&ite=%3E>) to get a list of functions that were impacted by the check:
```sql
select distinct
req.pathname as function_name,
res.status_code
from
function_edge_logs
cross join UNNEST(metadata) as metadata
cross join UNNEST(metadata.request) as req
cross join UNNEST(metadata.response) as res
where status_code = 401 and metadata.execution_id is null
limit 10;
```js
console.error('Returning 401 - reason:', reason)
```
### Step 2: Disabling the JWT check
See: [Error handling in Edge Functions](/docs/guides/functions/error-handling)
The JWT check provides minimal security benefits, so we now recommend handling authentication through app logic instead. See the [Edge Function Auth Doc](/docs/guides/functions/auth) for details.
---
If you've migrated to [asymmetric keys (publishable/secret)](/dashboard/project/_/settings/api-keys) or no longer need the JWT check, you can disable it using one of the three options below:
## Built-in JWT check failures
Supabase Edge Functions have a legacy auth verification check that runs before your code. When it fails, your function never executes, and you get a 401 with `"Invalid JWT"` or `"Missing authorization header"` directly from the platform.
<Admonition type="deprecation">
Supabase now recommends turning off this built-in check and managing authentication directly in your function code, giving you more control over access. See [Securing Edge Functions](/docs/guides/functions/auth).
</Admonition>
The subsections below cover specific failure modes.
### Incompatible key format
Your project uses the [new asymmetric keys](/blog/jwt-signing-keys) for authentication. However, the [legacy auth verification check](/docs/guides/functions/development-tips#skipping-authorization-checks) only understands the legacy format.
**Fix:** Disable the built-in JWT check using one of the below methods and optionally [handle auth in your function code](/docs/guides/functions/auth)
<Accordion
type="default"
@@ -99,51 +145,49 @@ If you've migrated to [asymmetric keys (publishable/secret)](/dashboard/project/
>
<div className="border-b mt-3 pb-3">
<AccordionItem
header="In the Dashboard"
header="Method A: Dashboard"
id="item-1"
>
In the [Function Dashboard](/dashboard/project/_/functions/), under the offending function's detail tab, you can toggle off the check:
![image](/docs/img/troubleshooting/401_edge_functions_toggle_off_JWT_check.png)
In the [Functions Dashboard](/dashboard/project/_/functions/), open the affected function's `detail tab` and toggle off JWT verification.
![image](/docs/img/troubleshooting/401_edge_functions_toggle_off_JWT_check.png)
</AccordionItem>
</div>
<div className="border-b mt-3 pb-3">
<AccordionItem
header="Method B: Supabase CLI"
id="item-2"
>
Redeploy the edge function from the [Supabase CLI](/docs/guides/functions/quickstart) with the `--no-verify-jwt` flag
```sh
supabase functions deploy YOUR_FUNCTION_NAME --no-verify-jwt
```
</AccordionItem>
</div>
<div className="border-b mt-3 pb-3">
<AccordionItem
header="With the Management API"
id="item-2"
>
1. Generate a secret token in the [Account Preferences Dashboard](/dashboard/account/tokens)
2. Get your project ID from the [General Settings](/dashboard/project/_/settings/general)
3. Substitute in the `PROJECT_ID`, `FUNCTION_NAME`, and `SECRET_TOKEN` in below [management endpoint](/docs/reference/api/v1-update-a-function). Then execute the request from a terminal environment
```sh
# remember to substitute in your PROJECT_ID, FUNCTION_NAME, and SECRET_TOKEN
curl 'https://api.supabase.com/v1/projects/PROJECT_ID/functions/FUNCTION_NAME?verify_jwt=false' \
--request PATCH \
--header 'Content-Type: application/vnd.denoland.eszip' \
--header 'Authorization: Bearer YOUR_SECRET_TOKEN' \
--data '""'
````
</AccordionItem>
</div>
<div className="border-b mt-3 pb-3">
<AccordionItem
header="With the Supabase CLI"
header="Method C: Management API"
id="item-3"
>
Disable the legacy auth check with the [Supabase Management API](/docs/reference/api/introduction):
With the [Supabase CLI](/docs/guides/local-development/cli/getting-started), you can redeploy your edge function without the JWT check:
1. Generate a token at [Account Preferences](/dashboard/account/tokens).
2. Get your project ID from [General Settings](/dashboard/project/_/settings/general).
3. Run:
```sh
supabase functions deploy hello-world --no-verify-jwt
curl 'https://api.supabase.com/v1/projects/PROJECT_ID/functions/FUNCTION_NAME' \
--request PATCH \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer YOUR_SECRET_TOKEN' \
--data '{"verify_jwt": false}'
```
</AccordionItem>
@@ -151,12 +195,43 @@ supabase functions deploy hello-world --no-verify-jwt
</div>
</Accordion>
If you would like to continue using the JWT check, make sure that your [Supabase Client](/docs/guides/api/rest/client-libs) only uses your [legacy keys](/dashboard/project/_/settings/api-keys/legacy).
### Invalid key
The built-in check is enabled and the key you sent doesn't match your project's keys.
**Fix (recommended):** Disable the built-in check using the steps in [Incompatible key format](#incompatible-key-format).
**Fix (alternative):** If you want to keep the built-in check, ensure you're sending a valid key. Use one of your [legacy API keys](/dashboard/project/_/settings/api-keys/legacy) with the [Supabase client library](/docs/guides/api/rest/client-libs) when making your request.
```js
const supabase = createClient('https://xyzcompany.supabase.co', 'anon-key-or-service_role-key')
```
### Missing authorization header
The built-in check is enabled but your request has no `Authorization` header at all.
If you're using a [Supabase client library](/docs/guides/api/rest/client-libs), the header is added automatically. If you're calling the function from an external client (cURL, fetch, etc.), you need to supply it:
```sh
curl -L -X POST 'https://PROJECT_REF.supabase.co/functions/v1/hello-world' \
-H 'Authorization: Bearer YOUR_ANON_OR_SERVICE_ROLE_KEY' \
--data '{"name":"Functions"}'
```
Alternatively, you can disable the built-in check entirely (see [Incompatible key format](#incompatible-key-format)).
---
## Additional resources
- [Securing Edge Functions](/docs/guides/functions/auth)
- [Debugging Edge Functions](/docs/guides/functions/logging)
- [Quickstart Deployment: Dashboard](/docs/guides/functions/quickstart-dashboard)
- [Quickstart Deployment: CLI](/docs/guides/functions/quickstart)
````
- [Securing Edge Functions with Auth](/docs/guides/functions/auth)
- [Logging Edge Function Requests](/docs/guides/functions/logging)
- [Error Handling Edge Functions](/docs/guides/functions/error-handling)
- [Quickstart: Dashboard deployment](/docs/guides/functions/quickstart-dashboard)
- [Quickstart: CLI deployment](/docs/guides/functions/quickstart)
## Still stuck?
- Check the [Discord](https://discord.com/channels/839993398554656828/1006358244786196510), [Supabase GitHub Discussions](https://github.com/orgs/supabase/discussions), and [Reddit page](https://www.reddit.com/r/Supabase/) for similar reports that can help with debugging
- Open a [support ticket](/dashboard/support/new) for your project if the problem persists and you believe it is a platform issue
+34 -9
View File
@@ -265,15 +265,35 @@ function normalizeComment(original: TypedocComment | Comment | undefined): Comme
if (t.name) {
return [t.name, t.content.map((p) => p.text).join('')]
}
const first = t.content[0]
// TypeDoc may produce a tag with an empty content array (e.g. a tag with
// no body text). Guard here so the rest of the parsing can safely assume
// a non-empty first element.
if (!first) return ['', '']
const newline = first.text.indexOf('\n')
const name = (newline >= 0 ? first.text.slice(0, newline) : first.text).trim()
const tail = newline >= 0 ? first.text.slice(newline + 1) : ''
const body = [{ ...first, text: tail }, ...t.content.slice(1)].map((p) => p.text).join('')
if (!t.content[0]) return ['', '']
// The tag name may span multiple content nodes (e.g. "With " + "`select()`"
// when the name contains inline code). Scan through nodes until a newline.
const nameParts: string[] = []
let bodyStartIdx = 0
let bodyStartText = ''
let foundNewline = false
for (let i = 0; i < t.content.length; i++) {
const part = t.content[i]
const newline = part.text.indexOf('\n')
if (newline >= 0) {
nameParts.push(part.text.slice(0, newline))
bodyStartIdx = i
bodyStartText = part.text.slice(newline + 1)
foundNewline = true
break
} else {
nameParts.push(part.text)
}
}
const name = nameParts.join('').trim()
const body = foundNewline
? [{ text: bodyStartText }, ...t.content.slice(bodyStartIdx + 1)]
.map((p) => p.text)
.join('')
: ''
return [name, body]
}
@@ -539,9 +559,14 @@ function parseMethod(
let { params, ret, comment } = parseSignature(signature, map)
// When a method has multiple overload signatures, TypeDoc places the shared
// JSDoc on the method node rather than any individual signature.
if (!comment && node.comment) {
comment = normalizeComment(node.comment)
// JSDoc on the method node rather than any individual signature. Always merge
// node.comment as the base so that block tags (@remarks, @example, etc.) are
// not lost when overload signatures already carry a minimal summary comment.
if (node.comment) {
const nodeComment = normalizeComment(node.comment)
if (nodeComment) {
comment = { ...nodeComment, ...comment }
}
}
const types: MethodTypes = {
File diff suppressed because it is too large. Load diff
+1
View File
@@ -51,6 +51,7 @@ Chris Martin
Chris Stockton
Chris Ward
Clayton Kast
Colum Ferry
Craig Cannon
Cuong Do
Daniel Velázquez Lara
+41 -2
View File
@@ -4565,6 +4565,44 @@
"x-endpoint-owners": ["security"]
}
},
"/v1/projects/{ref}/database/openapi": {
"get": {
"description": "Returns the PostgREST OpenAPI specification for the project. This is the replacement for querying `/rest/v1/` directly with the anon key.",
"operationId": "v1-get-database-openapi",
"parameters": [
{
"name": "ref",
"required": true,
"in": "path",
"description": "Project ref",
"schema": { "minLength": 20, "maxLength": 20, "pattern": "^[a-z]+$", "type": "string" }
},
{
"name": "schema",
"required": false,
"in": "query",
"description": "The database schema to generate the OpenAPI spec for",
"schema": { "default": "public", "type": "string" }
}
],
"responses": {
"200": {
"description": "",
"content": { "application/json": { "schema": { "type": "object" } } }
},
"401": { "description": "Unauthorized" },
"403": { "description": "Forbidden action" },
"429": { "description": "Rate limit exceeded" },
"500": { "description": "Failed to fetch PostgREST OpenAPI spec" }
},
"security": [{ "bearer": [] }, { "fga_permissions": ["database_read"] }],
"summary": "Get PostgREST OpenAPI spec",
"tags": ["Database"],
"x-badges": [{ "name": "OAuth scope: database:read", "position": "after" }],
"x-endpoint-owners": ["management-api"],
"x-oauth-scope": "database:read"
}
},
"/v1/projects/{ref}/functions": {
"get": {
"description": "Returns all functions you've previously added to the specified project.",
@@ -10814,9 +10852,10 @@
"type": "object",
"properties": {
"name": { "type": "string" },
"status": { "type": "string", "enum": ["AVAILABLE", "PENDING", "REMOVED", "FAILED"] }
"status": { "type": "string", "enum": ["AVAILABLE", "PENDING", "REMOVED", "FAILED"] },
"completed_on": { "type": "string", "format": "date-time", "nullable": true }
},
"required": ["name", "status"]
"required": ["name", "status", "completed_on"]
},
"V1UndoBody": {
"type": "object",
+1 -1
View File
@@ -78,7 +78,7 @@ flags:
name: --workdir <string>
description: path to a Supabase project directory
default_value: ''
- id: "yes"
- id: 'yes'
name: --yes
description: answer yes to all prompts
default_value: 'false'
+6
View File
@@ -262,6 +262,12 @@
"slug": "v1-get-database-metadata",
"type": "operation"
},
{
"id": "v1-get-database-openapi",
"title": "Get database openapi",
"slug": "v1-get-database-openapi",
"type": "operation"
},
{
"id": "v1-get-jit-access",
"title": "Get jit access",
File diff suppressed because it is too large. Load diff
@@ -23,7 +23,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 88,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L88"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L88"
}
],
"type": {
@@ -42,7 +42,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 89,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L89"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L89"
}
],
"type": {
@@ -61,7 +61,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 90,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L90"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L90"
}
],
"type": {
@@ -80,7 +80,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 91,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L91"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L91"
}
],
"type": {
@@ -99,7 +99,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 92,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L92"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L92"
}
],
"type": {
@@ -118,7 +118,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 93,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L93"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L93"
}
],
"type": {
@@ -137,7 +137,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 94,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L94"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L94"
}
],
"type": {
@@ -156,7 +156,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 95,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L95"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L95"
}
],
"type": {
@@ -175,7 +175,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 96,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L96"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L96"
}
],
"type": {
@@ -194,7 +194,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 97,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L97"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L97"
}
],
"type": {
@@ -213,7 +213,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 98,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L98"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L98"
}
],
"type": {
@@ -232,7 +232,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 99,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L99"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L99"
}
],
"type": {
@@ -251,7 +251,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 100,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L100"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L100"
}
],
"type": {
@@ -270,7 +270,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 101,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L101"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L101"
}
],
"type": {
@@ -289,7 +289,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 102,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L102"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L102"
}
],
"type": {
@@ -309,7 +309,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 87,
"character": 12,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L87"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L87"
}
]
},
@@ -339,7 +339,7 @@
"fileName": "packages/core/functions-js/src/FunctionsClient.ts",
"line": 34,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/FunctionsClient.ts#L34"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/FunctionsClient.ts#L34"
}
],
"signatures": [
@@ -373,7 +373,7 @@
"fileName": "packages/core/functions-js/src/FunctionsClient.ts",
"line": 34,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/FunctionsClient.ts#L34"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/FunctionsClient.ts#L34"
}
],
"parameters": [
@@ -416,7 +416,7 @@
"fileName": "packages/core/functions-js/src/FunctionsClient.ts",
"line": 42,
"character": 6,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/FunctionsClient.ts#L42"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/FunctionsClient.ts#L42"
}
],
"type": {
@@ -645,7 +645,7 @@
"fileName": "packages/core/functions-js/src/FunctionsClient.ts",
"line": 41,
"character": 6,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/FunctionsClient.ts#L41"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/FunctionsClient.ts#L41"
}
],
"type": {
@@ -682,7 +682,7 @@
"fileName": "packages/core/functions-js/src/FunctionsClient.ts",
"line": 43,
"character": 6,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/FunctionsClient.ts#L43"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/FunctionsClient.ts#L43"
}
],
"type": {
@@ -705,7 +705,7 @@
"fileName": "packages/core/functions-js/src/FunctionsClient.ts",
"line": 40,
"character": 7,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/FunctionsClient.ts#L40"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/FunctionsClient.ts#L40"
}
]
}
@@ -735,7 +735,7 @@
"fileName": "packages/core/functions-js/src/FunctionsClient.ts",
"line": 19,
"character": 12,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/FunctionsClient.ts#L19"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/FunctionsClient.ts#L19"
}
],
"type": {
@@ -964,7 +964,7 @@
"fileName": "packages/core/functions-js/src/FunctionsClient.ts",
"line": 17,
"character": 12,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/FunctionsClient.ts#L17"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/FunctionsClient.ts#L17"
}
],
"type": {
@@ -1000,7 +1000,7 @@
"fileName": "packages/core/functions-js/src/FunctionsClient.ts",
"line": 18,
"character": 12,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/FunctionsClient.ts#L18"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/FunctionsClient.ts#L18"
}
],
"type": {
@@ -1023,7 +1023,7 @@
"fileName": "packages/core/functions-js/src/FunctionsClient.ts",
"line": 16,
"character": 12,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/FunctionsClient.ts#L16"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/FunctionsClient.ts#L16"
}
],
"type": {
@@ -1042,7 +1042,7 @@
"fileName": "packages/core/functions-js/src/FunctionsClient.ts",
"line": 75,
"character": 8,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/FunctionsClient.ts#L75"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/FunctionsClient.ts#L75"
}
],
"signatures": [
@@ -1076,7 +1076,7 @@
"fileName": "packages/core/functions-js/src/FunctionsClient.ts",
"line": 75,
"character": 8,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/FunctionsClient.ts#L75"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/FunctionsClient.ts#L75"
}
],
"typeParameters": [
@@ -1175,7 +1175,7 @@
"fileName": "packages/core/functions-js/src/FunctionsClient.ts",
"line": 60,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/FunctionsClient.ts#L60"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/FunctionsClient.ts#L60"
}
],
"signatures": [
@@ -1209,7 +1209,7 @@
"fileName": "packages/core/functions-js/src/FunctionsClient.ts",
"line": 60,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/FunctionsClient.ts#L60"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/FunctionsClient.ts#L60"
}
],
"parameters": [
@@ -1260,7 +1260,7 @@
"fileName": "packages/core/functions-js/src/FunctionsClient.ts",
"line": 15,
"character": 13,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/FunctionsClient.ts#L15"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/FunctionsClient.ts#L15"
}
]
},
@@ -1301,7 +1301,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 32,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L32"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L32"
}
],
"signatures": [
@@ -1316,7 +1316,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 32,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L32"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L32"
}
],
"parameters": [
@@ -1387,7 +1387,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 31,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L31"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L31"
}
],
"type": {
@@ -1411,7 +1411,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 30,
"character": 13,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L30"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L30"
}
],
"extendedTypes": [
@@ -1480,7 +1480,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 50,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L50"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L50"
}
],
"signatures": [
@@ -1495,7 +1495,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 50,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L50"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L50"
}
],
"parameters": [
@@ -1543,7 +1543,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 31,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L31"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L31"
}
],
"type": {
@@ -1572,7 +1572,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 49,
"character": 13,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L49"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L49"
}
],
"extendedTypes": [
@@ -1621,7 +1621,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 82,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L82"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L82"
}
],
"signatures": [
@@ -1636,7 +1636,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 82,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L82"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L82"
}
],
"parameters": [
@@ -1684,7 +1684,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 31,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L31"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L31"
}
],
"type": {
@@ -1713,7 +1713,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 81,
"character": 13,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L81"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L81"
}
],
"extendedTypes": [
@@ -1762,7 +1762,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 66,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L66"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L66"
}
],
"signatures": [
@@ -1777,7 +1777,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 66,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L66"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L66"
}
],
"parameters": [
@@ -1825,7 +1825,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 31,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L31"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L31"
}
],
"type": {
@@ -1854,7 +1854,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 65,
"character": 13,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L65"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L65"
}
],
"extendedTypes": [
@@ -1877,7 +1877,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 105,
"character": 12,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L105"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L105"
}
],
"type": {
@@ -1910,7 +1910,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 121,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L121"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L121"
}
],
"type": {
@@ -2019,7 +2019,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 109,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L109"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L109"
}
],
"type": {
@@ -2035,7 +2035,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 109,
"character": 12,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L109"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L109"
}
],
"indexSignatures": [
@@ -2050,7 +2050,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 109,
"character": 14,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L109"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L109"
}
],
"parameters": [
@@ -2096,7 +2096,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 113,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L113"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L113"
}
],
"type": {
@@ -2146,7 +2146,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 117,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L117"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L117"
}
],
"type": {
@@ -2177,7 +2177,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 132,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L132"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L132"
}
],
"type": {
@@ -2211,7 +2211,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 137,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L137"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L137"
}
],
"type": {
@@ -2231,7 +2231,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 105,
"character": 36,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L105"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L105"
}
]
}
@@ -2248,7 +2248,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 16,
"character": 12,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L16"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L16"
}
],
"typeParameters": [
@@ -23,7 +23,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 88,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L88"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L88"
}
],
"type": {
@@ -42,7 +42,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 89,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L89"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L89"
}
],
"type": {
@@ -61,7 +61,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 90,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L90"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L90"
}
],
"type": {
@@ -80,7 +80,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 91,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L91"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L91"
}
],
"type": {
@@ -99,7 +99,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 92,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L92"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L92"
}
],
"type": {
@@ -118,7 +118,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 93,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L93"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L93"
}
],
"type": {
@@ -137,7 +137,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 94,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L94"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L94"
}
],
"type": {
@@ -156,7 +156,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 95,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L95"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L95"
}
],
"type": {
@@ -175,7 +175,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 96,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L96"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L96"
}
],
"type": {
@@ -194,7 +194,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 97,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L97"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L97"
}
],
"type": {
@@ -213,7 +213,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 98,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L98"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L98"
}
],
"type": {
@@ -232,7 +232,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 99,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L99"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L99"
}
],
"type": {
@@ -251,7 +251,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 100,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L100"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L100"
}
],
"type": {
@@ -270,7 +270,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 101,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L101"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L101"
}
],
"type": {
@@ -289,7 +289,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 102,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L102"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L102"
}
],
"type": {
@@ -309,7 +309,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 87,
"character": 12,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L87"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L87"
}
]
},
@@ -339,7 +339,7 @@
"fileName": "packages/core/functions-js/src/FunctionsClient.ts",
"line": 34,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/FunctionsClient.ts#L34"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/FunctionsClient.ts#L34"
}
],
"signatures": [
@@ -373,7 +373,7 @@
"fileName": "packages/core/functions-js/src/FunctionsClient.ts",
"line": 34,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/FunctionsClient.ts#L34"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/FunctionsClient.ts#L34"
}
],
"parameters": [
@@ -416,7 +416,7 @@
"fileName": "packages/core/functions-js/src/FunctionsClient.ts",
"line": 42,
"character": 6,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/FunctionsClient.ts#L42"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/FunctionsClient.ts#L42"
}
],
"type": {
@@ -645,7 +645,7 @@
"fileName": "packages/core/functions-js/src/FunctionsClient.ts",
"line": 41,
"character": 6,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/FunctionsClient.ts#L41"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/FunctionsClient.ts#L41"
}
],
"type": {
@@ -682,7 +682,7 @@
"fileName": "packages/core/functions-js/src/FunctionsClient.ts",
"line": 43,
"character": 6,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/FunctionsClient.ts#L43"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/FunctionsClient.ts#L43"
}
],
"type": {
@@ -705,7 +705,7 @@
"fileName": "packages/core/functions-js/src/FunctionsClient.ts",
"line": 40,
"character": 7,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/FunctionsClient.ts#L40"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/FunctionsClient.ts#L40"
}
]
}
@@ -735,7 +735,7 @@
"fileName": "packages/core/functions-js/src/FunctionsClient.ts",
"line": 19,
"character": 12,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/FunctionsClient.ts#L19"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/FunctionsClient.ts#L19"
}
],
"type": {
@@ -964,7 +964,7 @@
"fileName": "packages/core/functions-js/src/FunctionsClient.ts",
"line": 17,
"character": 12,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/FunctionsClient.ts#L17"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/FunctionsClient.ts#L17"
}
],
"type": {
@@ -1000,7 +1000,7 @@
"fileName": "packages/core/functions-js/src/FunctionsClient.ts",
"line": 18,
"character": 12,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/FunctionsClient.ts#L18"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/FunctionsClient.ts#L18"
}
],
"type": {
@@ -1023,7 +1023,7 @@
"fileName": "packages/core/functions-js/src/FunctionsClient.ts",
"line": 16,
"character": 12,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/FunctionsClient.ts#L16"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/FunctionsClient.ts#L16"
}
],
"type": {
@@ -1042,7 +1042,7 @@
"fileName": "packages/core/functions-js/src/FunctionsClient.ts",
"line": 75,
"character": 8,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/FunctionsClient.ts#L75"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/FunctionsClient.ts#L75"
}
],
"signatures": [
@@ -1076,7 +1076,7 @@
"fileName": "packages/core/functions-js/src/FunctionsClient.ts",
"line": 75,
"character": 8,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/FunctionsClient.ts#L75"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/FunctionsClient.ts#L75"
}
],
"typeParameters": [
@@ -1175,7 +1175,7 @@
"fileName": "packages/core/functions-js/src/FunctionsClient.ts",
"line": 60,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/FunctionsClient.ts#L60"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/FunctionsClient.ts#L60"
}
],
"signatures": [
@@ -1209,7 +1209,7 @@
"fileName": "packages/core/functions-js/src/FunctionsClient.ts",
"line": 60,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/FunctionsClient.ts#L60"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/FunctionsClient.ts#L60"
}
],
"parameters": [
@@ -1260,7 +1260,7 @@
"fileName": "packages/core/functions-js/src/FunctionsClient.ts",
"line": 15,
"character": 13,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/FunctionsClient.ts#L15"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/FunctionsClient.ts#L15"
}
]
},
@@ -1301,7 +1301,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 32,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L32"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L32"
}
],
"signatures": [
@@ -1316,7 +1316,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 32,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L32"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L32"
}
],
"parameters": [
@@ -1387,7 +1387,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 31,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L31"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L31"
}
],
"type": {
@@ -1411,7 +1411,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 30,
"character": 13,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L30"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L30"
}
],
"extendedTypes": [
@@ -1480,7 +1480,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 50,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L50"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L50"
}
],
"signatures": [
@@ -1495,7 +1495,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 50,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L50"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L50"
}
],
"parameters": [
@@ -1543,7 +1543,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 31,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L31"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L31"
}
],
"type": {
@@ -1572,7 +1572,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 49,
"character": 13,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L49"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L49"
}
],
"extendedTypes": [
@@ -1621,7 +1621,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 82,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L82"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L82"
}
],
"signatures": [
@@ -1636,7 +1636,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 82,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L82"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L82"
}
],
"parameters": [
@@ -1684,7 +1684,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 31,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L31"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L31"
}
],
"type": {
@@ -1713,7 +1713,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 81,
"character": 13,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L81"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L81"
}
],
"extendedTypes": [
@@ -1762,7 +1762,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 66,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L66"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L66"
}
],
"signatures": [
@@ -1777,7 +1777,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 66,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L66"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L66"
}
],
"parameters": [
@@ -1825,7 +1825,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 31,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L31"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L31"
}
],
"type": {
@@ -1854,7 +1854,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 65,
"character": 13,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L65"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L65"
}
],
"extendedTypes": [
@@ -1877,7 +1877,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 105,
"character": 12,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L105"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L105"
}
],
"type": {
@@ -1910,7 +1910,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 121,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L121"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L121"
}
],
"type": {
@@ -2019,7 +2019,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 109,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L109"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L109"
}
],
"type": {
@@ -2035,7 +2035,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 109,
"character": 12,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L109"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L109"
}
],
"indexSignatures": [
@@ -2050,7 +2050,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 109,
"character": 14,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L109"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L109"
}
],
"parameters": [
@@ -2096,7 +2096,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 113,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L113"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L113"
}
],
"type": {
@@ -2146,7 +2146,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 117,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L117"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L117"
}
],
"type": {
@@ -2177,7 +2177,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 132,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L132"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L132"
}
],
"type": {
@@ -2211,7 +2211,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 137,
"character": 2,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L137"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L137"
}
],
"type": {
@@ -2231,7 +2231,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 105,
"character": 36,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L105"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L105"
}
]
}
@@ -2248,7 +2248,7 @@
"fileName": "packages/core/functions-js/src/types.ts",
"line": 16,
"character": 12,
"url": "https://github.com/supabase/supabase-js/blob/b835e3c870f3767980776cc25fd16df72fa416be/packages/core/functions-js/src/types.ts#L16"
"url": "https://github.com/supabase/supabase-js/blob/9ee9997608ae9f6d28717dcda4c4a85a66208069/packages/core/functions-js/src/types.ts#L16"
}
],
"typeParameters": [
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+96
View File
@@ -1424,6 +1424,38 @@
"summary": "Retrieve object info",
"tags": ["object"],
"parameters": [
{
"schema": { "type": "integer", "minimum": 0 },
"example": 100,
"in": "query",
"name": "height",
"required": false
},
{
"schema": { "type": "integer", "minimum": 0 },
"example": 100,
"in": "query",
"name": "width",
"required": false
},
{
"schema": { "type": "string", "enum": ["cover", "contain", "fill"] },
"in": "query",
"name": "resize",
"required": false
},
{
"schema": { "type": "string", "enum": ["origin", "avif"] },
"in": "query",
"name": "format",
"required": false
},
{
"schema": { "type": "integer", "minimum": 20, "maximum": 100 },
"in": "query",
"name": "quality",
"required": false
},
{
"schema": { "type": "string" },
"example": "avatars",
@@ -1462,6 +1494,38 @@
"tags": ["object"],
"description": "Object Info",
"parameters": [
{
"schema": { "type": "integer", "minimum": 0 },
"example": 100,
"in": "query",
"name": "height",
"required": false
},
{
"schema": { "type": "integer", "minimum": 0 },
"example": 100,
"in": "query",
"name": "width",
"required": false
},
{
"schema": { "type": "string", "enum": ["cover", "contain", "fill"] },
"in": "query",
"name": "resize",
"required": false
},
{
"schema": { "type": "string", "enum": ["origin", "avif"] },
"in": "query",
"name": "format",
"required": false
},
{
"schema": { "type": "integer", "minimum": 20, "maximum": 100 },
"in": "query",
"name": "quality",
"required": false
},
{
"schema": { "type": "string" },
"example": "avatars",
@@ -1648,6 +1712,38 @@
"tags": ["object"],
"description": "returns object info",
"parameters": [
{
"schema": { "type": "integer", "minimum": 0 },
"example": 100,
"in": "query",
"name": "height",
"required": false
},
{
"schema": { "type": "integer", "minimum": 0 },
"example": 100,
"in": "query",
"name": "width",
"required": false
},
{
"schema": { "type": "string", "enum": ["cover", "contain", "fill"] },
"in": "query",
"name": "resize",
"required": false
},
{
"schema": { "type": "string", "enum": ["origin", "avif"] },
"in": "query",
"name": "format",
"required": false
},
{
"schema": { "type": "integer", "minimum": 20, "maximum": 100 },
"in": "query",
"name": "quality",
"required": false
},
{
"schema": { "type": "string" },
"example": "avatars",
File diff suppressed because it is too large. Load diff
@@ -16518,6 +16518,78 @@
"x-endpoint-owners": ["security"]
}
},
"/v1/projects/{ref}/database/openapi": {
"get": {
"description": "Returns the PostgREST OpenAPI specification for the project. This is the replacement for querying `/rest/v1/` directly with the anon key.",
"operationId": "v1-get-database-openapi",
"parameters": [
{
"name": "ref",
"required": true,
"in": "path",
"description": "Project ref",
"schema": {
"minLength": 20,
"maxLength": 20,
"pattern": "^[a-z]+$",
"type": "string"
}
},
{
"name": "schema",
"required": false,
"in": "query",
"description": "The database schema to generate the OpenAPI spec for",
"schema": {
"default": "public",
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Forbidden action"
},
"429": {
"description": "Rate limit exceeded"
},
"500": {
"description": "Failed to fetch PostgREST OpenAPI spec"
}
},
"security": [
{
"bearer": []
},
{
"fga_permissions": ["database_read"]
}
],
"summary": "Get PostgREST OpenAPI spec",
"tags": ["Database"],
"x-badges": [
{
"name": "OAuth scope: database:read",
"position": "after"
}
],
"x-endpoint-owners": ["management-api"],
"x-oauth-scope": "database:read"
}
},
"/v1/projects/{ref}/functions": {
"get": {
"description": "Returns all functions you've previously added to the specified project.",
@@ -20653,9 +20725,14 @@
"status": {
"type": "string",
"enum": ["AVAILABLE", "PENDING", "REMOVED", "FAILED"]
},
"completed_on": {
"type": "string",
"format": "date-time",
"nullable": true
}
},
"required": ["name", "status"]
"required": ["name", "status", "completed_on"]
}
}
}
@@ -20729,9 +20806,14 @@
"status": {
"type": "string",
"enum": ["AVAILABLE", "PENDING", "REMOVED", "FAILED"]
},
"completed_on": {
"type": "string",
"format": "date-time",
"nullable": true
}
},
"required": ["name", "status"]
"required": ["name", "status", "completed_on"]
}
}
}
@@ -29558,9 +29640,14 @@
"status": {
"type": "string",
"enum": ["AVAILABLE", "PENDING", "REMOVED", "FAILED"]
},
"completed_on": {
"type": "string",
"format": "date-time",
"nullable": true
}
},
"required": ["name", "status"]
"required": ["name", "status", "completed_on"]
},
"V1UndoBody": {
"type": "object",
@@ -2351,6 +2351,54 @@
"summary": "Retrieve object info",
"tags": ["object"],
"parameters": [
{
"schema": {
"type": "integer",
"minimum": 0
},
"example": 100,
"in": "query",
"name": "height",
"required": false
},
{
"schema": {
"type": "integer",
"minimum": 0
},
"example": 100,
"in": "query",
"name": "width",
"required": false
},
{
"schema": {
"type": "string",
"enum": ["cover", "contain", "fill"]
},
"in": "query",
"name": "resize",
"required": false
},
{
"schema": {
"type": "string",
"enum": ["origin", "avif"]
},
"in": "query",
"name": "format",
"required": false
},
{
"schema": {
"type": "integer",
"minimum": 20,
"maximum": 100
},
"in": "query",
"name": "quality",
"required": false
},
{
"schema": {
"type": "string"
@@ -2412,6 +2460,54 @@
"tags": ["object"],
"description": "Object Info",
"parameters": [
{
"schema": {
"type": "integer",
"minimum": 0
},
"example": 100,
"in": "query",
"name": "height",
"required": false
},
{
"schema": {
"type": "integer",
"minimum": 0
},
"example": 100,
"in": "query",
"name": "width",
"required": false
},
{
"schema": {
"type": "string",
"enum": ["cover", "contain", "fill"]
},
"in": "query",
"name": "resize",
"required": false
},
{
"schema": {
"type": "string",
"enum": ["origin", "avif"]
},
"in": "query",
"name": "format",
"required": false
},
{
"schema": {
"type": "integer",
"minimum": 20,
"maximum": 100
},
"in": "query",
"name": "quality",
"required": false
},
{
"schema": {
"type": "string"
@@ -2766,6 +2862,54 @@
"tags": ["object"],
"description": "returns object info",
"parameters": [
{
"schema": {
"type": "integer",
"minimum": 0
},
"example": 100,
"in": "query",
"name": "height",
"required": false
},
{
"schema": {
"type": "integer",
"minimum": 0
},
"example": 100,
"in": "query",
"name": "width",
"required": false
},
{
"schema": {
"type": "string",
"enum": ["cover", "contain", "fill"]
},
"in": "query",
"name": "resize",
"required": false
},
{
"schema": {
"type": "string",
"enum": ["origin", "avif"]
},
"in": "query",
"name": "format",
"required": false
},
{
"schema": {
"type": "integer",
"minimum": 20,
"maximum": 100
},
"in": "query",
"name": "quality",
"required": false
},
{
"schema": {
"type": "string"
+6 -16
View File
@@ -3,9 +3,9 @@
"react-hooks/exhaustive-deps": 204,
"import/no-anonymous-default-export": 57,
"@tanstack/query/exhaustive-deps": 12,
"@typescript-eslint/no-explicit-any": 1204,
"@typescript-eslint/no-explicit-any": 1190,
"no-restricted-imports": 59,
"no-restricted-exports": 278,
"no-restricted-exports": 273,
"react/no-unstable-nested-components": 66
},
"ruleFiles": {
@@ -319,6 +319,7 @@
"components/interfaces/Docs/ResourceContent.tsx": 1,
"components/interfaces/Docs/RpcContent.tsx": 4,
"components/interfaces/Docs/Snippets.ts": 1,
"components/interfaces/ErrorHandling/error-mappings.tsx": 1,
"components/interfaces/Functions/CommandRender.tsx": 4,
"components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx": 1,
"components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.utils.tsx": 3,
@@ -416,7 +417,6 @@
"components/interfaces/SQLEditor/UtilityPanel/UtilityPanel.tsx": 1,
"components/interfaces/SQLEditor/hooks.ts": 1,
"components/interfaces/SQLEditor/useAddDefinitions.ts": 1,
"components/interfaces/Settings/Addons/Addons.tsx": 1,
"components/interfaces/Settings/Addons/CustomDomainSidePanel.tsx": 1,
"components/interfaces/Settings/Addons/PITRSidePanel.tsx": 1,
"components/interfaces/Settings/Database/DatabaseSettings/ResetDbPassword.tsx": 2,
@@ -476,7 +476,6 @@
"components/interfaces/TableGridEditor/SidePanelEditor/ActionBar.tsx": 1,
"components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnDefaultValue.tsx": 2,
"components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.tsx": 6,
"components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.utils.ts": 2,
"components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnType.tsx": 1,
"components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/InputWithSuggestions.tsx": 2,
"components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/ForeignRowSelector/ForeignRowSelector.tsx": 1,
@@ -491,7 +490,7 @@
"components/interfaces/TableGridEditor/SidePanelEditor/SchemaEditor.tsx": 2,
"components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx": 14,
"components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.types.ts": 1,
"components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.tsx": 15,
"components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.tsx": 14,
"components/interfaces/TableGridEditor/SidePanelEditor/SpreadsheetImport/SpreadSheetFileUpload.tsx": 3,
"components/interfaces/TableGridEditor/SidePanelEditor/SpreadsheetImport/SpreadSheetTextInput.tsx": 1,
"components/interfaces/TableGridEditor/SidePanelEditor/SpreadsheetImport/SpreadsheetImport.tsx": 4,
@@ -499,10 +498,7 @@
"components/interfaces/TableGridEditor/SidePanelEditor/SpreadsheetImport/SpreadsheetImport.utils.tsx": 18,
"components/interfaces/TableGridEditor/SidePanelEditor/SpreadsheetImport/SpreadsheetImportPreview.tsx": 2,
"components/interfaces/TableGridEditor/SidePanelEditor/SpreadsheetImport/SpreadsheetPreviewGrid.tsx": 2,
"components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/Column.tsx": 3,
"components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/ColumnManagement.tsx": 3,
"components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/ForeignKeysManagement/ForeignKeyRow.tsx": 1,
"components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx": 2,
"components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.types.ts": 1,
"components/interfaces/TableGridEditor/TableDefinition.tsx": 3,
"components/interfaces/TableGridEditor/TableEntity.utils.ts": 1,
@@ -536,7 +532,7 @@
"components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorTreeViewItem.tsx": 2,
"components/layouts/SQLEditorLayout/SQLEditorNavV2/SearchList.tsx": 1,
"components/layouts/TableEditorLayout/EntityListItem.tsx": 1,
"components/layouts/TableEditorLayout/TableEditorMenu.tsx": 2,
"components/layouts/TableEditorLayout/TableEditorMenu.tsx": 1,
"components/layouts/Tabs/NewTab.tsx": 1,
"components/to-be-cleaned/KeyMap.tsx": 3,
"components/ui/AIAssistantPanel/AIAssistant.utils.ts": 1,
@@ -571,7 +567,7 @@
"components/ui/DatePicker/index.tsx": 2,
"components/ui/DateRangePicker.tsx": 1,
"components/ui/DebouncedComponent.tsx": 1,
"components/ui/DownloadResultsButton.tsx": 2,
"components/ui/DownloadResultsButton.tsx": 1,
"components/ui/Error.tsx": 1,
"components/ui/ErrorBoundary/ErrorBoundary.tsx": 2,
"components/ui/ErrorBoundary/GlobalErrorBoundaryState.tsx": 1,
@@ -696,7 +692,6 @@
"pages/api/ai/feedback/rate.ts": 2,
"pages/api/check-cname.ts": 1,
"pages/api/edge-functions/test.ts": 1,
"pages/api/integrations/stripe-sync.ts": 1,
"pages/api/platform/auth/[ref]/users/[id]/factors.ts": 1,
"pages/api/platform/database/[ref]/pooling.ts": 1,
"pages/api/platform/integrations/github/authorization.ts": 1,
@@ -968,8 +963,6 @@
"components/interfaces/TableGridEditor/SidePanelEditor/SpreadsheetImport/SpreadSheetImportConfiguration.tsx": 1,
"components/interfaces/TableGridEditor/SidePanelEditor/SpreadsheetImport/SpreadSheetTextInput.tsx": 1,
"components/interfaces/TableGridEditor/SidePanelEditor/SpreadsheetImport/SpreadsheetPreviewGrid.tsx": 1,
"components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/Column.tsx": 1,
"components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/ColumnManagement.tsx": 1,
"components/interfaces/TableGridEditor/ViewEntityAutofixSecurityModal.tsx": 1,
"components/layouts/APIAuthorizationLayout.tsx": 1,
"components/layouts/APIKeys/APIKeysLayout.tsx": 1,
@@ -987,7 +980,6 @@
"components/layouts/JWTKeys/JWTKeysLayout.tsx": 1,
"components/layouts/LinkAwsMarketplaceLayout.tsx": 1,
"components/layouts/LogsLayout/LogsLayout.tsx": 1,
"components/layouts/Navigation/FloatingMobileToolbar/FloatingMobileToolbar.tsx": 1,
"components/layouts/Navigation/NavigationBar/MobileNavigationBar.tsx": 1,
"components/layouts/Navigation/NavigationBar/MobileViewNav.tsx": 1,
"components/layouts/Navigation/NavigationBar/NavigationIconLink.tsx": 1,
@@ -999,11 +991,9 @@
"components/layouts/ProjectLayout/ConnectingState.tsx": 1,
"components/layouts/ProjectLayout/OrganizationSettingsLayout.tsx": 1,
"components/layouts/ProjectLayout/RestartingState.tsx": 1,
"components/layouts/ProjectLayout/RestoringState.tsx": 1,
"components/layouts/ProjectSettingsLayout/SettingsLayout.tsx": 1,
"components/layouts/RealtimeLayout/RealtimeLayout.tsx": 1,
"components/layouts/RedeemCreditsLayout.tsx": 1,
"components/layouts/ReportsLayout/ReportsLayout.tsx": 1,
"components/layouts/ReportsLayout/ReportsMenu.tsx": 1,
"components/layouts/SQLEditorLayout/SQLEditorLayout.tsx": 1,
"components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorLoadingSnippets.tsx": 1,
+6 -43
View File
@@ -1,4 +1,4 @@
import { keepPreviousData, useQueryClient } from '@tanstack/react-query'
import { keepPreviousData } from '@tanstack/react-query'
import { useParams } from 'common'
import { isMsSqlForeignTable } from 'data/table-editor/table-editor-types'
import { useTableRowsQuery } from 'data/table-rows/table-rows-query'
@@ -15,10 +15,7 @@ import { useTableEditorStateSnapshot } from 'state/table-editor'
import { QueuedOperation } from 'state/table-editor-operation-queue.types'
import { useTableEditorTableStateSnapshot } from 'state/table-editor-table'
import {
useIsQueueOperationsEnabled,
useIsTableFilterBarEnabled,
} from '../interfaces/App/FeaturePreview/FeaturePreviewContext'
import { useIsTableFilterBarEnabled } from '../interfaces/App/FeaturePreview/FeaturePreviewContext'
import { Shortcuts } from './components/common/Shortcuts'
import { Footer } from './components/footer/Footer'
import { Grid } from './components/grid/Grid'
@@ -29,7 +26,7 @@ import { useTableFilter } from './hooks/useTableFilter'
import { useTableSort } from './hooks/useTableSort'
import { validateMsSqlSorting } from './MsSqlValidation'
import { GridProps } from './types'
import { reapplyOptimisticUpdates } from './utils/reapplyOptimisticUpdates'
import { formatGridDataWithOperationValues, reapplyOptimisticUpdates } from './utils/reapplyOptimisticUpdates'
export const SupabaseGrid = ({
customHeader,
@@ -43,9 +40,6 @@ export const SupabaseGrid = ({
const { id: _id } = useParams()
const tableId = _id ? Number(_id) : undefined
const isQueueOperationsEnabled = useIsQueueOperationsEnabled()
const queryClient = useQueryClient()
const { data: project } = useSelectedProjectQuery()
const tableEditorSnap = useTableEditorStateSnapshot()
const snap = useTableEditorTableStateSnapshot()
@@ -73,7 +67,6 @@ export const SupabaseGrid = ({
isError,
isPending: isLoading,
isRefetching,
dataUpdatedAt,
} = useTableRowsQuery(
{
projectRef: project?.ref,
@@ -100,39 +93,9 @@ export const SupabaseGrid = ({
if (!mounted) setMounted(true)
}, [])
// Re-apply optimistic updates when table data is loaded/refetched
// This ensures pending changes remain visible when switching tabs or after data refresh
// Skip re-applying during save to avoid race condition where refetch completes before queue clears
const isSaving = tableEditorSnap.operationQueue.status === 'saving'
useEffect(() => {
if (
isSuccess &&
project?.ref &&
tableId &&
isQueueOperationsEnabled &&
tableEditorSnap.hasPendingOperations &&
!isSaving
) {
reapplyOptimisticUpdates({
queryClient,
projectRef: project.ref,
tableId,
operations: tableEditorSnap.operationQueue.operations as readonly QueuedOperation[],
})
}
}, [
isSuccess,
dataUpdatedAt,
project?.ref,
tableId,
isQueueOperationsEnabled,
tableEditorSnap.hasPendingOperations,
tableEditorSnap.operationQueue.operations,
queryClient,
isSaving,
])
const rows = data?.rows ?? EMPTY_ARR
const operations = tableEditorSnap.operationQueue.operations as QueuedOperation[]
const baseRows = data?.rows ?? EMPTY_ARR
const rows = formatGridDataWithOperationValues({ operations, rows: baseRows })
const HeaderComponent = newFilterBarEnabled ? HeaderNew : Header
@@ -1,18 +1,19 @@
import AwesomeDebouncePromise from 'awesome-debounce-promise'
import { compact } from 'lodash'
import { useEffect, useMemo } from 'react'
import { CalculatedColumn, CellKeyboardEvent } from 'react-data-grid'
import type { Filter, SavedState } from 'components/grid/types'
import type { Filter, SavedState, SupaRow } from 'components/grid/types'
import { Entity, isTableLike } from 'data/table-editor/table-editor-types'
import { BASE_PATH } from 'lib/constants'
import { compact } from 'lodash'
import { useSearchParams } from 'next/navigation'
import { parseAsNativeArrayOf, parseAsString, useQueryStates } from 'nuqs'
import { useEffect, useMemo } from 'react'
import { CalculatedColumn, CellKeyboardEvent } from 'react-data-grid'
import { copyToClipboard } from 'ui'
import { FilterOperatorOptions } from './components/header/filter/Filter.constants'
import { STORAGE_KEY_PREFIX } from './constants'
import type { Sort, SupaColumn, SupaTable } from './types'
import { formatClipboardValue } from './utils/common'
import { QueuedOperation, QueuedOperationType } from '@/state/table-editor-operation-queue.types'
export function formatSortURLParams(tableName: string, sort?: string[]): Sort[] {
if (Array.isArray(sort)) {
@@ -2,10 +2,11 @@ import { useOperationQueueActions } from 'components/grid/hooks/useOperationQueu
import { useOperationQueueShortcuts } from 'components/grid/hooks/useOperationQueueShortcuts'
import { useIsQueueOperationsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { AnimatePresence, motion } from 'framer-motion'
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { useTableEditorStateSnapshot } from 'state/table-editor'
import { Button } from 'ui'
import { useEffect, useRef, useState } from 'react'
import { getModKeyLabel } from '@/lib/helpers'
export const SaveQueueActionBar = () => {
@@ -13,7 +14,6 @@ export const SaveQueueActionBar = () => {
const snap = useTableEditorStateSnapshot()
const isQueueOperationsEnabled = useIsQueueOperationsEnabled()
const { handleSave } = useOperationQueueActions()
const [leftPosition, setLeftPosition] = useState<string>('50%')
useOperationQueueShortcuts()
@@ -24,46 +24,10 @@ export const SaveQueueActionBar = () => {
const isVisible =
isQueueOperationsEnabled && snap.hasPendingOperations && !isOperationQueuePanelOpen
// Center position relative to grid container (viewport alignment)
useEffect(() => {
if (!isVisible) return
const gridContainer = document.querySelector('.sb-grid')
const updatePosition = () => {
if (!gridContainer) {
setLeftPosition('50%')
return
}
const gridRect = gridContainer.getBoundingClientRect()
const gridCenter = gridRect.left + gridRect.width / 2
setLeftPosition(`${gridCenter}px`)
}
updatePosition()
if (!gridContainer) return
const resizeObserver = new ResizeObserver(updatePosition)
resizeObserver.observe(gridContainer)
window.addEventListener('resize', updatePosition)
window.addEventListener('scroll', updatePosition, true)
return () => {
resizeObserver.disconnect()
window.removeEventListener('resize', updatePosition)
window.removeEventListener('scroll', updatePosition, true)
}
}, [isVisible])
const content = (
<AnimatePresence>
{isVisible && (
<div
className="fixed bottom-12 z-50 transform-gpu will-change-transform"
style={{ left: leftPosition, transform: 'translateX(-50%)' }}
>
<div className="fixed bottom-12 z-50 left-1/2 -translate-x-1/2">
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
@@ -1,4 +1,4 @@
import { THRESHOLD_COUNT } from '@supabase/pg-meta/src/sql/studio/get-count-estimate'
import { THRESHOLD_COUNT } from '@supabase/pg-meta'
import { keepPreviousData } from '@tanstack/react-query'
import { useParams } from 'common'
import { useTableFilter } from 'components/grid/hooks/useTableFilter'
@@ -131,9 +131,7 @@ export function useOnRowsChange(rows: SupaRow[]) {
if (isQueueOperationsEnabled) {
queueCellEditWithOptimisticUpdate({
queryClient,
queueOperation: tableEditorSnap.queueOperation,
projectRef: project.ref,
tableId: snap.table.id,
table: snap.originalTable,
row: previousRow,
@@ -167,7 +165,6 @@ export function useOnRowsChange(rows: SupaRow[]) {
snap.originalTable,
snap.table.id,
tableEditorSnap,
queryClient,
]
)
}
@@ -283,7 +283,6 @@ const RowHeader = ({ tableQueriesEnabled = true }: RowHeaderProps) => {
queueRowDeletesWithOptimisticUpdate({
rows,
table: snap.originalTable,
queryClient,
queueOperation: tableEditorSnap.queueOperation,
projectRef: project?.ref,
})
@@ -65,17 +65,19 @@ export const HeaderNew = ({
return (
<div>
<div className="flex flex-wrap min-h-10 items-center bg-dash-sidebar dark:bg-surface-100 px-1.5 py-1.5 gap-2">
<div className="flex flex-wrap min-h-10 items-center bg-dash-sidebar dark:bg-surface-100 py-1.5 gap-2">
{customHeader ? (
customHeader
<div className="flex-1 px-1.5">{customHeader}</div>
) : snap.selectedRows.size > 0 ? (
<RowHeader tableQueriesEnabled={tableQueriesEnabled} />
<div className="flex-1 px-1.5">
<RowHeader tableQueriesEnabled={tableQueriesEnabled} />
</div>
) : (
<div className="flex-1 min-w-[300px] flex items-center gap-2">
<div className="w-full flex items-center gap-2 px-1.5 pb-1.5 border-b border-border">
<FilterPopoverNew isRefetching={isRefetching} />
</div>
)}
<div className="flex items-center gap-2 overflow-x-auto">
<div className="flex items-center gap-2 overflow-x-auto px-1.5">
{!customHeader && snap.selectedRows.size === 0 && (
<SortPopover tableQueriesEnabled={tableQueriesEnabled} />
)}
@@ -275,7 +277,6 @@ const RowHeader = ({ tableQueriesEnabled = true }: RowHeaderProps) => {
queueRowDeletesWithOptimisticUpdate({
rows,
table: snap.originalTable,
queryClient,
queueOperation: tableEditorSnap.queueOperation,
projectRef: project?.ref,
})
@@ -207,7 +207,7 @@ export const FilterPopoverNew = ({ isRefetching = false }: FilterPopoverProps) =
actions={actions}
isLoading={isGenerating}
variant="pill"
className="bg-transparent border-0 overflow-visible"
className="bg-transparent border-0 overflow-visible px-1.5"
icon={icon}
/>
</div>
@@ -1,4 +1,4 @@
import { THRESHOLD_COUNT } from '@supabase/pg-meta/src/query/table-row-query'
import { THRESHOLD_COUNT } from '@supabase/pg-meta'
import { keepPreviousData } from '@tanstack/react-query'
import { useParams } from 'common'
import { useTableFilter } from 'components/grid/hooks/useTableFilter'
@@ -25,7 +25,6 @@ type RowContextMenuProps = {
type RowContextMenuItemProps = ItemParams<{ rowIdx: number }, string>
export const RowContextMenu = ({ rows }: RowContextMenuProps) => {
const queryClient = useQueryClient()
const { data: project } = useSelectedProjectQuery()
const tableEditorSnap = useTableEditorStateSnapshot()
const snap = useTableEditorTableStateSnapshot()
@@ -46,7 +45,6 @@ export const RowContextMenu = ({ rows }: RowContextMenuProps) => {
queueRowDeletesWithOptimisticUpdate({
rows: [row],
table: snap.originalTable,
queryClient,
queueOperation: tableEditorSnap.queueOperation,
projectRef: project?.ref,
})
@@ -1,5 +1,9 @@
import { useQueryClient } from '@tanstack/react-query'
import { useOperationQueueActions } from './useOperationQueueActions'
import { useIsQueueOperationsEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { tableRowKeys } from '@/data/table-rows/keys'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { useHotKey } from '@/hooks/ui/useHotKey'
import { useTableEditorStateSnapshot } from '@/state/table-editor'
@@ -14,6 +18,8 @@ import { useTableEditorStateSnapshot } from '@/state/table-editor'
* before the data grid handles the keyboard event.
*/
export function useOperationQueueShortcuts() {
const queryClient = useQueryClient()
const { data: project } = useSelectedProjectQuery()
const isQueueOperationsEnabled = useIsQueueOperationsEnabled()
const snap = useTableEditorStateSnapshot()
const { handleSave } = useOperationQueueActions()
@@ -43,4 +49,23 @@ export function useOperationQueueShortcuts() {
'.',
{ enabled: isEnabled }
)
useHotKey(
(event) => {
event.preventDefault()
event.stopPropagation()
const tableIdLatestOperation = snap.operationQueue.operations.at(-1)?.tableId
snap.undoLatestOperation()
// Invalidate the query to revert the optimistic update
if (project && tableIdLatestOperation) {
queryClient.invalidateQueries({
queryKey: tableRowKeys.tableRowsAndCount(project.ref, tableIdLatestOperation),
})
}
},
'z',
{ enabled: isEnabled }
)
}
@@ -1,10 +1,17 @@
import { describe, test, expect } from 'vitest'
import { generateTableChangeKey, rowMatchesIdentifiers, applyCellEdit } from './queueOperationUtils'
import { describe, expect, test } from 'vitest'
import type { SupaRow } from '../types'
import {
type NewEditCellContentOperation,
formatGridDataWithOperationValues,
generateTableChangeKey,
rowMatchesIdentifiers,
} from './queueOperationUtils'
import {
QueuedOperationType,
type NewAddRowOperation,
type NewDeleteRowOperation,
QueuedOperationType,
type NewEditCellContentOperation,
type QueuedOperation,
} from '@/state/table-editor-operation-queue.types'
describe('generateTableChangeKey', () => {
@@ -145,101 +152,257 @@ describe('rowMatchesIdentifiers', () => {
})
})
describe('applyCellEdit', () => {
test('should apply cell edit to matching row', () => {
describe('formatGridDataWithOperationValues', () => {
const makeRow = (idx: number, data: Record<string, unknown> = {}): SupaRow => ({
idx,
...data,
})
const makeEditOp = (
overrides: Partial<QueuedOperation & { payload: any }> = {}
): QueuedOperation => ({
id: 'op-1',
tableId: 1,
timestamp: Date.now(),
type: QueuedOperationType.EDIT_CELL_CONTENT,
payload: {
rowIdentifiers: { id: 1 },
columnName: 'name',
oldValue: 'old',
newValue: 'new',
table: {} as any,
},
...overrides,
})
const makeDeleteOp = (
rowIdentifiers: Record<string, unknown>,
originalRow: SupaRow
): QueuedOperation => ({
id: 'op-del',
tableId: 1,
timestamp: Date.now(),
type: QueuedOperationType.DELETE_ROW,
payload: { rowIdentifiers, originalRow, table: {} as any },
})
const makeAddOp = (tempId: string, rowData: Record<string, unknown> = {}): QueuedOperation => ({
id: 'op-add',
tableId: 1,
timestamp: Date.now(),
type: QueuedOperationType.ADD_ROW,
payload: {
tempId,
rowData: { idx: Number(tempId), __tempId: tempId, ...rowData },
table: {} as any,
},
})
test('should return rows unchanged when there are no operations', () => {
const rows = [makeRow(0, { id: 1, name: 'Alice' }), makeRow(1, { id: 2, name: 'Bob' })]
const result = formatGridDataWithOperationValues({ operations: [], rows })
expect(result).toEqual(rows)
})
test('should apply EDIT_CELL_CONTENT to matching row', () => {
const rows = [makeRow(0, { id: 1, name: 'Alice' }), makeRow(1, { id: 2, name: 'Bob' })]
const op = makeEditOp({
payload: {
rowIdentifiers: { id: 1 },
columnName: 'name',
oldValue: 'Alice',
newValue: 'Updated',
table: {} as any,
},
})
const result = formatGridDataWithOperationValues({ operations: [op], rows })
expect(result[0]).toEqual({ idx: 0, id: 1, name: 'Updated' })
expect(result[1]).toEqual(rows[1])
})
test('should not modify rows when EDIT_CELL_CONTENT does not match any row', () => {
const rows = [makeRow(0, { id: 1, name: 'Alice' })]
const op = makeEditOp({
payload: {
rowIdentifiers: { id: 999 },
columnName: 'name',
oldValue: 'old',
newValue: 'new',
table: {} as any,
},
})
const result = formatGridDataWithOperationValues({ operations: [op], rows })
expect(result).toEqual(rows)
})
test('should apply multiple EDIT_CELL_CONTENT operations to different rows', () => {
const rows = [makeRow(0, { id: 1, name: 'Alice' }), makeRow(1, { id: 2, name: 'Bob' })]
const op1 = makeEditOp({
id: 'op-1',
payload: {
rowIdentifiers: { id: 1 },
columnName: 'name',
oldValue: 'Alice',
newValue: 'Updated Alice',
table: {} as any,
},
})
const op2 = makeEditOp({
id: 'op-2',
payload: {
rowIdentifiers: { id: 2 },
columnName: 'name',
oldValue: 'Bob',
newValue: 'Updated Bob',
table: {} as any,
},
})
const result = formatGridDataWithOperationValues({ operations: [op1, op2], rows })
expect(result[0].name).toBe('Updated Alice')
expect(result[1].name).toBe('Updated Bob')
})
test('last edit wins when multiple operations target the same row', () => {
// Each operation spreads from the original row, so only the last op's column change is preserved
const rows = [makeRow(0, { id: 1, name: 'Alice', email: 'alice@test.com' })]
const op1 = makeEditOp({
id: 'op-1',
payload: {
rowIdentifiers: { id: 1 },
columnName: 'name',
oldValue: 'Alice',
newValue: 'Updated',
table: {} as any,
},
})
const op2 = makeEditOp({
id: 'op-2',
payload: {
rowIdentifiers: { id: 1 },
columnName: 'email',
oldValue: 'alice@test.com',
newValue: 'updated@test.com',
table: {} as any,
},
})
const result = formatGridDataWithOperationValues({ operations: [op1, op2], rows })
// The second op overwrites the first since both spread from the original row
expect(result[0].name).toBe('Alice')
expect(result[0].email).toBe('updated@test.com')
})
test('should mark matching row as deleted for DELETE_ROW operation', () => {
const rows = [makeRow(0, { id: 1, name: 'Alice' }), makeRow(1, { id: 2, name: 'Bob' })]
const op = makeDeleteOp({ id: 1 }, rows[0])
const result = formatGridDataWithOperationValues({ operations: [op], rows })
expect(result[0].__isDeleted).toBe(true)
expect(result[1].__isDeleted).toBeUndefined()
})
test('should not modify rows when DELETE_ROW does not match any row', () => {
const rows = [makeRow(0, { id: 1, name: 'Alice' })]
const op = makeDeleteOp({ id: 999 }, makeRow(0, { id: 999 }))
const result = formatGridDataWithOperationValues({ operations: [op], rows })
expect(result[0].__isDeleted).toBeUndefined()
})
test('should not mutate the original rows array', () => {
const rows = [makeRow(0, { id: 1, name: 'Alice' })]
const op = makeEditOp({
payload: {
rowIdentifiers: { id: 1 },
columnName: 'name',
oldValue: 'Alice',
newValue: 'Updated',
table: {} as any,
},
})
const result = formatGridDataWithOperationValues({ operations: [op], rows })
expect(rows[0].name).toBe('Alice')
expect(result[0].name).toBe('Updated')
})
test('should handle mixed operation types', () => {
const rows = [
{ idx: 0, id: 1, name: 'old' },
{ idx: 1, id: 2, name: 'test' },
makeRow(0, { id: 1, name: 'Alice' }),
makeRow(1, { id: 2, name: 'Bob' }),
makeRow(2, { id: 3, name: 'Charlie' }),
]
const result = applyCellEdit(rows, 'name', { id: 1 }, 'new')
expect(result).toEqual([
{ idx: 0, id: 1, name: 'new' },
{ idx: 1, id: 2, name: 'test' },
])
const editOp = makeEditOp({
payload: {
rowIdentifiers: { id: 1 },
columnName: 'name',
oldValue: 'Alice',
newValue: 'Updated Alice',
table: {} as any,
},
})
const deleteOp = makeDeleteOp({ id: 2 }, rows[1])
const result = formatGridDataWithOperationValues({
operations: [editOp, deleteOp],
rows,
})
expect(result[0].name).toBe('Updated Alice')
expect(result[1].__isDeleted).toBe(true)
expect(result[2]).toEqual(rows[2])
})
test('should not affect non-matching rows', () => {
const rows = [
{ idx: 0, id: 1, name: 'old' },
{ idx: 1, id: 2, name: 'test' },
test('should prepend new row for ADD_ROW operation', () => {
const rows = [makeRow(0, { id: 1, name: 'Alice' })]
const op = makeAddOp('-100', { name: 'New Row' })
const result = formatGridDataWithOperationValues({ operations: [op], rows })
expect(result).toHaveLength(2)
expect(result[0]).toMatchObject({ __tempId: '-100', name: 'New Row' })
expect(result[1]).toEqual(rows[0])
})
test('should update existing pending row for ADD_ROW with same tempId', () => {
const rows: SupaRow[] = [
{ idx: -100, __tempId: '-100', name: 'Original' } as any,
makeRow(1, { id: 1, name: 'Alice' }),
]
const result = applyCellEdit(rows, 'name', { id: 3 }, 'new')
expect(result).toEqual([
{ idx: 0, id: 1, name: 'old' },
{ idx: 1, id: 2, name: 'test' },
])
const op = makeAddOp('-100', { name: 'Updated' })
const result = formatGridDataWithOperationValues({ operations: [op], rows })
expect(result).toHaveLength(2)
expect(result[0]).toMatchObject({ __tempId: '-100', name: 'Updated' })
})
test('should create new row instances for matching row', () => {
const rows = [{ idx: 0, id: 1, name: 'old' }]
const result = applyCellEdit(rows, 'name', { id: 1 }, 'new')
expect(result[0]).not.toBe(rows[0])
expect(result[0]).toEqual({ idx: 0, id: 1, name: 'new' })
test('should handle multiple ADD_ROW operations', () => {
const rows = [makeRow(0, { id: 1, name: 'Alice' })]
const op1 = makeAddOp('-100', { name: 'Row 1' })
const op2 = makeAddOp('-200', { name: 'Row 2' })
const result = formatGridDataWithOperationValues({ operations: [op1, op2], rows })
expect(result).toHaveLength(3)
expect(result[0]).toMatchObject({ __tempId: '-200', name: 'Row 2' })
expect(result[1]).toMatchObject({ __tempId: '-100', name: 'Row 1' })
})
test('should not modify original array', () => {
const rows = [{ idx: 0, id: 1, name: 'old' }]
const originalRows = [...rows]
applyCellEdit(rows, 'name', { id: 1 }, 'new')
expect(rows).toEqual(originalRows)
})
test('should handle EDIT_CELL_CONTENT with composite primary keys', () => {
const rows = [makeRow(0, { tenant_id: 'a', user_id: 1, name: 'Alice' })]
const op = makeEditOp({
payload: {
rowIdentifiers: { tenant_id: 'a', user_id: 1 },
columnName: 'name',
oldValue: 'Alice',
newValue: 'Updated',
table: {} as any,
},
})
test('should handle multiple matching rows with composite keys', () => {
const rows = [
{ idx: 0, id: 1, org_id: 10, name: 'old1' },
{ idx: 1, id: 1, org_id: 20, name: 'old2' },
{ idx: 2, id: 2, org_id: 10, name: 'old3' },
]
const result = applyCellEdit(rows, 'name', { id: 1, org_id: 10 }, 'new')
expect(result).toEqual([
{ idx: 0, id: 1, org_id: 10, name: 'new' },
{ idx: 1, id: 1, org_id: 20, name: 'old2' },
{ idx: 2, id: 2, org_id: 10, name: 'old3' },
])
})
test('should handle setting value to null', () => {
const rows = [{ idx: 0, id: 1, name: 'test' }]
const result = applyCellEdit(rows, 'name', { id: 1 }, null)
expect(result).toEqual([{ idx: 0, id: 1, name: null }])
})
test('should handle setting value to undefined', () => {
const rows = [{ idx: 0, id: 1, name: 'test' }]
const result = applyCellEdit(rows, 'name', { id: 1 }, undefined)
expect(result).toEqual([{ idx: 0, id: 1, name: undefined }])
})
test('should handle numeric values', () => {
const rows = [{ idx: 0, id: 1, count: 0 }]
const result = applyCellEdit(rows, 'count', { id: 1 }, 42)
expect(result).toEqual([{ idx: 0, id: 1, count: 42 }])
})
test('should handle object values', () => {
const rows = [{ idx: 0, id: 1, data: null }]
const newValue = { nested: { value: 123 } }
const result = applyCellEdit(rows, 'data', { id: 1 }, newValue)
expect(result).toEqual([{ idx: 0, id: 1, data: newValue }])
})
test('should handle empty rows array', () => {
const rows: any[] = []
const result = applyCellEdit(rows, 'name', { id: 1 }, 'new')
expect(result).toEqual([])
})
test('should update all matching rows with same identifier', () => {
const rows = [
{ idx: 0, id: 1, name: 'row1' },
{ idx: 1, id: 1, name: 'row2' },
{ idx: 2, id: 2, name: 'row3' },
]
const result = applyCellEdit(rows, 'name', { id: 1 }, 'updated')
expect(result).toEqual([
{ idx: 0, id: 1, name: 'updated' },
{ idx: 1, id: 1, name: 'updated' },
{ idx: 2, id: 2, name: 'row3' },
])
const result = formatGridDataWithOperationValues({ operations: [op], rows })
expect(result[0].name).toBe('Updated')
})
})
@@ -1,10 +1,7 @@
import type { QueryClient } from '@tanstack/react-query'
import { isTableLike, type Entity } from 'data/table-editor/table-editor-types'
import { tableRowKeys } from 'data/table-rows/keys'
import type { TableRowsData } from 'data/table-rows/table-rows-query'
import type { Dictionary } from 'types'
import { isPendingAddRow, PendingAddRow, PendingDeleteRow, SupaRow } from '../types'
import { isPendingAddRow, PendingAddRow, SupaRow } from '../types'
import {
EditCellContentOperation,
NewQueuedOperation,
@@ -63,68 +60,12 @@ export function rowMatchesIdentifiers(
return identifierEntries.every(([key, value]) => row[key] === value)
}
export function applyCellEdit(
rows: SupaRow[],
columnName: string,
rowIdentifiers: Dictionary<unknown>,
newValue: unknown
): SupaRow[] {
return rows.map((row) => {
const rowMatches = rowMatchesIdentifiers(row, rowIdentifiers)
if (rowMatches) {
return { ...row, [columnName]: newValue }
}
return row
})
}
export function applyRowAdd(
rows: SupaRow[],
tempId: string,
idx: number,
rowData: Dictionary<unknown>
): (PendingAddRow | SupaRow)[] {
// Check if row with this tempId already exists
const existingIndex = rows.findIndex((row) => isPendingAddRow(row) && row.__tempId === tempId)
if (existingIndex >= 0) {
// Update existing row in place
return rows.map((row, index) => {
if (index === existingIndex) {
return { ...row, ...rowData, __tempId: tempId }
}
return row
})
}
const newRow: PendingAddRow = {
idx,
...rowData,
__tempId: tempId,
}
return [newRow, ...rows]
}
export function markRowAsDeleted(
rows: SupaRow[],
rowIdentifiers: Dictionary<unknown>
): (PendingDeleteRow | SupaRow)[] {
return rows.map((row): PendingDeleteRow | SupaRow => {
const rowMatches = rowMatchesIdentifiers(row, rowIdentifiers)
if (rowMatches) {
return { ...row, __isDeleted: true }
}
return row
})
}
export function removeRow(rows: SupaRow[], rowIdentifiers: Dictionary<unknown>): SupaRow[] {
return rows.filter((row) => !rowMatchesIdentifiers(row, rowIdentifiers))
}
interface QueueCellEditParams {
queryClient: QueryClient
queueOperation: (operation: NewQueuedOperation) => void
projectRef: string
tableId: number
table: Entity
row: SupaRow
@@ -136,9 +77,7 @@ interface QueueCellEditParams {
}
export function queueCellEditWithOptimisticUpdate({
queryClient,
queueOperation,
projectRef,
tableId,
table,
row,
@@ -167,22 +106,10 @@ export function queueCellEditWithOptimisticUpdate({
enumArrayColumns,
},
})
// Apply optimistic update to the UI
const queryKey = tableRowKeys.tableRows(projectRef, { table: { id: tableId } })
queryClient.setQueriesData<TableRowsData>({ queryKey }, (old) => {
if (!old) return old
return {
...old,
rows: applyCellEdit(old.rows, columnName, rowIdentifiers, newValue),
}
})
}
interface QueueRowAddParams {
queryClient: QueryClient
queueOperation: (operation: NewQueuedOperation) => void
projectRef: string
tableId: number
table: Entity
rowData: PendingAddRow
@@ -190,9 +117,7 @@ interface QueueRowAddParams {
}
export function queueRowAddWithOptimisticUpdate({
queryClient,
queueOperation,
projectRef,
tableId,
table,
rowData,
@@ -225,10 +150,56 @@ export function queueRowAddWithOptimisticUpdate({
})
}
export const formatGridDataWithOperationValues = ({
operations,
rows,
}: {
operations: QueuedOperation[]
rows: SupaRow[]
}) => {
const formattedRows = rows.slice()
operations.forEach((op) => {
if (op.type === QueuedOperationType.EDIT_CELL_CONTENT) {
const { rowIdentifiers, columnName, newValue } = op.payload
const rowMatches = rows.find((row) => rowMatchesIdentifiers(row, rowIdentifiers))
if (rowMatches) {
formattedRows[rowMatches.idx] = { ...rowMatches, [columnName]: newValue }
}
} else if (op.type === QueuedOperationType.ADD_ROW) {
const { tempId, rowData } = op.payload
const idx = Number(tempId)
// Check if row with this tempId already exists
const existingIndex = formattedRows.findIndex(
(row) => isPendingAddRow(row) && row.__tempId === tempId
)
if (existingIndex >= 0) {
// Update existing row in place
formattedRows[existingIndex] = {
...formattedRows[existingIndex],
...rowData,
__tempId: tempId,
}
} else {
const newRow: PendingAddRow = { ...rowData, idx, __tempId: tempId }
formattedRows.unshift(newRow)
}
} else if (op.type === QueuedOperationType.DELETE_ROW) {
const { rowIdentifiers } = op.payload
const rowMatches = rows.find((row) => rowMatchesIdentifiers(row, rowIdentifiers))
if (rowMatches) {
formattedRows[rowMatches.idx] = { ...rowMatches, __isDeleted: true }
}
}
})
return formattedRows
}
interface QueueRowDeletesParams {
rows: SupaRow[]
table: Entity
queryClient: QueryClient
queueOperation: (operation: NewQueuedOperation) => void
projectRef: string | undefined
}
@@ -240,7 +211,6 @@ interface QueueRowDeletesParams {
export function queueRowDeletesWithOptimisticUpdate({
rows,
table,
queryClient,
queueOperation,
projectRef,
}: QueueRowDeletesParams): void {
@@ -276,24 +246,5 @@ export function queueRowDeletesWithOptimisticUpdate({
table,
},
})
const queryKey = tableRowKeys.tableRows(projectRef, { table: { id: table.id } })
queryClient.setQueriesData<TableRowsData>({ queryKey }, (old) => {
if (!old) return old
// For pending add rows, remove completely
if (isPendingAddRow(row)) {
return {
...old,
rows: removeRow(old.rows, rowIdentifiers),
}
}
// For existing rows, mark as deleted
return {
...old,
rows: markRowAsDeleted(old.rows, rowIdentifiers),
}
})
}
}
@@ -1,16 +1,15 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { useConsentState } from 'common'
import { LOCAL_STORAGE_KEYS } from 'common/constants/local-storage'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { InlineLink } from 'components/ui/InlineLink'
import { useSendResetMutation } from 'data/telemetry/send-reset-mutation'
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
import { X } from 'lucide-react'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
import * as z from 'zod'
import { useConsentState } from 'common'
import { LOCAL_STORAGE_KEYS } from 'common/constants/local-storage'
import { useSendResetMutation } from 'data/telemetry/send-reset-mutation'
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
import { Alert_Shadcn_, AlertDescription_Shadcn_, AlertTitle_Shadcn_, Badge } from 'ui'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { Card, CardContent, FormControl_Shadcn_, FormField_Shadcn_, Form_Shadcn_, Switch } from 'ui'
import { Card, CardContent, Form_Shadcn_, FormControl_Shadcn_, FormField_Shadcn_, Switch } from 'ui'
import { Admonition } from 'ui-patterns/admonition'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import {
PageSection,
@@ -20,6 +19,7 @@ import {
PageSectionSummary,
PageSectionTitle,
} from 'ui-patterns/PageSection'
import * as z from 'zod'
export const PrivacyUpdateBanner = () => {
const [privacyUpdateAcknowledged, setPrivacyUpdateAcknowledged] = useLocalStorageQuery(
@@ -30,25 +30,20 @@ export const PrivacyUpdateBanner = () => {
if (privacyUpdateAcknowledged) return null
return (
<Alert_Shadcn_ className="mb-4 relative">
<AlertTitle_Shadcn_>
<Badge variant="default" className="mr-2">
NOTICE
</Badge>
Privacy Policy Update – Effective March 16, 2026
</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
We've updated our{' '}
<a href="https://supabase.com/privacy" target="_blank" className="text hover:text-brand">
Privacy Policy
</a>{' '}
to clarify our use of analytics, cookies, and advertising audience management tools. By
continuing to use Supabase after March 16, you agree to the new terms. Questions? Contact{' '}
<a href="mailto:privacy@supabase.com" target="_blank" className="text hover:text-brand">
our team
</a>
.
</AlertDescription_Shadcn_>
<Admonition
type="note"
title="Updates to our privacy policy"
description={
<>
We’ve clarified how we use analytics, cookies, and advertising tools in our{' '}
<InlineLink href="https://supabase.com/privacy">privacy policy</InlineLink>, effective
March 16, 2026. By continuing to use Supabase, you agree to the updated terms.{' '}
<InlineLink href="mailto:privacy@supabase.com">Contact us</InlineLink> with any
questions.{' '}
</>
}
className="mb-10 relative"
>
<ButtonTooltip
type="text"
icon={<X />}
@@ -56,7 +51,7 @@ export const PrivacyUpdateBanner = () => {
onClick={() => setPrivacyUpdateAcknowledged(true)}
tooltip={{ content: { side: 'bottom', text: 'Dismiss' } }}
/>
</Alert_Shadcn_>
</Admonition>
)
}
@@ -1,20 +1,9 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import * as z from 'zod'
import { LOCAL_STORAGE_KEYS } from 'common'
import { SIDEBAR_KEYS } from 'components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
import {
Card,
CardContent,
FormControl_Shadcn_,
FormField_Shadcn_,
Form_Shadcn_,
KeyboardShortcut,
Switch,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { useForm } from 'react-hook-form'
import { Card, Form_Shadcn_ } from 'ui'
import {
PageSection,
PageSectionContent,
@@ -23,11 +12,17 @@ import {
PageSectionSummary,
PageSectionTitle,
} from 'ui-patterns/PageSection'
import * as z from 'zod'
import { HotkeyToggle } from './HotkeyToggle'
const HotkeySchema = z.object({
commandMenuEnabled: z.boolean(),
aiAssistantEnabled: z.boolean(),
inlineEditorEnabled: z.boolean(),
copyMarkdownEnabled: z.boolean(),
copyJsonEnabled: z.boolean(),
downloadCsvEnabled: z.boolean(),
})
export const HotkeySettings = () => {
@@ -43,6 +38,18 @@ export const HotkeySettings = () => {
LOCAL_STORAGE_KEYS.HOTKEY_SIDEBAR(SIDEBAR_KEYS.AI_ASSISTANT),
true
)
const [copyMarkdownEnabled, setCopyMarkdownEnabled] = useLocalStorageQuery(
LOCAL_STORAGE_KEYS.HOTKEY_COPY_MARKDOWN,
true
)
const [copyJsonEnabled, setCopyJsonEnabled] = useLocalStorageQuery(
LOCAL_STORAGE_KEYS.HOTKEY_COPY_JSON,
true
)
const [downloadCsvEnabled, setDownloadCsvEnabled] = useLocalStorageQuery(
LOCAL_STORAGE_KEYS.HOTKEY_DOWNLOAD_CSV,
true
)
const form = useForm<z.infer<typeof HotkeySchema>>({
resolver: zodResolver(HotkeySchema),
@@ -50,6 +57,9 @@ export const HotkeySettings = () => {
commandMenuEnabled: commandMenuEnabled ?? true,
aiAssistantEnabled: aiAssistantEnabled ?? true,
inlineEditorEnabled: inlineEditorEnabled ?? true,
copyMarkdownEnabled: copyMarkdownEnabled ?? true,
copyJsonEnabled: copyJsonEnabled ?? true,
downloadCsvEnabled: downloadCsvEnabled ?? true,
},
})
@@ -66,87 +76,49 @@ export const HotkeySettings = () => {
<PageSectionContent>
<Form_Shadcn_ {...form}>
<Card>
<CardContent className="border-b">
<FormField_Shadcn_
control={form.control}
name="commandMenuEnabled"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label={
<div className="flex items-center gap-x-3">
<KeyboardShortcut keys={['Meta', 'k']} />
<span>Command menu</span>
</div>
}
>
<FormControl_Shadcn_>
<Switch
checked={field.value}
onCheckedChange={(value) => {
field.onChange(value)
setCommandMenuEnabled(value)
}}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</CardContent>
<CardContent className="border-b">
<FormField_Shadcn_
control={form.control}
name="aiAssistantEnabled"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label={
<div className="flex items-center gap-x-3">
<KeyboardShortcut keys={['Meta', 'i']} />
<span>AI Assistant Panel</span>
</div>
}
>
<FormControl_Shadcn_>
<Switch
checked={field.value}
onCheckedChange={(value) => {
field.onChange(value)
setAiAssistantEnabled(value)
}}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</CardContent>
<CardContent>
<FormField_Shadcn_
control={form.control}
name="inlineEditorEnabled"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label={
<div className="flex items-center gap-x-3">
<KeyboardShortcut keys={['Meta', 'e']} />
<span>Inline SQL Editor Panel</span>
</div>
}
>
<FormControl_Shadcn_>
<Switch
checked={field.value}
onCheckedChange={(value) => {
field.onChange(value)
setInlineEditorEnabled(value)
}}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</CardContent>
<HotkeyToggle
form={form}
name="commandMenuEnabled"
keys={['Meta', 'k']}
label="Command menu"
onToggle={setCommandMenuEnabled}
/>
<HotkeyToggle
form={form}
name="aiAssistantEnabled"
keys={['Meta', 'i']}
label="AI Assistant Panel"
onToggle={setAiAssistantEnabled}
/>
<HotkeyToggle
form={form}
name="inlineEditorEnabled"
keys={['Meta', 'e']}
label="Inline SQL Editor Panel"
onToggle={setInlineEditorEnabled}
/>
<HotkeyToggle
form={form}
name="copyMarkdownEnabled"
keys={['Shift', 'Meta', 'm']}
label="Copy results as Markdown"
onToggle={setCopyMarkdownEnabled}
/>
<HotkeyToggle
form={form}
name="copyJsonEnabled"
keys={['Shift', 'Meta', 'j']}
label="Copy results as JSON"
onToggle={setCopyJsonEnabled}
/>
<HotkeyToggle
form={form}
name="downloadCsvEnabled"
keys={['Shift', 'Meta', 'd']}
label="Download results as CSV"
onToggle={setDownloadCsvEnabled}
isLast
/>
</Card>
</Form_Shadcn_>
</PageSectionContent>
@@ -0,0 +1,51 @@
import type { FieldValues, Path, UseFormReturn } from 'react-hook-form'
import { CardContent, FormControl_Shadcn_, FormField_Shadcn_, KeyboardShortcut, Switch } from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
interface HotkeyToggleProps<T extends FieldValues> {
form: UseFormReturn<T>
name: Path<T>
keys: string[]
label: string
onToggle: (value: boolean) => void
isLast?: boolean
}
export function HotkeyToggle<T extends FieldValues>({
form,
name,
keys,
label,
onToggle,
isLast,
}: HotkeyToggleProps<T>) {
return (
<CardContent className={isLast ? undefined : 'border-b'}>
<FormField_Shadcn_
control={form.control}
name={name}
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label={
<div className="flex items-center gap-x-3">
<KeyboardShortcut keys={keys} />
<span>{label}</span>
</div>
}
>
<FormControl_Shadcn_>
<Switch
checked={field.value}
onCheckedChange={(value) => {
field.onChange(value)
onToggle(value)
}}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</CardContent>
)
}
@@ -15,7 +15,7 @@ export type FeaturePreview = {
export const useFeaturePreviews = (): FeaturePreview[] => {
const isUnifiedLogsPreviewAvailable = useFlag('unifiedLogs')
const tableEditorNewFilterBar = useFlag('tableEditorNewFilterBar')
const pgDeltaDiffEnabled = useFlag('pgdeltaDiff')
const showFloatingMobileToolbar = useFlag('enableFloatingMobileToolbar')
const platformWebhooksEnabled = useFlag('platformWebhooks')
@@ -101,7 +101,7 @@ export const useFeaturePreviews = (): FeaturePreview[] => {
enabled: true,
isNew: true,
isPlatformOnly: false,
isDefaultOptIn: tableEditorNewFilterBar,
isDefaultOptIn: true,
},
{
key: LOCAL_STORAGE_KEYS.UI_PREVIEW_FLOATING_MOBILE_TOOLBAR,
@@ -5,7 +5,7 @@ import {
BillingCustomerDataForm,
type BillingCustomerDataFormValues,
} from 'components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerDataForm'
import { TAX_IDS } from 'components/interfaces/Organization/BillingSettings/BillingCustomerData/TaxID.constants'
import { resolveStoredTaxId } from 'components/interfaces/Organization/BillingSettings/BillingCustomerData/TaxID.utils'
import { useBillingCustomerDataForm } from 'components/interfaces/Organization/BillingSettings/BillingCustomerData/useBillingCustomerDataForm'
import { useOrganizationCustomerProfileQuery } from 'data/organizations/organization-customer-profile-query'
import { useOrganizationCustomerProfileUpdateMutation } from 'data/organizations/organization-customer-profile-update-mutation'
@@ -82,9 +82,8 @@ export function UpdateBillingAddressModal() {
tax_id_type: taxId?.type,
tax_id_value: taxId?.value,
tax_id_name: taxId
? TAX_IDS.find(
(option) => option.type === taxId.type && option.countryIso2 === taxId.country
)?.name || ''
? (resolveStoredTaxId(taxId.type, taxId.country, customerProfile?.address?.country)?.name ??
'')
: '',
}),
[customerProfile, taxId]
@@ -2,6 +2,7 @@ import { useParams } from 'common'
import { Edit, MoreVertical, Plus, Search, Trash, X } from 'lucide-react'
import { parseAsBoolean, parseAsStringLiteral, useQueryState } from 'nuqs'
import { useEffect, useMemo, useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import {
Badge,
Button,
@@ -36,8 +37,11 @@ import AlertError from '@/components/ui/AlertError'
import { FilterPopover } from '@/components/ui/FilterPopover'
import { UpgradePlanButton } from '@/components/ui/UpgradePlanButton'
import { useAuthConfigQuery } from '@/data/auth/auth-config-query'
import { useAuthConfigUpdateMutation } from '@/data/auth/auth-config-update-mutation'
import { useOAuthCustomProvidersQuery } from '@/data/oauth-custom-providers/oauth-custom-providers-query'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
import { toast } from 'sonner'
import { Admonition } from 'ui-patterns/admonition'
const CUSTOM_PROVIDERS_SORT_VALUES = [
'name:asc',
@@ -87,6 +91,23 @@ export const CustomAuthProvidersList = () => {
const isCustomProvidersEnabled = !!authConfig?.CUSTOM_OAUTH_ENABLED
const providerLimit = authConfig?.CUSTOM_OAUTH_MAX_PROVIDERS || 0
const queryClient = useQueryClient()
const { mutate: updateAuthConfig, isPending: isEnabling } = useAuthConfigUpdateMutation({
onSuccess: () => {
toast.success('Custom providers have been enabled')
// Invalidate and refetch custom providers query so it retries after enabling
queryClient.invalidateQueries({
queryKey: ['projects', projectRef, 'oauth-custom-providers'],
})
},
})
const handleEnableCustomProviders = () => {
if (projectRef) {
updateAuthConfig({ projectRef, config: { CUSTOM_OAUTH_ENABLED: true } })
}
}
const [selectedProviderToEdit, setSelectedProviderToEdit] = useState<string | null>(null)
const [selectedProviderToDelete, setSelectedProviderToDelete] = useState<string | null>(null)
const [filteredProviderTypes, setFilteredProviderTypes] = useState<string[]>([])
@@ -201,7 +222,44 @@ export const CustomAuthProvidersList = () => {
return <GenericSkeletonLoader />
}
if (!isCustomProvidersEnabled) {
return (
<Admonition
type="default"
title="Custom providers are not enabled"
description="Enable custom OAuth/OIDC providers to configure your own identity providers for authentication."
>
<Button
type="primary"
loading={isEnabling}
disabled={isEnabling}
onClick={handleEnableCustomProviders}
>
Enable Custom Providers
</Button>
</Admonition>
)
}
if (isError) {
if (error?.message?.includes('Custom providers are not enabled')) {
return (
<Admonition
type="default"
title="Custom providers are not enabled"
description="Enable custom OAuth/OIDC providers to configure your own identity providers for authentication."
>
<Button
type="primary"
loading={isEnabling}
disabled={isEnabling}
onClick={handleEnableCustomProviders}
>
Enable Custom Providers
</Button>
</Admonition>
)
}
return <AlertError error={error} subject="Failed to retrieve Custom Auth Providers" />
}
@@ -267,7 +325,18 @@ export const CustomAuthProvidersList = () => {
className="text-xs flex flex-col gap-y-2 bg-alternative items-start"
>
{!isCustomProvidersEnabled ? (
'Custom providers are not enabled for this project. Please contact support to enable this feature.'
<div className="flex flex-col gap-y-2">
<p>Custom providers are not enabled for this project.</p>
<Button
type="primary"
size="tiny"
loading={isEnabling}
disabled={isEnabling}
onClick={handleEnableCustomProviders}
>
Enable Custom Providers
</Button>
</div>
) : (
<>
<p>You've reached the limit of {providerLimit} providers for your plan.</p>
@@ -1,6 +1,7 @@
import { Badge } from 'ui'
import {
PageSection,
PageSectionAside,
PageSectionContent,
PageSectionDescription,
PageSectionMeta,
@@ -8,6 +9,8 @@ import {
PageSectionTitle,
} from 'ui-patterns/PageSection'
import { DocsButton } from 'components/ui/DocsButton'
import { DOCS_URL } from 'lib/constants'
import { CustomAuthProvidersList } from './CustomAuthProvidersList'
export const CustomAuthProviders = () => {
@@ -23,6 +26,9 @@ export const CustomAuthProviders = () => {
Configure OAuth/OIDC providers for this project using your own issuer or endpoints.
</PageSectionDescription>
</PageSectionSummary>
<PageSectionAside>
<DocsButton href={`${DOCS_URL}/guides/auth/custom-oauth-providers`} />
</PageSectionAside>
</PageSectionMeta>
<PageSectionContent>
<CustomAuthProvidersList />
@@ -1,5 +1,4 @@
import { OptimizedSearchColumns } from '@supabase/pg-meta'
import { THRESHOLD_COUNT } from '@supabase/pg-meta/src/sql/studio/get-count-estimate'
import { THRESHOLD_COUNT, type OptimizedSearchColumns } from '@supabase/pg-meta'
import { keepPreviousData } from '@tanstack/react-query'
import { useParams } from 'common'
import { formatEstimatedCount } from 'components/grid/components/footer/pagination/Pagination.utils'
@@ -13,6 +13,10 @@ import {
} from '@stripe/stripe-js'
import { Form } from '@ui/components/shadcn/ui/form'
import { TAX_IDS } from 'components/interfaces/Organization/BillingSettings/BillingCustomerData/TaxID.constants'
import {
resolveStoredTaxId,
getEffectiveTaxCountry,
} from 'components/interfaces/Organization/BillingSettings/BillingCustomerData/TaxID.utils'
import type { CustomerAddress, CustomerTaxId } from 'data/organizations/types'
import { getURL } from 'lib/helpers'
import { Check, ChevronsUpDown } from 'lucide-react'
@@ -96,10 +100,8 @@ export const NewPaymentMethodElement = forwardRef(
resolver: zodResolver(BillingCustomerDataSchema),
defaultValues: {
tax_id_name: currentTaxId
? TAX_IDS.find(
(option) =>
option.type === currentTaxId.type && option.countryIso2 === currentTaxId.country
)?.name || ''
? (resolveStoredTaxId(currentTaxId.type, currentTaxId.country, currentAddress?.country)
?.name ?? '')
: '',
tax_id_type: currentTaxId ? currentTaxId.type : '',
tax_id_value: currentTaxId ? currentTaxId.value : '',
@@ -172,7 +174,7 @@ export const NewPaymentMethodElement = forwardRef(
function getConfiguredTaxId(): CustomerTaxId | null {
return purchasingAsBusiness && selectedTaxId
? {
country: selectedTaxId.countryIso2,
country: getEffectiveTaxCountry(selectedTaxId),
type: selectedTaxId.type,
value: form.getValues('tax_id_value'),
}
@@ -44,8 +44,6 @@ export function ConnectConfigSection({
}: ConnectConfigSectionProps) {
if (activeFields.length === 0) return null
const formLayoutClassName = 'md:[&>div:first-child]:!w-1/3 xl:[&>div:first-child]:!w-2/5'
return (
<div className="flex flex-col gap-y-4">
{activeFields.map((field) => {
@@ -471,7 +471,7 @@ describe('connect.resolver:getDefaultState', () => {
steps: [],
}
const state = getDefaultState(schema)
const state = getDefaultState({ schema })
expect(state.mode).toBe('framework')
})
@@ -501,7 +501,7 @@ describe('connect.resolver:getDefaultState', () => {
steps: [],
}
const state = getDefaultState(schema)
const state = getDefaultState({ schema })
expect(state.framework).toBe('nextjs')
expect(state.library).toBe('supabasejs')
expect(state.mcpReadonly).toBe(false)
@@ -514,7 +514,7 @@ describe('connect.resolver:getDefaultState', () => {
steps: [],
}
const state = getDefaultState(schema)
const state = getDefaultState({ schema })
expect(state.mode).toBe('direct')
})
})
@@ -196,7 +196,7 @@ function resolveFieldOptions(field: { options?: unknown }, state: ConnectState):
/**
* Gets default state for the schema, using first mode and default field values.
*/
export function getDefaultState(schema: ConnectSchema): ConnectState {
export function getDefaultState({ schema }: { schema: ConnectSchema }): ConnectState {
const defaultMode = schema.modes[0]?.id ?? 'direct'
const state: ConnectState = { mode: defaultMode }
@@ -170,6 +170,7 @@ describe('connect.schema:steps resolution', () => {
const steps = resolveSteps(connectSchema, state)
expect(steps.find((s) => s.id === 'shadcn-add')).toBeDefined()
expect(steps.find((s) => s.id === 'shadcn-env')).toBeDefined()
expect(steps.find((s) => s.id === 'shadcn-explore')).toBeDefined()
})
@@ -186,6 +187,7 @@ describe('connect.schema:steps resolution', () => {
const steps = resolveSteps(connectSchema, state)
expect(steps.find((s) => s.id === 'shadcn-add')).toBeDefined()
expect(steps.find((s) => s.id === 'shadcn-env')).toBeDefined()
})
test('should resolve default steps for other frameworks', () => {
@@ -337,6 +339,14 @@ describe('connect.schema:step content paths', () => {
expect(exploreStep?.content).toBe('steps/shadcn/explore')
})
test('shadcn env step should have valid content path', () => {
const state: ConnectState = { mode: 'framework', framework: 'nextjs', frameworkUi: true }
const steps = resolveSteps(connectSchema, state)
const envStep = steps.find((s) => s.id === 'shadcn-env')
expect(envStep?.content).toBe('steps/shadcn/env')
})
test('direct connection step should have valid content path', () => {
const state: ConnectState = { mode: 'direct' }
const steps = resolveSteps(connectSchema, state)
@@ -53,6 +53,13 @@ const frameworkShadcnStep: StepDefinition = {
content: 'steps/shadcn/command',
}
const frameworkShadcnEnvStep: StepDefinition = {
id: 'shadcn-env',
title: 'Set env variables',
description: 'Add the following values to your env file.',
content: 'steps/shadcn/env',
}
const frameworkShadcnExploreStep: StepDefinition = {
id: 'shadcn-explore',
title: 'Check out more UI components',
@@ -173,7 +180,7 @@ export const connectSchema: ConnectSchema = {
id: 'direct',
label: 'Direct',
description: 'Connection string',
fields: ['connectionMethod', 'useSharedPooler', 'connectionType'],
fields: ['connectionSource', 'connectionMethod', 'useSharedPooler', 'connectionType'],
},
{
id: 'orm',
@@ -226,6 +233,13 @@ export const connectSchema: ConnectSchema = {
},
// Direct connection fields
connectionSource: {
id: 'connectionSource',
type: 'select',
label: 'Source',
options: { source: 'connectionSources' },
defaultValue: undefined,
},
connectionMethod: {
id: 'connectionMethod',
type: 'radio-list',
@@ -297,6 +311,7 @@ export const connectSchema: ConnectSchema = {
true: [
frameworkInstallStep,
frameworkShadcnStep,
frameworkShadcnEnvStep,
frameworkShadcnExploreStep,
skillsInstallStep,
],
@@ -308,6 +323,7 @@ export const connectSchema: ConnectSchema = {
true: [
frameworkInstallStep,
frameworkShadcnStep,
frameworkShadcnEnvStep,
frameworkShadcnExploreStep,
skillsInstallStep,
],
@@ -1,7 +1,10 @@
import { useParams } from 'common'
import { useMemo } from 'react'
import { CodeBlock } from 'ui'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import { getConnectionStrings } from '../../../DatabaseSettings.utils'
import { getAddons } from '@/components/interfaces/Billing/Subscription/Subscription.utils'
import {
type ConnectionStringMethod,
type DatabaseConnectionType,
@@ -15,6 +18,12 @@ import {
PASSWORD_PLACEHOLDER,
resolveConnectionString,
} from '@/components/interfaces/ConnectSheet/ConnectionString.utils'
import { usePgbouncerConfigQuery } from '@/data/database/pgbouncer-config-query'
import { useSupavisorConfigurationQuery } from '@/data/database/supavisor-configuration-query'
import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query'
import { useProjectAddonsQuery } from '@/data/subscriptions/project-addons-query'
import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
import { pluckObjectFields } from '@/lib/helpers'
const buildPsqlCommand = (params: { host: string; port: string; database: string; user: string }) =>
`psql -h ${params.host} -p ${params.port} -d ${params.database} -U ${params.user}`
@@ -22,15 +31,93 @@ const buildPsqlCommand = (params: { host: string; port: string; database: string
const buildJdbcString = (params: { host: string; port: string; database: string; user: string }) =>
`jdbc:postgresql://${params.host}:${params.port}/${params.database}?user=${params.user}&password=${PASSWORD_PLACEHOLDER}`
/**
* [Joshen] ConnectStepsSection does something similar but since only this page needs to consider connection strings
* from all databases (including read replicas), am opting to separate the logic for retrieving connection strings here
*
* We can however, consider to shift this logic into ConnectStepsSection, such that we can consider read replicas for
* the other tabs like "Framework" and "ORM" too. However, leaving them out for now and only updating "Direct"
*/
const useConnectionStringDatabases = () => {
const { ref: projectRef } = useParams()
const { hasAccess: allowPgBouncerSelection } = useCheckEntitlements('dedicated_pooler')
const { data: databases = [] } = useReadReplicasQuery({ projectRef })
const { data: pgbouncerConfig } = usePgbouncerConfigQuery({ projectRef })
const { data: supavisorConfig } = useSupavisorConfigurationQuery({ projectRef })
const { data: addons } = useProjectAddonsQuery({ projectRef })
const { ipv4: ipv4Addon } = getAddons(addons?.selected_addons ?? [])
const DB_FIELDS = ['db_host', 'db_name', 'db_port', 'db_user', 'inserted_at']
const emptyState = { db_user: '', db_host: '', db_port: '', db_name: '' }
return Object.fromEntries(
databases.map((db) => {
const connectionInfo = pluckObjectFields(db || emptyState, DB_FIELDS)
const poolingConfigurationShared = supavisorConfig?.find(
(x) => x.identifier === db.identifier
)
const poolingConfigurationDedicated = allowPgBouncerSelection ? pgbouncerConfig : undefined
const connectionStringsShared = getConnectionStrings({
connectionInfo,
poolingInfo: {
connectionString: poolingConfigurationShared?.connection_string ?? '',
db_host: poolingConfigurationShared?.db_host ?? '',
db_name: poolingConfigurationShared?.db_name ?? '',
db_port: poolingConfigurationShared?.db_port ?? 0,
db_user: poolingConfigurationShared?.db_user ?? '',
},
metadata: { projectRef: db.identifier },
})
const connectionStringsDedicated =
poolingConfigurationDedicated !== undefined
? getConnectionStrings({
connectionInfo,
poolingInfo: {
connectionString: poolingConfigurationDedicated.connection_string.replace(
projectRef ?? '_',
db.identifier
),
db_host: poolingConfigurationDedicated.db_host,
db_name: poolingConfigurationDedicated.db_name,
db_port: poolingConfigurationDedicated.db_port,
db_user: poolingConfigurationDedicated.db_user,
},
metadata: { projectRef: db.identifier },
})
: undefined
return [
db.identifier,
{
transactionShared: connectionStringsShared.pooler.uri,
sessionShared: connectionStringsShared.pooler.uri.replace('6543', '5432'),
transactionDedicated: connectionStringsDedicated?.pooler.uri,
sessionDedicated: connectionStringsDedicated?.pooler.uri.replace('6543', '5432'),
ipv4SupportedForDedicatedPooler: !!ipv4Addon,
direct: connectionStringsShared.direct.uri,
},
]
})
)
}
/**
* Step component for direct database connections.
* Uses state to determine which connection string to show.
*/
function DirectConnectionContent({ state, connectionStringPooler }: StepContentProps) {
function DirectConnectionContent({ state }: StepContentProps) {
const connectionSource = state.connectionSource
const connectionType = (state.connectionType as DatabaseConnectionType) ?? 'uri'
const connectionMethod = (state.connectionMethod as ConnectionStringMethod) ?? 'direct'
const useSharedPooler = Boolean(state.useSharedPooler)
const connectionStrings = useConnectionStringDatabases()
const connectionStringPooler =
connectionStrings[connectionSource as keyof typeof connectionStrings]
// Determine which connection string to use
const resolvedConnectionString = useMemo(
() =>
@@ -0,0 +1,52 @@
import { MultipleCodeBlock } from 'ui-patterns/MultipleCodeBlock'
import type { StepContentProps } from '@/components/interfaces/ConnectSheet/Connect.types'
function getEnvFile(
state: StepContentProps['state'],
projectKeys: StepContentProps['projectKeys']
) {
if (state.framework === 'nextjs') {
return {
name: '.env.local',
language: 'bash',
code: [
`NEXT_PUBLIC_SUPABASE_URL=${projectKeys.apiUrl ?? 'your-project-url'}`,
projectKeys.publishableKey
? `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY=${projectKeys.publishableKey}`
: `NEXT_PUBLIC_SUPABASE_ANON_KEY=${projectKeys.anonKey ?? 'your-anon-key'}`,
'',
].join('\n'),
}
}
if (state.framework === 'react') {
const isCreateReactApp = state.frameworkVariant === 'create-react-app'
const envFileName = isCreateReactApp ? '.env.local' : '.env'
const keyPrefix = isCreateReactApp ? 'REACT_APP' : 'VITE'
return {
name: envFileName,
language: 'bash',
code: [
`${keyPrefix}_SUPABASE_URL=${projectKeys.apiUrl ?? 'your-project-url'}`,
projectKeys.publishableKey
? `${keyPrefix}_SUPABASE_PUBLISHABLE_DEFAULT_KEY=${projectKeys.publishableKey}`
: `${keyPrefix}_SUPABASE_ANON_KEY=${projectKeys.anonKey ?? 'your-anon-key'}`,
'',
].join('\n'),
}
}
return null
}
function ShadcnEnvContent({ state, projectKeys }: StepContentProps) {
const envFile = getEnvFile(state, projectKeys)
if (!envFile) return null
return <MultipleCodeBlock files={[envFile]} />
}
export default ShadcnEnvContent
@@ -1,8 +1,16 @@
import { act, renderHook } from '@testing-library/react'
import { describe, expect, test } from 'vitest'
import { describe, expect, test, vi } from 'vitest'
import { useConnectState } from './useConnectState'
vi.mock('common', () => ({
useParams: () => ({ ref: 'test-ref' }),
}))
vi.mock('@/data/read-replicas/replicas-query', () => ({
useReadReplicasQuery: () => ({ data: [] }),
}))
describe('useConnectState', () => {
// ============================================================================
// Initial State Tests
@@ -371,6 +379,7 @@ describe('useConnectState', () => {
const stepIds = result.current.resolvedSteps.map((s) => s.id)
expect(stepIds).toContain('shadcn-add')
expect(stepIds).toContain('shadcn-env')
})
})
@@ -1,3 +1,4 @@
import { useParams } from 'common'
import { useCallback, useMemo, useState } from 'react'
import { FEATURE_GROUPS_PLATFORM, MCP_CLIENTS } from 'ui-patterns/McpUrlBuilder'
@@ -24,6 +25,8 @@ import type {
ResolvedStep,
} from './Connect.types'
import { resolveFrameworkLibraryKey } from './Connect.utils'
import { Database, useReadReplicasQuery } from '@/data/read-replicas/replicas-query'
import { formatDatabaseID, formatDatabaseRegion } from '@/data/read-replicas/replicas.utils'
// ============================================================================
// Data Source Helpers
@@ -33,7 +36,15 @@ import { resolveFrameworkLibraryKey } from './Connect.utils'
* Get field options from a data source reference.
* This maps source names to actual data.
*/
function getFieldOptionsFromSource(source: string, state: ConnectState): FieldOption[] {
function getFieldOptionsFromSource({
source,
state,
databases,
}: {
source: string
state: ConnectState
databases: Database[]
}): FieldOption[] {
switch (source) {
case 'frameworks':
return [...FRAMEWORKS, ...MOBILES].map((f) => ({
@@ -98,6 +109,16 @@ function getFieldOptionsFromSource(source: string, state: ConnectState): FieldOp
description: m.description,
}))
case 'connectionSources':
return databases.map((db) => {
const region = formatDatabaseRegion(db?.region ?? '')
const id = formatDatabaseID(db.identifier ?? '')
const label = db.identifier.includes('-rr-')
? `Read Replica (${region} - ${id}}`
: 'Primary Database'
return { value: db.identifier, label }
})
case 'connectionTypes':
return DATABASE_CONNECTION_TYPES.map((t) => ({
value: t.id,
@@ -133,7 +154,15 @@ function getFieldOptionsFromSource(source: string, state: ConnectState): FieldOp
/**
* Resolve field options, handling both static options and data source references.
*/
function resolveFieldOptionsWithSource(field: ResolvedField, state: ConnectState): FieldOption[] {
function resolveFieldOptionsWithSource({
field,
state,
databases,
}: {
field: ResolvedField
state: ConnectState
databases: Database[]
}): FieldOption[] {
// If already resolved (from conditional resolution)
if (field.resolvedOptions.length > 0) {
return field.resolvedOptions
@@ -142,7 +171,7 @@ function resolveFieldOptionsWithSource(field: ResolvedField, state: ConnectState
// Check if it's a source reference
const options = connectSchema.fields[field.id]?.options
if (options && typeof options === 'object' && 'source' in options) {
return getFieldOptionsFromSource(options.source as string, state)
return getFieldOptionsFromSource({ source: options.source as string, state, databases })
}
return []
@@ -163,8 +192,11 @@ export interface UseConnectStateReturn {
}
export function useConnectState(initialState?: Partial<ConnectState>): UseConnectStateReturn {
const { ref: projectRef } = useParams()
const { data: databases = [] } = useReadReplicasQuery({ projectRef })
const [state, setState] = useState<ConnectState>(() => {
const defaults = getDefaultState(connectSchema)
const defaults = getDefaultState({ schema: connectSchema })
// Set initial framework if mode is framework
if (defaults.mode === 'framework' && !defaults.framework) {
@@ -246,41 +278,45 @@ export function useConnectState(initialState?: Partial<ConnectState>): UseConnec
})
}, [])
const setMode = useCallback((mode: ConnectMode) => {
setState((prev) => {
const next: ConnectState = { ...prev, mode }
const setMode = useCallback(
(mode: ConnectMode) => {
setState((prev) => {
const next: ConnectState = { ...prev, mode }
// Initialize mode-specific defaults
if (mode === 'framework' && !next.framework) {
const firstFramework = FRAMEWORKS[0]
next.framework = firstFramework?.key ?? ''
if (firstFramework?.children?.length > 1) {
next.frameworkVariant = firstFramework.children[0]?.key ?? ''
// Initialize mode-specific defaults
if (mode === 'framework' && !next.framework) {
const firstFramework = FRAMEWORKS[0]
next.framework = firstFramework?.key ?? ''
if (firstFramework?.children?.length > 1) {
next.frameworkVariant = firstFramework.children[0]?.key ?? ''
}
const libraryKey = resolveFrameworkLibraryKey({
framework: next.framework,
frameworkVariant: next.frameworkVariant,
library: next.library,
})
if (libraryKey) next.library = libraryKey
}
const libraryKey = resolveFrameworkLibraryKey({
framework: next.framework,
frameworkVariant: next.frameworkVariant,
library: next.library,
})
if (libraryKey) next.library = libraryKey
}
if (mode === 'direct') {
next.connectionMethod = next.connectionMethod ?? 'direct'
next.connectionType = next.connectionType ?? 'uri'
}
if (mode === 'direct') {
next.connectionMethod = next.connectionMethod ?? 'direct'
next.connectionType = next.connectionType ?? 'uri'
next.connectionSource = projectRef ?? '_'
}
if (mode === 'orm' && !next.orm) {
next.orm = ORMS[0]?.key ?? ''
}
if (mode === 'orm' && !next.orm) {
next.orm = ORMS[0]?.key ?? ''
}
if (mode === 'mcp' && !next.mcpClient) {
next.mcpClient = MCP_CLIENTS[0]?.key ?? ''
}
if (mode === 'mcp' && !next.mcpClient) {
next.mcpClient = MCP_CLIENTS[0]?.key ?? ''
}
return next
})
}, [])
return next
})
},
[projectRef]
)
const activeFields = useMemo(() => getActiveFields(connectSchema, state), [state])
@@ -290,9 +326,9 @@ export function useConnectState(initialState?: Partial<ConnectState>): UseConnec
(fieldId: string): FieldOption[] => {
const field = activeFields.find((f) => f.id === fieldId)
if (!field) return []
return resolveFieldOptionsWithSource(field, state)
return resolveFieldOptionsWithSource({ field, state, databases })
},
[activeFields, state]
[activeFields, state, databases]
)
return {
@@ -1,8 +1,3 @@
import { Check, ChevronsUpDown, Loader2 } from 'lucide-react'
import Link from 'next/link'
import { Fragment, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import CodeEditor from 'components/ui/CodeEditor/CodeEditor'
import { DocsButton } from 'components/ui/DocsButton'
import { useDatabaseIndexCreateMutation } from 'data/database-indexes/index-create-mutation'
@@ -11,32 +6,36 @@ import { useTableColumnsQuery } from 'data/database/table-columns-query'
import { useEntityTypesQuery } from 'data/entity-types/entity-types-infinite-query'
import { useIsOrioleDb, useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { DOCS_URL } from 'lib/constants'
import { Check, ChevronsUpDown, Loader2 } from 'lucide-react'
import Link from 'next/link'
import { Fragment, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import {
Button,
cn,
Command_Shadcn_,
CommandEmpty_Shadcn_,
CommandGroup_Shadcn_,
CommandInput_Shadcn_,
CommandItem_Shadcn_,
CommandList_Shadcn_,
Command_Shadcn_,
Popover_Shadcn_,
PopoverContent_Shadcn_,
PopoverTrigger_Shadcn_,
Popover_Shadcn_,
ScrollArea,
Select_Shadcn_,
SelectContent_Shadcn_,
SelectItem_Shadcn_,
SelectSeparator_Shadcn_,
SelectTrigger_Shadcn_,
SelectValue_Shadcn_,
Select_Shadcn_,
SidePanel,
cn,
} from 'ui'
import { Admonition } from 'ui-patterns'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { MultiSelectOption } from 'ui-patterns/MultiSelectDeprecated'
import { MultiSelectV2 } from 'ui-patterns/MultiSelectDeprecated/MultiSelectV2'
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { INDEX_TYPES } from './Indexes.constants'
interface CreateIndexSidePanelProps {
@@ -54,6 +53,7 @@ export const CreateIndexSidePanel = ({ visible, onClose }: CreateIndexSidePanelP
const [selectedIndexType, setSelectedIndexType] = useState<string>(INDEX_TYPES[0].value)
const [schemaDropdownOpen, setSchemaDropdownOpen] = useState(false)
const [tableDropdownOpen, setTableDropdownOpen] = useState(false)
const [schemaSearchTerm, setSchemaSearchTerm] = useState('')
const [searchTerm, setSearchTerm] = useState('')
const { data: schemas } = useSchemasQuery({
@@ -89,11 +89,17 @@ export const CreateIndexSidePanel = ({ visible, onClose }: CreateIndexSidePanelP
() => entities?.pages.flatMap((page) => page.data.entities) || [],
[entities?.pages]
)
function handleSearchChange(value: string) {
setSearchTerm(value)
}
function handleSchemaSelect(schema: string) {
setSelectedSchema(schema)
setSearchTerm('')
setSchemaSearchTerm('')
setSchemaDropdownOpen(false)
}
const columns = tableColumns?.[0]?.columns ?? []
const columnOptions: MultiSelectOption[] = columns
.filter((column): column is NonNullable<typeof column> => column !== null)
@@ -132,6 +138,8 @@ CREATE INDEX ON "${selectedSchema}"."${selectedEntity}" USING ${selectedIndexTyp
setSelectedEntity('')
setSelectedColumns([])
setSelectedIndexType(INDEX_TYPES[0].value)
setSchemaSearchTerm('')
setSearchTerm('')
}
}, [visible])
@@ -139,6 +147,7 @@ CREATE INDEX ON "${selectedSchema}"."${selectedEntity}" USING ${selectedIndexTyp
setSelectedEntity('')
setSelectedColumns([])
setSelectedIndexType(INDEX_TYPES[0].value)
setSearchTerm('')
}, [selectedSchema])
useEffect(() => {
@@ -146,7 +155,11 @@ CREATE INDEX ON "${selectedSchema}"."${selectedEntity}" USING ${selectedIndexTyp
setSelectedIndexType(INDEX_TYPES[0].value)
}, [selectedEntity])
const isSelectEntityDisabled = entityTypes.length === 0
useEffect(() => {
if (!schemaDropdownOpen) setSchemaSearchTerm('')
}, [schemaDropdownOpen])
const isSelectEntityDisabled = entityTypes.length === 0 && searchTerm.trim().length === 0
return (
<SidePanel
@@ -188,34 +201,33 @@ CREATE INDEX ON "${selectedSchema}"."${selectedEntity}" USING ${selectedIndexTyp
>
<Command_Shadcn_>
<CommandInput_Shadcn_
placeholder="Find table..."
value={searchTerm}
onValueChange={handleSearchChange}
placeholder="Find schema..."
value={schemaSearchTerm}
onValueChange={setSchemaSearchTerm}
/>
<CommandList_Shadcn_>
<CommandList_Shadcn_
className={cn((schemas ?? []).length > 7 && '!max-h-[210px] overflow-y-auto')}
onWheel={(event) => event.stopPropagation()}
>
<CommandEmpty_Shadcn_>No schemas found</CommandEmpty_Shadcn_>
<CommandGroup_Shadcn_>
<ScrollArea className={(schemas || []).length > 7 ? 'h-[210px]' : ''}>
{(schemas ?? []).map((schema) => (
<CommandItem_Shadcn_
key={schema.name}
className="cursor-pointer flex items-center justify-between space-x-2 w-full"
onSelect={() => {
setSelectedSchema(schema.name)
setSchemaDropdownOpen(false)
}}
onClick={() => {
setSelectedSchema(schema.name)
setSchemaDropdownOpen(false)
}}
>
<span>{schema.name}</span>
{selectedEntity === schema.name && (
<Check className="text-brand" strokeWidth={2} size={16} />
)}
</CommandItem_Shadcn_>
))}
</ScrollArea>
{(schemas ?? []).map((schema) => (
<CommandItem_Shadcn_
key={schema.name}
className="cursor-pointer flex items-center justify-between space-x-2 w-full"
onSelect={() => {
handleSchemaSelect(schema.name)
}}
onClick={() => {
handleSchemaSelect(schema.name)
}}
>
<span>{schema.name}</span>
{selectedSchema === schema.name && (
<Check className="text-brand" strokeWidth={2} size={16} />
)}
</CommandItem_Shadcn_>
))}
</CommandGroup_Shadcn_>
</CommandList_Shadcn_>
</Command_Shadcn_>
@@ -274,7 +286,10 @@ CREATE INDEX ON "${selectedSchema}"."${selectedEntity}" USING ${selectedIndexTyp
value={searchTerm}
onValueChange={handleSearchChange}
/>
<CommandList_Shadcn_>
<CommandList_Shadcn_
className={cn(entityTypes.length > 7 && '!max-h-[210px] overflow-y-auto')}
onWheel={(event) => event.stopPropagation()}
>
<CommandEmpty_Shadcn_>
{isLoadingEntities ? (
<div className="flex items-center gap-2 text-center justify-center">
@@ -286,27 +301,25 @@ CREATE INDEX ON "${selectedSchema}"."${selectedEntity}" USING ${selectedIndexTyp
)}
</CommandEmpty_Shadcn_>
<CommandGroup_Shadcn_>
<ScrollArea className={(entityTypes || []).length > 7 ? 'h-[210px]' : ''}>
{(entityTypes ?? []).map((entity) => (
<CommandItem_Shadcn_
key={entity.name}
className="cursor-pointer flex items-center justify-between space-x-2 w-full"
onSelect={() => {
setSelectedEntity(entity.name)
setTableDropdownOpen(false)
}}
onClick={() => {
setSelectedEntity(entity.name)
setTableDropdownOpen(false)
}}
>
<span>{entity.name}</span>
{selectedEntity === entity.name && (
<Check className="text-brand" strokeWidth={2} size={16} />
)}
</CommandItem_Shadcn_>
))}
</ScrollArea>
{entityTypes.map((entity) => (
<CommandItem_Shadcn_
key={entity.name}
className="cursor-pointer flex items-center justify-between space-x-2 w-full"
onSelect={() => {
setSelectedEntity(entity.name)
setTableDropdownOpen(false)
}}
onClick={() => {
setSelectedEntity(entity.name)
setTableDropdownOpen(false)
}}
>
<span>{entity.name}</span>
{selectedEntity === entity.name && (
<Check className="text-brand" strokeWidth={2} size={16} />
)}
</CommandItem_Shadcn_>
))}
</CommandGroup_Shadcn_>
</CommandList_Shadcn_>
</Command_Shadcn_>
@@ -1,7 +1,7 @@
import { useParams } from 'common'
import { AnalyticsBucket, BigQuery, Database } from 'icons'
import { PropsWithChildren, useMemo } from 'react'
import { Handle, Position } from 'reactflow'
import { Handle, Position } from '@xyflow/react'
import { AWS_REGIONS } from 'shared-data'
import { cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
@@ -1,5 +1,5 @@
import dagre from '@dagrejs/dagre'
import { Edge, Node, Position } from 'reactflow'
import { Edge, Node, Position } from '@xyflow/react'
import { NODE_WIDTH } from './Nodes'
@@ -2,7 +2,7 @@ import { useQueryClient } from '@tanstack/react-query'
import { useParams } from 'common'
import { useTheme } from 'next-themes'
import { useEffect, useMemo } from 'react'
import ReactFlow, { Background, ReactFlowProvider, useReactFlow } from 'reactflow'
import { Background, ColorMode, ReactFlow, ReactFlowProvider, useReactFlow } from '@xyflow/react'
import { getStatusName } from '../Pipeline.utils'
import { PrimaryDatabaseNode, ReadReplicaNode, ReplicationNode } from './Nodes'
@@ -14,7 +14,7 @@ import { ReplicationPipelineStatusResponse } from '@/data/replication/pipeline-s
import { useReplicationPipelinesQuery } from '@/data/replication/pipelines-query'
import { timeout } from '@/lib/helpers'
import 'reactflow/dist/style.css'
import '@xyflow/react/dist/style.css'
export const ReplicationDiagram = () => {
return (
@@ -128,6 +128,8 @@ const ReplicationDiagramContent = () => {
return (
<div className="nowheel relative min-h-[350px]">
<ReactFlow
// FIXME: https://github.com/xyflow/xyflow/issues/4876
colorMode={'' as unknown as ColorMode}
fitView
fitViewOptions={{ minZoom: 0.8, maxZoom: 0.9 }}
className="bg"
@@ -137,7 +139,7 @@ const ReplicationDiagramContent = () => {
nodesConnectable={false}
zoomOnDoubleClick={false}
edgesFocusable={false}
edgesUpdatable={false}
edgesReconnectable={false}
defaultNodes={[]}
defaultEdges={[]}
nodeTypes={nodeTypes}
@@ -0,0 +1,202 @@
import {
BaseEdge,
Edge,
EdgeLabelRenderer,
EdgeProps,
getSmoothStepPath,
Position,
useReactFlow,
} from '@xyflow/react'
import { ArrowLeft, ArrowRight } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { Badge, cn } from 'ui'
import { EdgeData } from './Schemas.constants'
import { useQuerySchemaState } from '@/hooks/misc/useSchemaQueryState'
import { useStaticEffectEvent } from '@/hooks/useStaticEffectEvent'
export const DefaultEdge = ({
id,
animated,
data,
deletable,
selectable,
source,
sourceX,
sourceY,
sourceHandleId,
sourcePosition = Position.Bottom,
target,
targetX,
targetY,
targetHandleId,
targetPosition = Position.Top,
selected,
pathOptions,
...props
}: EdgeProps<Edge<EdgeData>>) => {
const [edgePath, labelX, labelY] = getSmoothStepPath({
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
borderRadius: pathOptions?.borderRadius,
offset: pathOptions?.offset,
stepPosition: pathOptions?.stepPosition,
})
return (
<>
<BaseEdge
id={id}
path={edgePath}
className={cn(selected ? '!stroke-brand' : undefined)}
{...props}
/>
{data && selected ? (
<EdgeRelationInfo
source={source}
target={target}
edgePath={edgePath}
labelX={labelX}
labelY={labelY}
sourceX={sourceX}
targetX={targetX}
data={data}
/>
) : null}
</>
)
}
const EdgeRelationInfo = ({
data,
edgePath,
source,
target,
labelX,
labelY,
targetX,
sourceX,
}: {
data: EdgeData
edgePath: string
source: string
target: string
labelX: number
labelY: number
sourceX: number
targetX: number
}) => {
const [show, setShow] = useState(false)
const reactFlowInstance = useReactFlow()
const checkIfShouldBeDisplayed = useStaticEffectEvent(
(relationInfoElement: HTMLDivElement | null) => {
if (!relationInfoElement) return
const sourceNode = reactFlowInstance.getNode(source)
const targetNode = reactFlowInstance.getNode(target)
if (!sourceNode || !targetNode) return
const relationInfoRect = relationInfoElement.getBoundingClientRect()
// Get the origin position of the relation information badge in the ReactFlow coordinates
const relationInfoOriginPositionInReactFlow = reactFlowInstance.screenToFlowPosition({
x: relationInfoRect.x,
y: relationInfoRect.y,
})
// Get the end position (origin + dimensions) of the relation information badge in the ReactFlow coordinates
const relationInfoTargetPositionInReactFlow = reactFlowInstance.screenToFlowPosition({
x: relationInfoRect.x + relationInfoRect.width,
y: relationInfoRect.y + relationInfoRect.height,
})
// Create a ReactFlow Rect from the computed position above
const relationInfoReactFlowRect = {
x: relationInfoOriginPositionInReactFlow.x,
y: relationInfoOriginPositionInReactFlow.y,
width: relationInfoTargetPositionInReactFlow.x - relationInfoOriginPositionInReactFlow.x,
height: relationInfoTargetPositionInReactFlow.y - relationInfoOriginPositionInReactFlow.y,
}
// Check whether the relation information badge is intersecting with either the source or target node
const isNodeIntersectingWithSource = reactFlowInstance.isNodeIntersecting(
sourceNode,
relationInfoReactFlowRect
)
const isNodeIntersectingWithTarget = reactFlowInstance.isNodeIntersecting(
targetNode,
relationInfoReactFlowRect
)
// If it is, hide it as they are too close
setShow(!isNodeIntersectingWithSource && !isNodeIntersectingWithTarget)
}
)
return (
<EdgeLabelRenderer>
<Badge
ref={checkIfShouldBeDisplayed}
className={cn(
'absolute pointer-events-auto z-50 p-1 rounded-[4px] gap-1 outline outline-1 outline-brand',
show ? 'opacity-100' : 'opacity-0'
)}
style={{
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
}}
>
{
// Show the columns in the order of the schema instead of the Postgre relation order
sourceX < targetX ? (
<>
<EdgeNodeData
schema={data.sourceSchemaName}
table={data.sourceName}
column={data.sourceColumnName}
/>
<ArrowRight size={12} />
<EdgeNodeData
schema={data.targetSchemaName}
table={data.targetName}
column={data.targetColumnName}
/>
</>
) : (
<>
<EdgeNodeData
schema={data.targetSchemaName}
table={data.targetName}
column={data.targetColumnName}
/>
<ArrowLeft size={12} />
<EdgeNodeData
schema={data.sourceSchemaName}
table={data.sourceName}
column={data.sourceColumnName}
/>
</>
)
}
</Badge>
</EdgeLabelRenderer>
)
}
const EdgeNodeData = ({
schema,
table,
column,
}: {
schema: string
table: string
column: string
}) => {
const { selectedSchema } = useQuerySchemaState()
return (
<Badge className="normal-case text-[8px]">
{selectedSchema === schema ? '' : `${schema}.`}
{table}.{column}
</Badge>
)
}
@@ -1,13 +1,23 @@
import type { PostgresSchema, PostgresTable } from '@supabase/postgres-meta'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import {
Background,
BackgroundVariant,
ColorMode,
Edge,
MiniMap,
Node,
OnSelectionChangeParams,
ReactFlow,
useReactFlow,
} from '@xyflow/react'
import { toPng, toSvg } from 'html-to-image'
import { Check, Copy, Download, Loader2, Plus } from 'lucide-react'
import { useTheme } from 'next-themes'
import Link from 'next/link'
import { useEffect, useMemo, useRef, useState } from 'react'
import ReactFlow, { Background, BackgroundVariant, MiniMap, useReactFlow } from 'reactflow'
import 'reactflow/dist/style.css'
import '@xyflow/react/dist/style.css'
import { LOCAL_STORAGE_KEYS, useParams } from 'common'
import AlertError from 'components/ui/AlertError'
@@ -34,10 +44,13 @@ import {
import { Admonition } from 'ui-patterns/admonition'
import { SidePanelEditor } from '../../TableGridEditor/SidePanelEditor/SidePanelEditor'
import { DefaultEdge } from './DefaultEdge'
import { SchemaGraphContextProvider, SchemaGraphContextType } from './SchemaGraphContext'
import { SchemaGraphLegend } from './SchemaGraphLegend'
import { EdgeData, TableNodeData } from './Schemas.constants'
import { getGraphDataFromTables, getLayoutedElementsViaDagre } from './Schemas.utils'
import { TableNode } from './SchemaTableNode'
import { useStaticEffectEvent } from '@/hooks/useStaticEffectEvent'
// [Joshen] Persisting logic: Only save positions to local storage WHEN a node is moved OR when explicitly clicked to reset layout
@@ -70,6 +83,12 @@ export const SchemaGraph = () => {
}),
[]
)
const edgeTypes = useMemo(
() => ({
default: DefaultEdge,
}),
[]
)
const {
data: schemas,
@@ -115,14 +134,17 @@ export const SchemaGraph = () => {
const nodes = reactFlowInstance.getNodes()
const edges = reactFlowInstance.getEdges()
getLayoutedElementsViaDagre(nodes, edges)
getLayoutedElementsViaDagre(
nodes.filter((item) => item.type === 'table') as Node<TableNodeData>[],
edges
)
reactFlowInstance.setNodes(nodes)
reactFlowInstance.setEdges(edges)
setTimeout(() => reactFlowInstance.fitView({}))
saveNodePositions()
}
const saveNodePositions = () => {
const saveNodePositions = useStaticEffectEvent(() => {
if (schema === undefined) return console.error('Schema is required')
const nodes = reactFlowInstance.getNodes()
@@ -132,7 +154,18 @@ export const SchemaGraph = () => {
}, {})
setStoredPositions(nodesPositionData)
}
}
})
const [selectedEdge, setSelectedEdge] = useState<Edge | undefined>(undefined)
const handleSelectionChange = useStaticEffectEvent(
(params: OnSelectionChangeParams<Node<TableNodeData>, Edge<EdgeData>>) => {
if (params.edges.length === 1) {
setSelectedEdge(params.edges[0])
return
}
setSelectedEdge(undefined)
}
)
const downloadImage = (format: 'png' | 'svg') => {
const reactflowViewport = document.querySelector('.react-flow__viewport') as HTMLElement
@@ -223,6 +256,7 @@ export const SchemaGraph = () => {
const schemaGraphPanelEditorContext = useMemo<SchemaGraphContextType>(
() => ({
selectedEdge,
isDownloading,
onEditColumn: (tableId, columnId) => {
const table = tables.find((table) => table.id === tableId)
@@ -242,7 +276,7 @@ export const SchemaGraph = () => {
snap.onEditTable()
},
}),
[tables, snap, isDownloading]
[tables, snap, isDownloading, selectedEdge]
)
return (
@@ -368,20 +402,24 @@ export const SchemaGraph = () => {
) : (
<SchemaGraphContextProvider value={schemaGraphPanelEditorContext}>
<div className="w-full h-full">
<ReactFlow
<ReactFlow<Node<TableNodeData>, Edge<EdgeData>>
// FIXME: https://github.com/xyflow/xyflow/issues/4876
colorMode={'' as unknown as ColorMode}
defaultNodes={[]}
defaultEdges={[]}
defaultEdgeOptions={{
type: 'smoothstep',
type: 'default',
animated: true,
deletable: false,
}}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
fitView
minZoom={0.8}
maxZoom={1.8}
proOptions={{ hideAttribution: true }}
onNodeDragStop={() => saveNodePositions()}
onNodeDragStop={saveNodePositions}
onSelectionChange={handleSelectionChange}
>
<Background
gap={16}
@@ -1,7 +1,9 @@
import { Edge } from '@xyflow/react'
import { createContext, useContext, type ReactNode } from 'react'
export type SchemaGraphContextType = {
isDownloading: boolean
selectedEdge: Edge | undefined
onEditColumn: (tableId: number, columnId: string) => void
onEditTable: (tableId: number) => void
}
@@ -1,4 +1,5 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { Handle, Node, NodeProps } from '@xyflow/react'
import { buildTableEditorUrl } from 'components/grid/SupabaseGrid.utils'
import { TableEditor } from 'icons'
import {
@@ -13,7 +14,6 @@ import {
Table2,
} from 'lucide-react'
import { useRouter } from 'next/router'
import { Handle, NodeProps } from 'reactflow'
import {
Button,
cn,
@@ -28,6 +28,7 @@ import {
} from 'ui'
import { useSchemaGraphContext } from './SchemaGraphContext'
import { TableNodeData } from './Schemas.constants'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
@@ -35,30 +36,13 @@ import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
export const TABLE_NODE_WIDTH = 320
export const TABLE_NODE_ROW_HEIGHT = 40
export type TableNodeData = {
id: number
schema: string
name: string
ref?: string
isForeign: boolean
description: string
columns: {
id: string
isPrimary: boolean
isNullable: boolean
isUnique: boolean
isIdentity: boolean
name: string
format: string
}[]
}
export const TableNode = ({
id,
data,
targetPosition,
sourcePosition,
placeholder,
}: NodeProps<TableNodeData> & { placeholder?: boolean }) => {
}: NodeProps<Node<TableNodeData>> & { placeholder?: boolean }) => {
// Important styles is a nasty hack to use Handles (required for edges calculations), but do not show them in the UI.
// ref: https://github.com/wbkd/react-flow/discussions/2698
const hiddenNodeConnector = '!h-px !w-px !min-w-0 !min-h-0 !cursor-grab !border-0 !opacity-0'
@@ -71,10 +55,18 @@ export const TableNode = ({
const router = useRouter()
const itemHeight = 'h-[22px]'
const hasEdgesSelected =
schemaGraphContext.selectedEdge?.source === id || schemaGraphContext.selectedEdge?.target === id
return (
<>
<article>
{data.isForeign ? (
<header className="text-[0.55rem] px-2 py-1 border-[0.5px] rounded-[4px] bg-alternative flex gap-1 items-center">
<header
className={cn(
'text-[0.55rem] px-2 py-1 border-[0.5px] rounded-[4px] bg-alternative flex gap-1 items-center',
hasEdgesSelected ? 'outline outline-1 outline-brand' : undefined
)}
>
{data.name}
{targetPosition && (
<Handle
@@ -87,7 +79,10 @@ export const TableNode = ({
</header>
) : (
<div
className="border-[0.5px] overflow-hidden rounded-[4px] shadow-sm"
className={cn(
'border-[0.5px] overflow-hidden rounded-[4px] shadow-sm',
hasEdgesSelected ? 'outline outline-1 outline-brand' : undefined
)}
style={{ width: TABLE_NODE_WIDTH / 2 }}
>
<header
@@ -216,7 +211,13 @@ export const TableNode = ({
</div>
<div className="flex w-full justify-between min-w-0">
<span
className="text-ellipsis overflow-hidden whitespace-nowrap min-w-0 max-w-[80%]"
className={cn(
'text-ellipsis overflow-hidden whitespace-nowrap min-w-0 max-w-[80%]',
schemaGraphContext.selectedEdge?.sourceHandle === column.id ||
schemaGraphContext.selectedEdge?.targetHandle === column.id
? 'text-brand'
: undefined
)}
title={column.name}
>
{column.name}
@@ -230,7 +231,7 @@ export const TableNode = ({
type="target"
id={column.id}
position={targetPosition}
className={cn(hiddenNodeConnector, '!left-0')}
className={cn(hiddenNodeConnector)}
/>
)}
{sourcePosition && (
@@ -238,7 +239,7 @@ export const TableNode = ({
type="source"
id={column.id}
position={sourcePosition}
className={cn(hiddenNodeConnector, '!right-0')}
className={cn(hiddenNodeConnector)}
/>
)}
<DropdownMenu>
@@ -289,6 +290,6 @@ export const TableNode = ({
))}
</div>
)}
</>
</article>
)
}
@@ -0,0 +1,26 @@
export type TableNodeData = {
id: number
schema: string
name: string
ref?: string
isForeign: boolean
description: string
columns: {
id: string
isPrimary: boolean
isNullable: boolean
isUnique: boolean
isIdentity: boolean
name: string
format: string
}[]
}
export type EdgeData = {
sourceName: string
sourceSchemaName: string
sourceColumnName: string
targetName: string
targetSchemaName: string
targetColumnName: string
}
@@ -1,14 +1,15 @@
import dagre from '@dagrejs/dagre'
import type { PostgresSchema, PostgresTable } from '@supabase/postgres-meta'
import { Edge, Node, Position } from '@xyflow/react'
import { uniqBy } from 'lodash'
import { Edge, Node, Position } from 'reactflow'
import 'reactflow/dist/style.css'
import '@xyflow/react/dist/style.css'
import { LOCAL_STORAGE_KEYS } from 'common'
import { tryParseJson } from 'lib/helpers'
import { TABLE_NODE_ROW_HEIGHT, TABLE_NODE_WIDTH, TableNodeData } from './SchemaTableNode'
import { TableNodeData } from './Schemas.constants'
import { TABLE_NODE_ROW_HEIGHT, TABLE_NODE_WIDTH } from './SchemaTableNode'
const NODE_SEP = 25
const RANK_SEP = 50
@@ -107,6 +108,15 @@ export async function getGraphDataFromTables(
sourceHandle,
target: targetId,
targetHandle: targetId,
deletable: false,
data: {
sourceName: rel.source_table_name,
sourceSchemaName: rel.source_schema,
sourceColumnName: rel.source_column_name,
targetName: rel.target_table_name,
targetSchemaName: rel.target_table_schema,
targetColumnName: rel.target_column_name,
},
})
}
@@ -132,6 +142,15 @@ export async function getGraphDataFromTables(
sourceHandle,
target,
targetHandle,
type: 'default',
data: {
sourceName: rel.source_table_name,
sourceSchemaName: rel.source_schema,
sourceColumnName: rel.source_column_name,
targetName: rel.target_table_name,
targetSchemaName: rel.target_table_schema,
targetColumnName: rel.target_column_name,
},
})
}
}
@@ -163,7 +182,7 @@ function findTablesHandleIds(
return []
}
export const getLayoutedElementsViaDagre = (nodes: Node[], edges: Edge[]) => {
export const getLayoutedElementsViaDagre = (nodes: Node<TableNodeData>[], edges: Edge[]) => {
const dagreGraph = new dagre.graphlib.Graph()
dagreGraph.setDefaultEdgeLabel(() => ({}))
dagreGraph.setGraph({
@@ -204,7 +223,7 @@ export const getLayoutedElementsViaDagre = (nodes: Node[], edges: Edge[]) => {
}
const getLayoutedElementsViaLocalStorage = (
nodes: Node[],
nodes: Node<TableNodeData>[],
edges: Edge[],
positions: { [key: string]: { x: number; y: number } }
) => {
@@ -73,9 +73,11 @@ export const EdgeFunctionsListItem = ({ function: item }: EdgeFunctionsListItemP
</div>
</TableCell>
<TableCell className="hidden 2xl:table-cell whitespace-nowrap">
<p className="text-foreground-light">
{dayjs(item.created_at).format('DD MMM, YYYY HH:mm')}
</p>
<TimestampInfo
className="text-sm text-foreground-light whitespace-nowrap"
utcTimestamp={item.created_at}
label={dayjs(item.created_at).fromNow()}
/>
</TableCell>
<TableCell className="lg:table-cell">
<TimestampInfo
@@ -3,15 +3,16 @@ import { CreateCronJobSheet } from 'components/interfaces/Integrations/CronJobs/
import { CronJob } from 'data/database-cron-jobs/database-cron-jobs-infinite-query'
import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { useInfiniteScroll } from 'hooks/misc/useInfiniteScroll'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { cleanPointerEventsNoneOnBody, isAtBottom } from 'lib/helpers'
import { cleanPointerEventsNoneOnBody } from 'lib/helpers'
import { createNavigationHandler } from 'lib/navigation'
import { isGreaterThanOrEqual } from 'lib/semver'
import { Loader2 } from 'lucide-react'
import { useRouter } from 'next/router'
import { parseAsBoolean, parseAsString, useQueryState } from 'nuqs'
import { MouseEvent, UIEvent, useEffect, useMemo, useRef, useState } from 'react'
import { MouseEvent, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { LoadingLine } from 'ui'
@@ -92,24 +93,12 @@ export const CronjobsTab = () => {
[org?.slug, ref, sendEvent, setCronJobForEditing, setCronJobForDeletion]
)
const xScroll = useRef<number>(0)
const handleScroll = (event: UIEvent<HTMLDivElement>) => {
const isScrollingHorizontally = xScroll.current !== event.currentTarget.scrollLeft
xScroll.current = event.currentTarget.scrollLeft
if (
grid.isLoading ||
grid.isFetchingNextPage ||
isScrollingHorizontally ||
!isAtBottom(event) ||
!grid.hasNextPage
) {
return
}
grid.fetchNextPage()
}
const handleScroll = useInfiniteScroll({
isLoading: grid.isLoading,
isFetchingNextPage: grid.isFetchingNextPage,
hasNextPage: grid.hasNextPage,
fetchNextPage: grid.fetchNextPage,
})
const onOpenCreateJobSheet = () => {
sendEvent({
@@ -1,17 +1,18 @@
import dayjs from 'dayjs'
import { CircleCheck, CircleX, Loader } from 'lucide-react'
import { UIEvent, useCallback, useMemo } from 'react'
import DataGrid, { Column, Row } from 'react-data-grid'
import { useParams } from 'common'
import {
CronJobRun,
useCronJobRunsInfiniteQuery,
} from 'data/database-cron-jobs/database-cron-jobs-runs-infinite-query'
import dayjs from 'dayjs'
import { useInfiniteScroll } from 'hooks/misc/useInfiniteScroll'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { CircleCheck, CircleX, Loader } from 'lucide-react'
import { useMemo } from 'react'
import DataGrid, { Column, Row } from 'react-data-grid'
import { cn, CodeBlock, LoadingLine, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
import { TimestampInfo } from 'ui-patterns'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import { calculateDuration, formatDate } from './CronJobs.utils'
import CronJobsEmptyState from './CronJobsEmptyState'
@@ -146,10 +147,6 @@ const columns = cronJobColumns.map((col) => {
return result
})
function isAtBottom({ currentTarget }: UIEvent<HTMLDivElement>): boolean {
return currentTarget.scrollTop + 10 >= currentTarget.scrollHeight - currentTarget.clientHeight
}
export const PreviousRunsTab = () => {
const { childId } = useParams()
const { data: project } = useSelectedProjectQuery()
@@ -160,6 +157,8 @@ export const PreviousRunsTab = () => {
data,
isPending: isLoadingCronJobRuns,
isFetching,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
} = useCronJobRunsInfiniteQuery(
{
@@ -172,15 +171,12 @@ export const PreviousRunsTab = () => {
const cronJobRuns = useMemo(() => data?.pages.flatMap((p) => p) || [], [data?.pages])
const handleScroll = useCallback(
(event: UIEvent<HTMLDivElement>) => {
if (isLoadingCronJobRuns || !isAtBottom(event)) return
// the cancelRefetch is to prevent the query from being refetched when the user scrolls back up and down again,
// resulting in multiple fetchNextPage calls
fetchNextPage({ cancelRefetch: false })
},
[fetchNextPage, isLoadingCronJobRuns]
)
const handleScroll = useInfiniteScroll({
isLoading: isLoadingCronJobRuns,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
})
return (
<div className="h-full flex flex-col">
@@ -1,10 +1,6 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { QUEUES_SCHEMA } from '@supabase/pg-meta'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
import { z } from 'zod'
import { DocsButton } from 'components/ui/DocsButton'
import { FormHeader } from 'components/ui/Forms/FormHeader'
import {
@@ -16,16 +12,16 @@ import { InlineLink } from 'components/ui/InlineLink'
import { useProjectPostgrestConfigQuery } from 'data/config/project-postgrest-config-query'
import { useProjectPostgrestConfigUpdateMutation } from 'data/config/project-postgrest-config-update-mutation'
import { useQueuesExposePostgrestStatusQuery } from 'data/database-queues/database-queues-expose-postgrest-status-query'
import {
QUEUES_SCHEMA,
useDatabaseQueueToggleExposeMutation,
} from 'data/database-queues/database-queues-toggle-postgrest-mutation'
import { useDatabaseQueueToggleExposeMutation } from 'data/database-queues/database-queues-toggle-postgrest-mutation'
import { useDatabaseQueuesVersionQuery } from 'data/database-queues/database-queues-version-query'
import { useTableUpdateMutation } from 'data/tables/table-update-mutation'
import { useTablesQuery } from 'data/tables/tables-query'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { DOCS_URL, IS_PLATFORM } from 'lib/constants'
import { useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
import {
Button,
Form_Shadcn_,
@@ -37,6 +33,7 @@ import {
import { Admonition } from 'ui-patterns'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { z } from 'zod'
// [Joshen] Not convinced with the UI and layout but getting the functionality out first
@@ -25,7 +25,7 @@ import {
BillingCustomerDataForm,
type BillingCustomerDataFormValues,
} from './BillingCustomerDataForm'
import { TAX_IDS } from './TaxID.constants'
import { resolveStoredTaxId } from './TaxID.utils'
import { useBillingCustomerDataForm } from './useBillingCustomerDataForm'
export const BillingCustomerData = () => {
@@ -66,9 +66,8 @@ export const BillingCustomerData = () => {
tax_id_type: taxId?.type,
tax_id_value: taxId?.value,
tax_id_name: taxId
? TAX_IDS.find(
(option) => option.type === taxId.type && option.countryIso2 === taxId.country
)?.name || ''
? (resolveStoredTaxId(taxId.type, taxId.country, customerProfile?.address?.country)?.name ??
'')
: '',
}),
[customerProfile, taxId]
@@ -5,6 +5,8 @@ export interface TaxId {
countryIso2: string
placeholder: string
vatPrefix?: string
/** Override the country code sent to Orb when it differs from countryIso2 */
taxCountryIso2?: string
}
// Commented out countries are not currently supported by Orb API
@@ -430,6 +432,16 @@ export const TAX_IDS: TaxId[] = [
vatPrefix: 'GB',
countryIso2: 'GB',
},
{
// Isle of Man uses GB VAT numbers, taxCountryIso2 must be GB for Orb validation
name: 'IM VAT',
type: 'gb_vat',
country: 'Isle of Man',
placeholder: 'GB123456789',
vatPrefix: 'GB',
countryIso2: 'IM',
taxCountryIso2: 'GB',
},
{
name: 'GE VAT',
type: 'ge_vat',
@@ -1,4 +1,35 @@
import { TAX_IDS } from './TaxID.constants'
import { TAX_IDS, TaxId } from './TaxID.constants'
/**
* Returns the country code to send to Orb for tax validation.
*
* Most tax IDs use their display country (countryIso2), but some territories
* share another country's tax system — e.g., Isle of Man uses GB VAT.
* In these cases, taxCountryIso2 overrides the country sent to Orb.
*/
export const getEffectiveTaxCountry = (taxId: TaxId): string =>
taxId.taxCountryIso2 ?? taxId.countryIso2
/**
* Resolves a stored tax ID (from Orb) back to its TAX_IDS UI entry.
*
* Uses billingCountry (from the customer's address) to disambiguate when
* multiple entries share the same Orb type+country — e.g., both "UK VAT"
* and "IM VAT" map to {type: 'gb_vat', country: 'GB'} in Orb, but differ
* by billing address country (GB vs IM).
*
* When billingCountry is unavailable, falls back to the stored country.
*/
export const resolveStoredTaxId = (
type: string,
taxCountry: string,
billingCountry?: string
): TaxId | undefined => {
const candidates = TAX_IDS.filter((option) => option.type === type)
const preferredCountry = billingCountry ?? taxCountry
return candidates.find((o) => o.countryIso2 === preferredCountry)
}
export const sanitizeTaxIdValue = (taxId: { name: string; value: string }) => {
const selectedTaxId = TAX_IDS.find((option) => option.name === taxId.name)
@@ -4,7 +4,7 @@ import { useForm } from 'react-hook-form'
import { BillingCustomerDataFormValues, BillingCustomerDataSchema } from './BillingCustomerDataForm'
import { TAX_IDS } from './TaxID.constants'
import { sanitizeTaxIdValue } from './TaxID.utils'
import { getEffectiveTaxCountry, sanitizeTaxIdValue } from './TaxID.utils'
import { components } from 'api-types'
import type { CustomerAddress } from 'data/organizations/types'
@@ -88,7 +88,7 @@ export function useBillingCustomerDataForm({
value: values.tax_id_value,
name: form.getValues().tax_id_name,
}),
country: selectedTaxId?.countryIso2,
country: getEffectiveTaxCountry(selectedTaxId),
}
: null,
})
@@ -0,0 +1,403 @@
export interface Permission {
id: string
label: string
description: string
group: 'organization' | 'project'
}
export const PERMISSIONS: Permission[] = [
// Organization permissions
{
id: 'organizations_read',
label: 'organizations_read',
description: 'View organization details',
group: 'organization',
},
{
id: 'organizations_create',
label: 'organizations_create',
description: 'Create organizations',
group: 'organization',
},
{
id: 'organization_admin_read',
label: 'organization_admin_read',
description: 'View organization admin settings',
group: 'organization',
},
{
id: 'organization_admin_write',
label: 'organization_admin_write',
description: 'Manage organization admin settings',
group: 'organization',
},
{
id: 'members_read',
label: 'members_read',
description: 'View organization members',
group: 'organization',
},
{
id: 'members_write',
label: 'members_write',
description: 'Manage organization members',
group: 'organization',
},
{
id: 'organization_projects_read',
label: 'organization_projects_read',
description: 'View organization projects',
group: 'organization',
},
{
id: 'organization_projects_create',
label: 'organization_projects_create',
description: 'Create projects in organization',
group: 'organization',
},
{
id: 'snippets_read',
label: 'snippets_read',
description: 'View SQL snippets',
group: 'organization',
},
{
id: 'analytics_usage_read',
label: 'analytics_usage_read',
description: 'View usage analytics',
group: 'organization',
},
// Project permissions
{
id: 'projects_read',
label: 'projects_read',
description: 'View project details',
group: 'project',
},
{
id: 'project_admin_read',
label: 'project_admin_read',
description: 'View project admin settings',
group: 'project',
},
{
id: 'project_admin_write',
label: 'project_admin_write',
description: 'Manage project admin settings',
group: 'project',
},
{
id: 'action_runs_read',
label: 'action_runs_read',
description: 'View action runs',
group: 'project',
},
{
id: 'action_runs_write',
label: 'action_runs_write',
description: 'Manage action runs',
group: 'project',
},
{
id: 'advisors_read',
label: 'advisors_read',
description: 'View advisor recommendations',
group: 'project',
},
{
id: 'analytics_logs_read',
label: 'analytics_logs_read',
description: 'View analytics logs',
group: 'project',
},
{
id: 'api_gateway_keys_read',
label: 'api_gateway_keys_read',
description: 'View API gateway keys',
group: 'project',
},
{
id: 'api_gateway_keys_write',
label: 'api_gateway_keys_write',
description: 'Manage API gateway keys',
group: 'project',
},
{
id: 'auth_config_read',
label: 'auth_config_read',
description: 'View auth configuration',
group: 'project',
},
{
id: 'auth_config_write',
label: 'auth_config_write',
description: 'Manage auth settings',
group: 'project',
},
{
id: 'auth_signing_keys_read',
label: 'auth_signing_keys_read',
description: 'View auth signing keys',
group: 'project',
},
{
id: 'auth_signing_keys_write',
label: 'auth_signing_keys_write',
description: 'Manage auth signing keys',
group: 'project',
},
{ id: 'backups_read', label: 'backups_read', description: 'View backups', group: 'project' },
{ id: 'backups_write', label: 'backups_write', description: 'Manage backups', group: 'project' },
{
id: 'branching_development_read',
label: 'branching_development_read',
description: 'View development branches',
group: 'project',
},
{
id: 'branching_development_create',
label: 'branching_development_create',
description: 'Create development branches',
group: 'project',
},
{
id: 'branching_development_write',
label: 'branching_development_write',
description: 'Manage development branches',
group: 'project',
},
{
id: 'branching_development_delete',
label: 'branching_development_delete',
description: 'Delete development branches',
group: 'project',
},
{
id: 'branching_production_read',
label: 'branching_production_read',
description: 'View production branches',
group: 'project',
},
{
id: 'branching_production_create',
label: 'branching_production_create',
description: 'Create production branches',
group: 'project',
},
{
id: 'branching_production_write',
label: 'branching_production_write',
description: 'Manage production branches',
group: 'project',
},
{
id: 'branching_production_delete',
label: 'branching_production_delete',
description: 'Delete production branches',
group: 'project',
},
{
id: 'custom_domain_read',
label: 'custom_domain_read',
description: 'View custom domain settings',
group: 'project',
},
{
id: 'custom_domain_write',
label: 'custom_domain_write',
description: 'Manage custom domain',
group: 'project',
},
{
id: 'data_api_config_read',
label: 'data_api_config_read',
description: 'View Data API configuration',
group: 'project',
},
{
id: 'data_api_config_write',
label: 'data_api_config_write',
description: 'Manage Data API settings',
group: 'project',
},
{
id: 'database_read',
label: 'database_read',
description: 'Read database data',
group: 'project',
},
{
id: 'database_write',
label: 'database_write',
description: 'Write database data',
group: 'project',
},
{
id: 'database_config_read',
label: 'database_config_read',
description: 'View database configuration',
group: 'project',
},
{
id: 'database_config_write',
label: 'database_config_write',
description: 'Manage database settings',
group: 'project',
},
{
id: 'database_migrations_read',
label: 'database_migrations_read',
description: 'View database migrations',
group: 'project',
},
{
id: 'database_migrations_write',
label: 'database_migrations_write',
description: 'Run database migrations',
group: 'project',
},
{
id: 'database_pooling_config_read',
label: 'database_pooling_config_read',
description: 'View connection pooling settings',
group: 'project',
},
{
id: 'database_pooling_config_write',
label: 'database_pooling_config_write',
description: 'Manage connection pooling',
group: 'project',
},
{
id: 'database_ssl_config_read',
label: 'database_ssl_config_read',
description: 'View SSL configuration',
group: 'project',
},
{
id: 'database_ssl_config_write',
label: 'database_ssl_config_write',
description: 'Manage SSL settings',
group: 'project',
},
{
id: 'database_webhooks_config_read',
label: 'database_webhooks_config_read',
description: 'View database webhooks',
group: 'project',
},
{
id: 'database_webhooks_config_write',
label: 'database_webhooks_config_write',
description: 'Manage database webhooks',
group: 'project',
},
{
id: 'edge_functions_read',
label: 'edge_functions_read',
description: 'View Edge Functions',
group: 'project',
},
{
id: 'edge_functions_write',
label: 'edge_functions_write',
description: 'Deploy and manage Edge Functions',
group: 'project',
},
{
id: 'edge_functions_secrets_read',
label: 'edge_functions_secrets_read',
description: 'View Edge Function secrets',
group: 'project',
},
{
id: 'edge_functions_secrets_write',
label: 'edge_functions_secrets_write',
description: 'Manage Edge Function secrets',
group: 'project',
},
{
id: 'infra_add_ons_read',
label: 'infra_add_ons_read',
description: 'View infrastructure add-ons',
group: 'project',
},
{
id: 'infra_add_ons_write',
label: 'infra_add_ons_write',
description: 'Manage infrastructure add-ons',
group: 'project',
},
{
id: 'infra_disk_config_read',
label: 'infra_disk_config_read',
description: 'View disk configuration',
group: 'project',
},
{
id: 'infra_disk_config_write',
label: 'infra_disk_config_write',
description: 'Manage disk settings',
group: 'project',
},
{
id: 'infra_read_replicas_read',
label: 'infra_read_replicas_read',
description: 'View read replicas',
group: 'project',
},
{
id: 'infra_read_replicas_write',
label: 'infra_read_replicas_write',
description: 'Manage read replicas',
group: 'project',
},
{
id: 'project_snippets_read',
label: 'project_snippets_read',
description: 'View project SQL snippets',
group: 'project',
},
{
id: 'project_snippets_write',
label: 'project_snippets_write',
description: 'Manage project SQL snippets',
group: 'project',
},
{
id: 'storage_read',
label: 'storage_read',
description: 'Read storage objects',
group: 'project',
},
{
id: 'storage_write',
label: 'storage_write',
description: 'Write storage objects',
group: 'project',
},
{
id: 'storage_config_read',
label: 'storage_config_read',
description: 'View storage configuration',
group: 'project',
},
{
id: 'storage_config_write',
label: 'storage_config_write',
description: 'Manage storage settings',
group: 'project',
},
{
id: 'vanity_subdomain_read',
label: 'vanity_subdomain_read',
description: 'View vanity subdomain settings',
group: 'project',
},
{
id: 'vanity_subdomain_write',
label: 'vanity_subdomain_write',
description: 'Manage vanity subdomain',
group: 'project',
},
]
@@ -0,0 +1 @@
export type AppsSort = 'created_at:asc' | 'created_at:desc'
@@ -0,0 +1,56 @@
import { describe, expect, it, vi } from 'vitest'
import type { PrivateApp } from '../PrivateApps.types'
import type { AppsSort } from './Apps.types'
import { handleSortChange, sortApps } from './Apps.utils'
describe('handleSortChange', () => {
it('toggles from asc to desc when clicking the same column', () => {
const setSort = vi.fn()
handleSortChange('created_at:asc', 'created_at', setSort)
expect(setSort).toHaveBeenCalledWith('created_at:desc')
})
it('toggles from desc to asc when clicking the same column', () => {
const setSort = vi.fn()
handleSortChange('created_at:desc', 'created_at', setSort)
expect(setSort).toHaveBeenCalledWith('created_at:asc')
})
it('defaults to asc when switching to a different column', () => {
const setSort = vi.fn()
handleSortChange('created_at:desc', 'name', setSort)
expect(setSort).toHaveBeenCalledWith('name:asc')
})
})
const makeApp = (id: string, created_at: string): PrivateApp =>
({
id,
name: id,
created_at,
description: '',
}) as unknown as PrivateApp
describe('sortApps', () => {
const apps = [
makeApp('b', '2024-01-02T00:00:00Z'),
makeApp('a', '2024-01-01T00:00:00Z'),
makeApp('c', '2024-01-03T00:00:00Z'),
]
it('sorts in ascending order', () => {
const sorted = sortApps(apps, 'created_at:asc' as AppsSort)
expect(sorted.map((a) => a.id)).toEqual(['a', 'b', 'c'])
})
it('sorts in descending order', () => {
const sorted = sortApps(apps, 'created_at:desc' as AppsSort)
expect(sorted.map((a) => a.id)).toEqual(['c', 'b', 'a'])
})
it('does not mutate the original array', () => {
const original = [...apps]
sortApps(apps, 'created_at:asc' as AppsSort)
expect(apps).toEqual(original)
})
})
@@ -0,0 +1,23 @@
import type { PrivateApp } from '../PrivateApps.types'
import type { AppsSort } from './Apps.types'
export function handleSortChange(
currentSort: AppsSort,
column: string,
setSort: (s: AppsSort) => void
) {
const [currentCol, currentOrder] = currentSort.split(':')
if (currentCol === column) {
setSort(`${column}:${currentOrder === 'asc' ? 'desc' : 'asc'}` as AppsSort)
} else {
setSort(`${column}:asc` as AppsSort)
}
}
export function sortApps(apps: PrivateApp[], sort: AppsSort): PrivateApp[] {
const [, order] = sort.split(':')
return [...apps].sort((a, b) => {
const diff = new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
return order === 'asc' ? diff : -diff
})
}
@@ -0,0 +1,114 @@
import { usePlatformAppDeleteMutation } from 'data/platform-apps/platform-app-delete-mutation'
import { usePlatformAppInstallationDeleteMutation } from 'data/platform-apps/platform-app-installation-delete-mutation'
import { AppWindow, Plus } from 'lucide-react'
import { useMemo, useState } from 'react'
import { toast } from 'sonner'
import { Button } from 'ui'
import { EmptyStatePresentational } from 'ui-patterns'
import { PrivateApp, usePrivateApps } from '../../PrivateAppsContext'
import { PromoteInstallationModal } from '../../Installations/PromoteInstallationModal'
import type { AppsSort } from '../Apps.types'
import { handleSortChange, sortApps } from '../Apps.utils'
import { DeleteAppModal } from '../DeleteAppModal'
import { ViewAppSheet } from '../ViewAppSheet/ViewAppSheet'
import { AppsListLoading } from './AppsListLoading'
import { AppsListTable } from './AppsListTable'
interface AppsListProps {
onCreateApp: () => void
}
export function AppsList({ onCreateApp }: AppsListProps) {
const { apps, isLoading, slug, installations, removeInstallation, removeInstallationsByAppId } =
usePrivateApps()
const { mutate: deleteApp, isPending: isDeleting } = usePlatformAppDeleteMutation({
onSuccess: (_, vars) => {
toast.success(`Deleted app`)
removeInstallationsByAppId(vars.appId)
if (appToDelete?.id === vars.appId) setAppToDelete(undefined)
},
})
const { mutate: deleteInstallation } = usePlatformAppInstallationDeleteMutation({
onSuccess: (_, vars) => {
removeInstallation(vars.installationId)
if (appToDelete && slug) deleteApp({ slug, appId: appToDelete.id })
},
})
const [sort, setSort] = useState<AppsSort>('created_at:desc')
const [viewApp, setViewApp] = useState<PrivateApp | undefined>()
const [appToDelete, setAppToDelete] = useState<PrivateApp | undefined>()
const [appToPromote, setAppToPromote] = useState<PrivateApp | undefined>()
const onSortChange = (column: string) => handleSortChange(sort, column, setSort)
const sortedApps = useMemo(() => sortApps(apps, sort), [apps, sort])
const installedAppIds = useMemo(
() => new Set(installations.map((i) => i.app_id)),
[installations]
)
const currentInstallation = installations[0]
function handleDelete() {
if (!appToDelete || !slug) return
const installation = installations.find((i) => i.app_id === appToDelete.id)
if (installation) {
deleteInstallation({ slug, installationId: installation.id })
} else {
deleteApp({ slug, appId: appToDelete.id })
}
}
return (
<>
{isLoading ? (
<AppsListLoading />
) : apps.length === 0 ? (
<EmptyStatePresentational
icon={AppWindow}
title="No private apps yet"
description="Create a private app to generate scoped access tokens for your organization."
>
<Button type="primary" icon={<Plus size={14} />} onClick={onCreateApp}>
Create app
</Button>
</EmptyStatePresentational>
) : (
<AppsListTable
sortedApps={sortedApps}
sort={sort}
installedAppIds={installedAppIds}
onSortChange={onSortChange}
onViewApp={setViewApp}
onDeleteApp={setAppToDelete}
onPromoteApp={setAppToPromote}
/>
)}
<ViewAppSheet
app={viewApp ?? null}
visible={viewApp !== undefined}
onClose={() => setViewApp(undefined)}
onDeleted={() => setViewApp(undefined)}
/>
<DeleteAppModal
app={appToDelete ?? null}
visible={appToDelete !== undefined}
onClose={() => setAppToDelete(undefined)}
onConfirm={handleDelete}
isLoading={isDeleting}
/>
<PromoteInstallationModal
appToPromote={appToPromote}
currentInstallation={currentInstallation}
onClose={() => setAppToPromote(undefined)}
onSuccess={() => setAppToPromote(undefined)}
/>
</>
)
}
@@ -0,0 +1,30 @@
import { Card, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from 'ui'
export function AppsListLoading() {
return (
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead className="max-w-xs">Name</TableHead>
<TableHead className="w-48">Created</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{Array.from({ length: 3 }).map((_, i) => (
<TableRow key={i}>
<TableCell>
<div className="h-4 w-32 bg-surface-300 rounded animate-pulse" />
</TableCell>
<TableCell>
<div className="h-4 w-24 bg-surface-300 rounded animate-pulse" />
</TableCell>
<TableCell />
</TableRow>
))}
</TableBody>
</Table>
</Card>
)
}
@@ -0,0 +1,113 @@
import dayjs from 'dayjs'
import { ArrowUpCircle, Key, MoreVertical, Trash } from 'lucide-react'
import {
Badge,
Button,
Card,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableHeadSort,
TableRow,
} from 'ui'
import { TimestampInfo } from 'ui-patterns/TimestampInfo'
import type { PrivateApp } from '../../PrivateAppsContext'
import type { AppsSort } from '../Apps.types'
interface AppsListTableProps {
sortedApps: PrivateApp[]
sort: AppsSort
installedAppIds: Set<string>
onSortChange: (column: string) => void
onViewApp: (app: PrivateApp) => void
onDeleteApp: (app: PrivateApp) => void
onPromoteApp: (app: PrivateApp) => void
}
export function AppsListTable({
sortedApps,
sort,
installedAppIds,
onSortChange,
onViewApp,
onDeleteApp,
onPromoteApp,
}: AppsListTableProps) {
return (
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead className="w-48">
<TableHeadSort column="created_at" currentSort={sort} onSortChange={onSortChange}>
Created
</TableHeadSort>
</TableHead>
<TableHead className="w-10" />
</TableRow>
</TableHeader>
<TableBody>
{sortedApps.map((app) => {
const isInstalled = installedAppIds.has(app.id)
return (
<TableRow key={app.id}>
<TableCell>
<div className="flex items-center gap-2">
<p className="text-left max-w-[48ch] truncate">{app.name}</p>
{isInstalled && (
<Badge variant="success" className="uppercase">
Installed
</Badge>
)}
</div>
</TableCell>
<TableCell>
<TimestampInfo
utcTimestamp={app.created_at}
label={dayjs(app.created_at).fromNow()}
className="text-sm text-foreground-light whitespace-nowrap"
/>
</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="default" icon={<MoreVertical size={14} />} className="w-7" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="bottom" className="w-44">
<DropdownMenuItem className="gap-x-2" onClick={() => onViewApp(app)}>
<Key size={14} />
View permissions
</DropdownMenuItem>
{!isInstalled && (
<DropdownMenuItem className="gap-x-2" onClick={() => onPromoteApp(app)}>
<ArrowUpCircle size={14} />
Promote to installed
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
className="!text-destructive gap-x-2"
onClick={() => onDeleteApp(app)}
>
<Trash size={14} />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</Card>
)
}
@@ -0,0 +1,424 @@
import type { components } from 'api-types'
import { usePlatformAppCreateMutation } from 'data/platform-apps/platform-app-create-mutation'
import { usePlatformAppDeleteMutation } from 'data/platform-apps/platform-app-delete-mutation'
import { usePlatformAppInstallationCreateMutation } from 'data/platform-apps/platform-app-installation-create-mutation'
import { usePlatformAppSigningKeyCreateMutation } from 'data/platform-apps/platform-app-signing-key-create-mutation'
import { useCopyToClipboard } from 'hooks/ui/useCopyToClipboard'
import { Copy, Download, Key, Plus, RotateCcw, X } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import {
Button,
Checkbox_Shadcn_,
Command_Shadcn_,
CommandEmpty_Shadcn_,
CommandGroup_Shadcn_,
CommandInput_Shadcn_,
CommandItem_Shadcn_,
CommandList_Shadcn_,
Input_Shadcn_,
Popover_Shadcn_,
PopoverContent_Shadcn_,
PopoverTrigger_Shadcn_,
ScrollArea,
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
WarningIcon,
} from 'ui'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { FormLayout } from 'ui-patterns/form/Layout/FormLayout'
import { usePrivateApps } from '../../PrivateAppsContext'
import { PERMISSIONS } from '../Apps.constants'
import { CreateAppSheetStep } from './CreateAppSheetStep'
type CreatePlatformAppResponse = components['schemas']['CreatePlatformAppResponse']
type CreatePlatformAppSigningKeyResponse =
components['schemas']['CreatePlatformAppSigningKeyResponse']
interface CreateAppSheetProps {
visible: boolean
onClose: () => void
onCreated: (app: CreatePlatformAppResponse) => void
}
export function CreateAppSheet({ visible, onClose, onCreated }: CreateAppSheetProps) {
const { slug, installations, addInstallation } = usePrivateApps()
const { copy } = useCopyToClipboard()
const [name, setName] = useState('')
const [selectedPermissions, setSelectedPermissions] = useState<string[]>([])
const [permissionSearchOpen, setPermissionSearchOpen] = useState(false)
const [createdApp, setCreatedApp] = useState<CreatePlatformAppResponse | null>(null)
const [generatedKey, setGeneratedKey] = useState<CreatePlatformAppSigningKeyResponse | null>(null)
const [keyCopied, setKeyCopied] = useState(false)
const [showCloseConfirm, setShowCloseConfirm] = useState(false)
const step3Ref = useRef<HTMLDivElement>(null)
const { mutate: deleteApp, isPending: isDeleting } = usePlatformAppDeleteMutation({
onSuccess: () => handleClose(),
})
const { mutate: installApp } = usePlatformAppInstallationCreateMutation({
onSuccess: (data) => {
if (data) addInstallation(data, 'all')
},
})
const { mutate: createSigningKey, isPending: isCreatingKey } =
usePlatformAppSigningKeyCreateMutation({
onSuccess: (keyData, vars) => {
if (keyData) setGeneratedKey(keyData)
if (installations.length === 0 && slug) {
installApp({ slug, app_id: vars.appId })
}
},
})
const { mutate: createApp, isPending: isCreatingApp } = usePlatformAppCreateMutation({
onSuccess: (data) => {
toast.success(`App "${data.name}" created`)
setCreatedApp(data)
createSigningKey({ slug: slug!, appId: data.id })
},
})
function reset() {
setName('')
setSelectedPermissions([])
setPermissionSearchOpen(false)
setCreatedApp(null)
setGeneratedKey(null)
setKeyCopied(false)
}
function handleClose() {
reset()
onClose()
}
function handleRequestClose() {
setShowCloseConfirm(true)
}
function handleCreate() {
if (!slug) return
createApp({
slug,
name: name.trim(),
permissions:
selectedPermissions as components['schemas']['CreatePlatformAppBody']['permissions'],
})
}
function toggle(id: string) {
setSelectedPermissions((prev) =>
prev.includes(id) ? prev.filter((p) => p !== id) : [...prev, id]
)
}
const canCreate = name.trim().length > 0 && selectedPermissions.length > 0
const isLoading = isCreatingApp || isCreatingKey || isDeleting
const keyRevealed = generatedKey !== null
useEffect(() => {
if (generatedKey) {
step3Ref.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
}, [generatedKey])
return (
<>
<Sheet
open={visible}
onOpenChange={(open) => {
if (!open) handleRequestClose()
}}
>
<SheetContent
showClose={false}
size="default"
className="!min-w-[600px] flex flex-col h-full gap-0"
>
<SheetHeader>
<SheetTitle>Create private app</SheetTitle>
<SheetDescription className="sr-only">
Create a private app to generate scoped access tokens for your organization.
</SheetDescription>
</SheetHeader>
<ScrollArea className="flex-1 max-h-[calc(100vh-116px)]">
<div className="px-5 sm:px-6 py-6">
<CreateAppSheetStep
number={1}
title="App details"
description="The first app you create will be automatically installed."
disabled={keyRevealed}
>
<FormLayout label="Name" id="app-name">
<Input_Shadcn_
id="app-name"
placeholder="My integration"
value={name}
onChange={(e) => setName(e.target.value)}
disabled={isLoading}
/>
</FormLayout>
</CreateAppSheetStep>
<CreateAppSheetStep
number={2}
title="Permissions"
description="Select the permissions this app requires."
disabled={keyRevealed}
>
<div className="space-y-4">
<div className="flex justify-between items-center">
<span className="text-sm">Configure permissions</span>
<div className="flex items-center gap-2">
{selectedPermissions.length > 0 && (
<Button
type="default"
size="tiny"
className="p-1"
icon={<RotateCcw size={16} />}
onClick={() => setSelectedPermissions([])}
disabled={isLoading}
/>
)}
<Popover_Shadcn_
open={permissionSearchOpen}
onOpenChange={setPermissionSearchOpen}
modal
>
<PopoverTrigger_Shadcn_ asChild>
<Button
type="default"
size="tiny"
icon={<Plus size={14} />}
disabled={isLoading}
>
Add permission
</Button>
</PopoverTrigger_Shadcn_>
<PopoverContent_Shadcn_ className="w-[400px] p-0" align="end">
<Command_Shadcn_>
<CommandInput_Shadcn_ placeholder="Search permissions..." />
<CommandList_Shadcn_>
<CommandEmpty_Shadcn_>No permissions found.</CommandEmpty_Shadcn_>
<CommandGroup_Shadcn_ className="[&>div]:text-left">
<div className="max-h-[210px] overflow-y-auto">
{PERMISSIONS.filter((p) => p.group === 'project').map((perm) => (
<CommandItem_Shadcn_
key={perm.id}
value={`${perm.id} ${perm.label}`}
onSelect={() => toggle(perm.id)}
className="text-foreground"
>
<div className="flex items-center gap-3 w-full">
<Checkbox_Shadcn_
checked={selectedPermissions.includes(perm.id)}
onCheckedChange={() => toggle(perm.id)}
onClick={(e) => e.stopPropagation()}
/>
<Key size={12} className="text-foreground-lighter" />
<span className="font-medium text-foreground">
{perm.label}
</span>
</div>
</CommandItem_Shadcn_>
))}
</div>
</CommandGroup_Shadcn_>
</CommandList_Shadcn_>
</Command_Shadcn_>
</PopoverContent_Shadcn_>
</Popover_Shadcn_>
</div>
</div>
{selectedPermissions.length === 0 ? (
<div className="text-center py-8 border border-dashed border-border rounded-lg">
<p className="text-sm text-foreground-light">
No permissions configured yet.
</p>
</div>
) : (
<div className="border border-border rounded-lg">
{selectedPermissions.map((id, index) => {
const perm = PERMISSIONS.find((p) => p.id === id)
return (
<div key={id}>
<div className="flex items-center gap-3 p-3">
<div className="flex-1">
<p className="text-sm font-medium">{perm?.label}</p>
{perm?.description && (
<p className="text-xs text-foreground-lighter">
{perm.description}
</p>
)}
</div>
<Button
type="text"
size="tiny"
className="p-1"
icon={<X size={16} />}
disabled={isLoading}
onClick={() =>
setSelectedPermissions((prev) => prev.filter((p) => p !== id))
}
/>
</div>
{index < selectedPermissions.length - 1 && (
<div className="border-t border-border" />
)}
</div>
)
})}
</div>
)}
<div className="w-full flex gap-x-2 items-center">
<WarningIcon />
<span className="text-xs text-foreground-lighter">
Once you've set these permissions, you cannot edit them.
</span>
</div>
</div>
</CreateAppSheetStep>
<div ref={step3Ref}>
<CreateAppSheetStep
number={3}
title="Signing key"
description={
isCreatingKey
? 'Generating your signing key...'
: keyRevealed
? 'This is the only time you can view this key. Copy or download it and store it securely.'
: "A signing key will be generated automatically when you create the app. You'll only be able to view it once."
}
isLast
disabled={createdApp === null && !isLoading}
>
{isCreatingKey && (
<p className="text-sm text-foreground-light animate-pulse">
Generating signing key...
</p>
)}
{keyRevealed && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium">Private key</h3>
<div className="flex items-center gap-2">
<Button
type="default"
size="tiny"
icon={<Copy size={12} />}
onClick={() => copy(generatedKey.private_key, { withToast: true })}
>
Copy
</Button>
<Button
type="default"
size="tiny"
icon={<Download size={12} />}
onClick={() => {
const blob = new Blob([generatedKey.private_key], {
type: 'text/plain',
})
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${createdApp?.name.toLowerCase().replace(/\s+/g, '-')}-private-key.pem`
a.click()
URL.revokeObjectURL(url)
}}
>
Download
</Button>
</div>
</div>
<textarea
readOnly
value={generatedKey.private_key}
rows={8}
className="w-full rounded-md border border-control bg-surface-200 px-3 py-2 text-xs font-mono resize-none focus:outline-none"
/>
<label className="flex items-center gap-3 cursor-pointer bg-warning-200 border border-warning-400 rounded-md px-3 py-2">
<Checkbox_Shadcn_
id="key-copied"
checked={keyCopied}
onCheckedChange={(v) => setKeyCopied(Boolean(v))}
/>
<span className="text-sm text-warning cursor-pointer select-none">
I have copied the key and stored it securely
</span>
</label>
</div>
)}
</CreateAppSheetStep>
</div>
</div>
</ScrollArea>
<SheetFooter className="!justify-end w-full mt-auto py-4 border-t">
<div className="flex gap-2">
<Button type="default" onClick={handleRequestClose} disabled={isLoading}>
Cancel
</Button>
{keyRevealed ? (
<Button
type="primary"
disabled={!keyCopied}
onClick={() => {
onCreated(createdApp!)
handleClose()
}}
>
Done
</Button>
) : (
<Button
type="primary"
disabled={!canCreate}
loading={isLoading}
onClick={handleCreate}
>
Create app
</Button>
)}
</div>
</SheetFooter>
</SheetContent>
</Sheet>
<ConfirmationModal
variant="destructive"
visible={showCloseConfirm}
title="Close without saving?"
confirmLabel="Close"
confirmLabelLoading="Closing..."
onCancel={() => setShowCloseConfirm(false)}
onConfirm={() => {
setShowCloseConfirm(false)
if (createdApp && slug) {
deleteApp({ slug, appId: createdApp.id })
} else {
handleClose()
}
}}
>
<p className="text-sm text-foreground-light py-2">
{createdApp
? 'The app will be deleted and your signing key will be permanently lost.'
: 'Any progress you have made will be lost.'}
</p>
</ConfirmationModal>
</>
)
}
@@ -0,0 +1,43 @@
import { PropsWithChildren } from 'react'
interface StepProps {
number: number
title: string
description: string
isLast?: boolean
disabled?: boolean
}
export function CreateAppSheetStep({
number,
title,
description,
isLast = false,
disabled = false,
children,
}: PropsWithChildren<StepProps>) {
return (
<div
className={`flex items-start gap-6 self-stretch transition-opacity ${disabled ? 'opacity-40 pointer-events-none' : ''}`}
>
<div className="relative self-stretch shrink-0 w-6">
<div className="absolute inset-0 flex items-start justify-center">
{!isLast && (
<div
aria-hidden
className="absolute left-[calc(50%-1px)] w-px bg-border opacity-60 h-full"
/>
)}
<div className="relative z-10 flex font-mono text-xs items-center justify-center min-w-6 w-6 h-6 border border-default rounded-md bg-surface-100 text-foreground-light">
{number}
</div>
</div>
</div>
<div className={`w-full ${isLast ? '' : 'pb-10'}`}>
<p className="text-sm font-medium text-foreground mb-1">{title}</p>
<p className="text-sm text-foreground-light mb-4">{description}</p>
{children}
</div>
</div>
)
}
Loaded 100 of 278 files, more files were not shown because too many files have changed in this diff. Show more