diff --git a/.cursor/rules/studio-ui.mdc b/.cursor/rules/studio-ui.mdc index 67db62a0ba1f6..c6f4888152547 100644 --- a/.cursor/rules/studio-ui.mdc +++ b/.cursor/rules/studio-ui.mdc @@ -128,6 +128,7 @@ export const MyPageComponent = () => ( - Use our `_Shadcn_` form primitives from `ui` and prefer `FormItemLayout` with layout="flex-row-reverse" for most controls (see `apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GitHubIntegrationConnectionForm.tsx`). - Keep imports from `ui` with `_Shadcn_` suffixes. - Forms should generally be wrapped in a Card unless specified +- If the submit button is outside the form, add a new variable named formId outside the component, and set it as property id on the form element and formId on the button. ### Example (single field) @@ -143,6 +144,8 @@ const profileSchema = z.object({ username: z.string().min(2, 'Username must be at least 2 characters'), }) +const formId = `profile-form` + export function ProfileForm() { const form = useForm>({ resolver: zodResolver(profileSchema), @@ -200,6 +203,11 @@ export function ProfileForm() { - Use a sheet when needing to reveal more complicated forms or information relating to an object and context switching away to a new page would be disruptive e.g. we list auth providers, clicking an auth provider opens a sheet with information about that provider and a form to enable, user can close sheet to go back to providers list +## React Query + +- When doing a mutation, always use the mutate function. Always use onSuccess and onError with a toast.success and toast.error. +- Use mutateAsync only if the mutation is part of multiple async actions. Wrap the mutateAsync call with try/catch block and add toast.success and toast.error. + ## Tables - Use the generic ui table components for most tables diff --git a/.github/workflows/docs-sync-auto-troubleshooting.yml b/.github/workflows/docs-sync-auto-troubleshooting.yml new file mode 100644 index 0000000000000..e439c1649eeb3 --- /dev/null +++ b/.github/workflows/docs-sync-auto-troubleshooting.yml @@ -0,0 +1,72 @@ +# Sync AI-generated troubleshooting guides from supabase/troubleshooting + +name: Sync from supabase/troubleshooting + +on: + repository_dispatch: + types: [sync_from_upstream] + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + sync: + runs-on: ubuntu-latest + + steps: + - name: Checkout supabase/supabase + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + persist-credentials: true + + - name: Decode the GitHub App Private Key + id: decode + run: | + private_key=$(echo "${{ secrets.DOCS_GITHUB_APP_PRIVATE_KEY }}" | base64 --decode | awk 'BEGIN {ORS="\\n"} {print}' | head -c -2) &> /dev/null + echo "::add-mask::$private_key" + echo "private-key=$private_key" >> "$GITHUB_OUTPUT" + + - name: Create GitHub App token for supabase/troubleshooting + id: app-token + uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4 + with: + app-id: ${{ vars.DOCS_GITHUB_APP_ID }} + private-key: ${{ steps.decode.outputs.private-key }} + repositories: troubleshooting + permission-contents: read + + - name: Checkout supabase/troubleshooting + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + persist-credentials: false + token: ${{ steps.app-token.outputs.token }} + repository: supabase/troubleshooting + path: troubleshooting-upstream + + - name: Sync supabase/troubleshooting changes back to supabase/supabase + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + git config user.name 'github-docs-bot' + git config user.email 'github-docs-bot@supabase.com' + BRANCH_NAME="bot/sync-troubleshooting" + EXISTING_BRANCH=$(git ls-remote --heads origin $BRANCH_NAME) + if [[ -n "$EXISTING_BRANCH" ]]; then + git push origin --delete $BRANCH_NAME + fi + git checkout -b $BRANCH_NAME + rsync --archive --verbose --ignore-existing ./troubleshooting-upstream/guides/ ./apps/docs/content/troubleshooting/ + git add apps/docs/content/troubleshooting/ + if git diff --quiet --cached; then + echo "No changes to sync" + exit 0 + fi + git commit --message "Sync from supabase/troubleshooting" + git push origin $BRANCH_NAME + if gh pr list --state open --head $BRANCH_NAME --json number --jq '.[0].number' | grep -q .; then + gh pr comment "$BRANCH_NAME" --body "Updated troubleshooting sync with latest changes." + else + gh pr create --title "[bot] Sync from supabase/troubleshooting" --body "This PR syncs the latest troubleshooting guides from the supabase/troubleshooting repository." --head $BRANCH_NAME + fi diff --git a/apps/docs/app/contributing/ContributingToC.tsx b/apps/docs/app/contributing/ContributingToC.tsx index a80468337505b..12b19d20dfc0f 100644 --- a/apps/docs/app/contributing/ContributingToC.tsx +++ b/apps/docs/app/contributing/ContributingToC.tsx @@ -14,7 +14,7 @@ interface TocItem extends HTMLAttributes { } export function ContributingToc({ className }: { className?: string }) { - const mobileToc = useBreakpoint('xl') + const mobileToc = useBreakpoint('lg') const [tocItems, setTocItems] = useState>([]) useEffect(() => { diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts index 9fa8cfc9a37cd..e40c690b7e931 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts @@ -697,6 +697,7 @@ export const auth: NavMenuConstant = { name: 'Sessions', url: '/guides/auth/sessions', items: [ + { name: 'User sessions', url: '/guides/auth/sessions' }, { name: 'Implicit flow', url: '/guides/auth/sessions/implicit-flow' }, { name: 'PKCE flow', url: '/guides/auth/sessions/pkce-flow' }, ], @@ -711,6 +712,7 @@ export const auth: NavMenuConstant = { name: 'Server-Side Rendering', url: '/guides/auth/server-side', items: [ + { name: 'Overview', url: '/guides/auth/server-side' }, { name: 'Next.js guide', url: '/guides/auth/server-side/nextjs' }, { name: 'SvelteKit guide', @@ -746,7 +748,7 @@ export const auth: NavMenuConstant = { { name: 'Social Login (OAuth)', url: '/guides/auth/social-login', - items: [...SocialLoginItems], + items: [{ name: 'Overview', url: '/guides/auth/social-login' }, ...SocialLoginItems], enabled: allAuthProvidersEnabled, }, @@ -755,6 +757,7 @@ export const auth: NavMenuConstant = { url: '/guides/auth/enterprise-sso', enabled: allAuthProvidersEnabled, items: [ + { name: 'Overview', url: '/guides/auth/enterprise-sso' }, { name: 'SAML 2.0', url: '/guides/auth/enterprise-sso/auth-sso-saml' as `/${string}`, @@ -781,6 +784,7 @@ export const auth: NavMenuConstant = { name: 'Multi-Factor Authentication', url: '/guides/auth/auth-mfa', items: [ + { name: 'Overview', url: '/guides/auth/auth-mfa' }, { name: 'App Authenticator (TOTP)', url: '/guides/auth/auth-mfa/totp' }, { name: 'Phone', url: '/guides/auth/auth-mfa/phone' }, ], @@ -831,6 +835,7 @@ export const auth: NavMenuConstant = { name: 'Auth Hooks', url: '/guides/auth/auth-hooks', items: [ + { name: 'Overview', url: '/guides/auth/auth-hooks' }, { name: 'Custom access token hook', url: '/guides/auth/auth-hooks/custom-access-token-hook' as `/${string}`, @@ -880,7 +885,10 @@ export const auth: NavMenuConstant = { name: 'JSON Web Tokens (JWT)', url: '/guides/auth/jwts', enabled: authFullSecurityEnabled, - items: [{ name: 'Claims Reference', url: '/guides/auth/jwt-fields' }], + items: [ + { name: 'Overview', url: '/guides/auth/jwts' }, + { name: 'Claims Reference', url: '/guides/auth/jwt-fields' }, + ], }, { name: 'JWT Signing Keys', @@ -921,6 +929,7 @@ const ormQuickstarts: NavMenuSection = { name: 'Prisma', url: '/guides/database/prisma', items: [ + { name: 'Connecting with Prisma', url: '/guides/database/prisma' as `/${string}` }, { name: 'Prisma troubleshooting', url: '/guides/database/prisma/prisma-troubleshooting' as `/${string}`, @@ -2008,7 +2017,13 @@ export const ai: NavMenuConstant = { { name: 'Vector indexes', url: '/guides/ai/vector-indexes', - items: vectorIndexItems, + items: [ + { + name: 'Overview', + url: '/guides/ai/vector-indexes', + }, + ...vectorIndexItems, + ], }, { name: 'Automatic embeddings', @@ -2355,6 +2370,10 @@ export const platform: NavMenuConstant = { name: 'Migrating within Supabase', url: '/guides/platform/migrating-within-supabase', items: [ + { + name: 'Overview', + url: '/guides/platform/migrating-within-supabase' as `/${string}`, + }, { name: 'Restore Dashboard backup', url: '/guides/platform/migrating-within-supabase/dashboard-restore' as `/${string}`, @@ -2368,7 +2387,10 @@ export const platform: NavMenuConstant = { { name: 'Migrating to Supabase', url: '/guides/platform/migrating-to-supabase', - items: MIGRATION_PAGES, + items: [ + { name: 'Overview', url: '/guides/platform/migrating-to-supabase' as `/${string}` }, + ...MIGRATION_PAGES, + ], }, ], }, @@ -2385,6 +2407,10 @@ export const platform: NavMenuConstant = { url: '/guides/platform/multi-factor-authentication', enabled: fullPlatformEnabled, items: [ + { + name: 'Overview', + url: '/guides/platform/multi-factor-authentication' as `/${string}`, + }, { name: 'Enforce MFA on organization', url: '/guides/platform/mfa/org-mfa-enforcement' as `/${string}`, @@ -2405,6 +2431,7 @@ export const platform: NavMenuConstant = { url: '/guides/platform/sso', enabled: fullPlatformEnabled, items: [ + { name: 'Overview', url: '/guides/platform/sso' as `/${string}` }, { name: 'SSO with Azure AD', url: '/guides/platform/sso/azure' }, { name: 'SSO with Google Workspace', @@ -2463,8 +2490,12 @@ export const platform: NavMenuConstant = { }, { name: 'Manage your usage', - url: '/guides/platform/manage-your-usage' as `/${string}`, + url: '/guides/platform/manage-your-usage', items: [ + { + name: 'Overview', + url: '/guides/platform/manage-your-usage' as `/${string}`, + }, { name: 'Compute', url: '/guides/platform/manage-your-usage/compute' as `/${string}`, @@ -2563,6 +2594,10 @@ export const platform: NavMenuConstant = { name: 'AWS Marketplace', url: '/guides/platform/aws-marketplace', items: [ + { + name: 'Overview', + url: '/guides/platform/aws-marketplace' as `/${string}`, + }, { name: 'Getting Started', url: '/guides/platform/aws-marketplace/getting-started', @@ -2804,6 +2839,10 @@ export const integrations: NavMenuConstant = { name: 'Build a Supabase integration', url: '/guides/integrations/build-a-supabase-integration', items: [ + { + name: 'Overview', + url: '/guides/integrations/build-a-supabase-integration' as `/${string}`, + }, { name: 'OAuth scopes', url: '/guides/integrations/build-a-supabase-integration/oauth-scopes' as `/${string}`, diff --git a/apps/docs/content/_partials/oauth_pkce_flow.mdx b/apps/docs/content/_partials/oauth_pkce_flow.mdx index f1035c0893708..cb538ad043758 100644 --- a/apps/docs/content/_partials/oauth_pkce_flow.mdx +++ b/apps/docs/content/_partials/oauth_pkce_flow.mdx @@ -123,12 +123,12 @@ export const GET = async (event) => { if (code) { const { error } = await supabase.auth.exchangeCodeForSession(code) if (!error) { - throw redirect(303, `/${next.slice(1)}`); + redirect(303, `/${next.slice(1)}`); } } // return the user to an error page with instructions - throw redirect(303, '/auth/auth-code-error'); + redirect(303, '/auth/auth-code-error'); }; ``` diff --git a/apps/docs/content/guides/getting-started/quickstarts/sveltekit.mdx b/apps/docs/content/guides/getting-started/quickstarts/sveltekit.mdx index 82061a1386828..f22ecc0563408 100644 --- a/apps/docs/content/guides/getting-started/quickstarts/sveltekit.mdx +++ b/apps/docs/content/guides/getting-started/quickstarts/sveltekit.mdx @@ -67,8 +67,8 @@ hideToc: true <$CodeTabs> ```text name=.env - VITE_PUBLIC_SUPABASE_URL= - VITE_PUBLIC_SUPABASE_PUBLISHABLE_KEY= + PUBLIC_VITE_SUPABASE_URL= + PUBLIC_VITE_SUPABASE_PUBLISHABLE_KEY= ``` @@ -90,16 +90,16 @@ hideToc: true ```js name=src/lib/supabaseClient.js import { createClient } from '@supabase/supabase-js'; - import { VITE_PUBLIC_SUPABASE_URL, VITE_PUBLIC_SUPABASE_PUBLISHABLE_KEY } from '$env/static/public'; + import { PUBLIC_VITE_SUPABASE_URL, PUBLIC_VITE_SUPABASE_PUBLISHABLE_KEY } from '$env/static/public'; - export const supabase = createClient(VITE_PUBLIC_SUPABASE_URL, VITE_PUBLIC_SUPABASE_PUBLISHABLE_KEY) + export const supabase = createClient(PUBLIC_VITE_SUPABASE_URL, PUBLIC_VITE_SUPABASE_PUBLISHABLE_KEY) ``` ```ts name=src/lib/supabaseClient.ts import { createClient } from '@supabase/supabase-js'; - import { VITE_PUBLIC_SUPABASE_URL, VITE_PUBLIC_SUPABASE_PUBLISHABLE_KEY } from '$env/static/public'; + import { PUBLIC_VITE_SUPABASE_URL, PUBLIC_VITE_SUPABASE_PUBLISHABLE_KEY } from '$env/static/public'; - export const supabase = createClient(VITE_PUBLIC_SUPABASE_URL, VITE_PUBLIC_SUPABASE_PUBLISHABLE_KEY) + export const supabase = createClient(PUBLIC_VITE_SUPABASE_URL, PUBLIC_VITE_SUPABASE_PUBLISHABLE_KEY) ``` diff --git a/apps/docs/content/guides/local-development/cli/testing-and-linting.mdx b/apps/docs/content/guides/local-development/cli/testing-and-linting.mdx index 0d0abc5f6414c..5246f956484f6 100644 --- a/apps/docs/content/guides/local-development/cli/testing-and-linting.mdx +++ b/apps/docs/content/guides/local-development/cli/testing-and-linting.mdx @@ -28,7 +28,7 @@ Our friends at [Basejump](https://usebasejump.com/) have created a useful set of ### Running database tests in CI -Use our GitHub Action to [automate your database tests](/docs/guides/cli/github-action/testing#testing-your-database). +Use our GitHub Action to [automate your database tests](/docs/guides/deployment/ci/testing). ## Testing your Edge Functions diff --git a/apps/docs/content/troubleshooting/cant-access-supabase-project-lovable-cloud.mdx b/apps/docs/content/troubleshooting/cant-access-supabase-project-lovable-cloud.mdx new file mode 100644 index 0000000000000..3e2140e23b865 --- /dev/null +++ b/apps/docs/content/troubleshooting/cant-access-supabase-project-lovable-cloud.mdx @@ -0,0 +1,131 @@ +--- +title = "Can’t Access Supabase Project When Using Lovable Cloud" +topics = [ +"ai", +] +keywords = [ +"lovable", +"lovable cloud" +] +--- + +## Problem + +Your Lovable project has **Lovable Cloud** enabled, and you’re trying to connect directly to your Supabase project. + +However, you don’t see the project listed on your [Supabase Dashboard](/dashboard), or you’re unable to access it through your Supabase account. + +## Why this occurs + +When you create a project connected to **Lovable Cloud**, the underlying Supabase instance is provisioned and managed entirely by **Lovable**. + +These projects are not owned by your Supabase account, so they **don't appear on your Supabase Dashboard** or be accessible using your Supabase credentials. + +## How to diagnose + +You might be affected by this if: + +- You recently created a project on Lovable and enabled the backend from the chat interface. By default, the Lovable platform uses **Lovable Cloud** for backend hosting. +- You see Supabase references in your project’s configuration but **can’t access the project** from your [Supabase Dashboard](/dashboard). +- The project ID or credentials used in your Lovable setup don’t match any project under your Supabase account. + +## How to fix + +There is **no automated way** to transfer a Supabase project from Lovable Cloud to your own Supabase account. + +However, you can manually clone your project backend and migrate your data following Lovable’s official guide: [Self-Hosting Guide (Lovable Docs)](https://docs.lovable.dev/tips-tricks/self-hosting) + +This process allows you to create a new Supabase project under your own account and connect it independently. + + + +There are certain limitations described in Lovable’s documentation, so review the guide carefully before proceeding. + + + +## How to prevent + +If you want direct access to your Supabase project from the start, make sure to: + +- Avoid enabling Lovable Cloud if you plan to manage your Supabase project independently. +- Create your project directly from the [Supabase Dashboard](/dashboard) and then **manually connect your Lovable project to your Supabase project**, or **start your Lovable project already connected** to your own Supabase project from the beginning. + +## Additional resources + +For more information, read [the Lovable Cloud FAQ](https://docs.lovable.dev/features/cloud#faq) + +## Frequently asked questions + + +
+ + + No. Projects running on **Lovable Cloud** use a managed Supabase backend that doesn't provide direct SQL editor access through [supabase.com/dashboard](/dashboard). + + + +
+ +
+ + + Lovable Cloud projects are owned and managed by **Lovable**, not by your personal Supabase account. They won't show up on your dashboard. + + + +
+ +
+ + + Once Lovable Cloud is enabled for a project, it **cannot be disconnected or switched** to an external Supabase connection. + + However, you can **create a new Supabase project under your own account** and migrate your data by following Lovable's [Project Migration Guide](https://docs.lovable.dev/tips-tricks/self-hosting). + + + +
+ +
+ + + External connections are **not supported** when your project runs on Lovable Cloud. + + + +
+ +
+ + + Service role and API keys are **not accessible** for Lovable Cloud–managed projects. + + If you need to integrate with external automation or API-based tools, you'll need to **create and manage your own Supabase project**, then connect Lovable to it directly. + + + +
+ +
diff --git a/apps/docs/content/troubleshooting/identify-lovable-cloud-or-supabase-backend.mdx b/apps/docs/content/troubleshooting/identify-lovable-cloud-or-supabase-backend.mdx new file mode 100644 index 0000000000000..ce4b64857fe09 --- /dev/null +++ b/apps/docs/content/troubleshooting/identify-lovable-cloud-or-supabase-backend.mdx @@ -0,0 +1,145 @@ +--- +title = "Identifying Lovable backend: Lovable Cloud or Supabase" +topics = [ +"ai", +] +keywords = [ +"lovable", +"lovable cloud" +] +--- + +## Overview + +When you create a project in **Lovable**, your app’s backend can be connected to one of two options: + +- **Lovable Cloud** — The backend (Supabase instance) is managed entirely by **Lovable**. +- **Your own Supabase project** — The backend is a Supabase project you created and manage directly in your [Supabase Dashboard](/dashboard). + +Knowing which backend your project uses helps you understand where your database, API keys, and configuration are managed. + +## How to identify your backend + +In the Lovable interface, click the **Cloud icon** on the top bar of your project editor. This section shows which backend your project is currently connected to. + +![Lovable Cloud icon inside Lovable](/docs/img/troubleshooting/identify-lovable-cloud-or-supabase-backend-0.png) + + + +The Cloud icon appears for all projects, regardless of which backend type you're using. Click it to see your actual backend configuration. + + + +Once you open the Cloud settings, you see one of two interfaces depending on your backend type: + +### Lovable Cloud backend + +If the page shows a menu and screens to manage your Database tables, and Users from within Lovable (similar to the screenshot below), then your backend is managed by **Lovable Cloud**. + +![Lovable Cloud backend settings inside Lovable](/docs/img/troubleshooting/identify-lovable-cloud-or-supabase-backend-1.png) + +Your Supabase instance in this case is **owned and managed by Lovable**, not by your personal Supabase account. +You won’t see this project in your Supabase Dashboard, and you won’t have access to service role keys or direct database URLs. + +### Connected Supabase backend + +If the page displays the Supabase icon, your Supabase project name, and some links that direct you to the Supabase Dashboard (similar to the screenshot below), then your Lovable project is connected to a **Supabase project you manage directly**. + +![Supabase backend settings inside Lovable](/docs/img/troubleshooting/identify-lovable-cloud-or-supabase-backend-2.png) + +## Summary + +| Backend Type | Managed By | Appears in Supabase Dashboard | Service Role / DB Access | +| ------------------------ | ---------- | ----------------------------- | ------------------------ | +| **Lovable Cloud** | Lovable | ❌ No | ❌ No | +| **Own Supabase Project** | You | ✅ Yes | ✅ Yes | + +## Frequently asked questions + + +
+ + + No. Lovable Cloud only affects your **backend connection** (database and APIs), not your app hosting or frontend. + + + +
+ +
+ + + No. Lovable Cloud backend is managed entirely by Lovable, and connection credentials are not exposed. + + + +
+ +
+ + + There's no automated way to transfer your backend. You can create a new Supabase project and connect your Lovable app to it manually. + + See [Lovable's Project Migration Guide](https://docs.lovable.dev/tips-tricks/self-hosting) for detailed steps. + + + +
+
+ +## Lovable Cloud – specific questions + + +
+ + + There's no automated way to transfer your backend from Lovable Cloud to your own Supabase account. + + You can create a new Supabase project and migrate your data manually. + + See [Can't Access Your Supabase Project When Using Lovable Cloud](/docs/guides/troubleshooting/cant-access-supabase-project-lovable-cloud) for more details. + + + +
+ +
+ + + That's expected — Lovable Cloud backend is managed by Lovable and doesn't appear in your Supabase Dashboard. + + Learn more in [Can't Access Your Supabase Project When Using Lovable Cloud](/docs/guides/troubleshooting/cant-access-supabase-project-lovable-cloud). + + + +
+
diff --git a/apps/docs/features/docs/__snapshots__/Reference.typeSpec.test.ts.snap b/apps/docs/features/docs/__snapshots__/Reference.typeSpec.test.ts.snap index 00fecd84440a7..3ef80a4ab5102 100644 --- a/apps/docs/features/docs/__snapshots__/Reference.typeSpec.test.ts.snap +++ b/apps/docs/features/docs/__snapshots__/Reference.typeSpec.test.ts.snap @@ -42109,7 +42109,7 @@ exports[`TS type spec parsing > matches snapshot 1`] = ` } }, "comment": { - "shortText": "Generates email links and OTPs to be sent via a custom email provider." + "shortText": "Generates email links and OTPs. This will not send links or OTPs to the end user. This function is for custom admin functionality." } }, "@supabase/auth-js.GoTrueAdminApi.getUserById": { diff --git a/apps/docs/public/humans.txt b/apps/docs/public/humans.txt index e24faa90c4600..f907179f8664b 100644 --- a/apps/docs/public/humans.txt +++ b/apps/docs/public/humans.txt @@ -74,12 +74,14 @@ Ignacio Dobronich Illia Basalaiev Inian P Ivan Vasilov +Jason Farber Jean-Paul Argudo Jeff Smick Jenny Kibiri Jess Shears Jim Chanco Jr John Pena +John Schaeffer Jon M Jonny Summers-Muir Jordi Enric @@ -135,6 +137,7 @@ Sam Meech-Ward Sam Rome Sam Rose Sean Oliver +Sean Thompson Sergio Cioban Filho Sreyas Udayavarman Stanislav M diff --git a/apps/docs/public/img/troubleshooting/identify-lovable-cloud-or-supabase-backend-0.png b/apps/docs/public/img/troubleshooting/identify-lovable-cloud-or-supabase-backend-0.png new file mode 100644 index 0000000000000..5fbc641c57015 Binary files /dev/null and b/apps/docs/public/img/troubleshooting/identify-lovable-cloud-or-supabase-backend-0.png differ diff --git a/apps/docs/public/img/troubleshooting/identify-lovable-cloud-or-supabase-backend-1.png b/apps/docs/public/img/troubleshooting/identify-lovable-cloud-or-supabase-backend-1.png new file mode 100644 index 0000000000000..c2eea9a364dc3 Binary files /dev/null and b/apps/docs/public/img/troubleshooting/identify-lovable-cloud-or-supabase-backend-1.png differ diff --git a/apps/docs/public/img/troubleshooting/identify-lovable-cloud-or-supabase-backend-2.png b/apps/docs/public/img/troubleshooting/identify-lovable-cloud-or-supabase-backend-2.png new file mode 100644 index 0000000000000..68fcfa2c9b521 Binary files /dev/null and b/apps/docs/public/img/troubleshooting/identify-lovable-cloud-or-supabase-backend-2.png differ diff --git a/apps/docs/spec/enrichments/tsdoc_v2/combined.json b/apps/docs/spec/enrichments/tsdoc_v2/combined.json index 7c2277b16e822..0961a32b0a737 100644 --- a/apps/docs/spec/enrichments/tsdoc_v2/combined.json +++ b/apps/docs/spec/enrichments/tsdoc_v2/combined.json @@ -65068,7 +65068,7 @@ "summary": [ { "kind": "text", - "text": "Generates email links and OTPs to be sent via a custom email provider." + "text": "Generates email links and OTPs. This will not send links or OTPs to the end user. This function is for custom admin functionality." } ] }, diff --git a/apps/docs/spec/enrichments/tsdoc_v2/combined_raw.json b/apps/docs/spec/enrichments/tsdoc_v2/combined_raw.json index a8e0cbb5a2d90..9e2f73cc033dc 100644 --- a/apps/docs/spec/enrichments/tsdoc_v2/combined_raw.json +++ b/apps/docs/spec/enrichments/tsdoc_v2/combined_raw.json @@ -6309,7 +6309,7 @@ "kindString": "Call signature", "flags": {}, "comment": { - "shortText": "Generates email links and OTPs to be sent via a custom email provider." + "shortText": "Generates email links and OTPs. This will not send links or OTPs to the end user. This function is for custom admin functionality." }, "parameters": [ { diff --git a/apps/docs/spec/enrichments/tsdoc_v2/gotrue.json b/apps/docs/spec/enrichments/tsdoc_v2/gotrue.json index bcc6eca3d2787..98811c5a188d6 100644 --- a/apps/docs/spec/enrichments/tsdoc_v2/gotrue.json +++ b/apps/docs/spec/enrichments/tsdoc_v2/gotrue.json @@ -4413,7 +4413,7 @@ "summary": [ { "kind": "text", - "text": "Generates email links and OTPs to be sent via a custom email provider." + "text": "Generates email links and OTPs. This will not send links or OTPs to the end user. This function is for custom admin functionality." } ] }, diff --git a/apps/docs/spec/enrichments/tsdoc_v2/gotrue_dereferenced.json b/apps/docs/spec/enrichments/tsdoc_v2/gotrue_dereferenced.json index bcc6eca3d2787..98811c5a188d6 100644 --- a/apps/docs/spec/enrichments/tsdoc_v2/gotrue_dereferenced.json +++ b/apps/docs/spec/enrichments/tsdoc_v2/gotrue_dereferenced.json @@ -4413,7 +4413,7 @@ "summary": [ { "kind": "text", - "text": "Generates email links and OTPs to be sent via a custom email provider." + "text": "Generates email links and OTPs. This will not send links or OTPs to the end user. This function is for custom admin functionality." } ] }, diff --git a/apps/docs/spec/supabase_dart_v1.yml b/apps/docs/spec/supabase_dart_v1.yml index a7d3326c0000b..a13ba8b73da2b 100644 --- a/apps/docs/spec/supabase_dart_v1.yml +++ b/apps/docs/spec/supabase_dart_v1.yml @@ -685,7 +685,7 @@ functions: - id: generate-link title: 'generateLink()' notes: | - Generates email links and OTPs to be sent via a custom email provider. + Generates email links and OTPs. This will not send links or OTPs to the end user. This function is for custom admin functionality. - The following types can be passed into `generateLink()`: `signup`, `magiclink`, `invite`, `recovery`, `emailChangeCurrent`, `emailChangeNew`, `phoneChange`. - `generateLink()` only generates the email link for `email_change_email` if the "Secure email change" setting is enabled under the "Email" provider in your Supabase project. - `generateLink()` handles the creation of the user for `signup`, `invite` and `magiclink`. diff --git a/apps/docs/spec/supabase_dart_v2.yml b/apps/docs/spec/supabase_dart_v2.yml index 82de09cd49e33..581856679b678 100644 --- a/apps/docs/spec/supabase_dart_v2.yml +++ b/apps/docs/spec/supabase_dart_v2.yml @@ -2428,7 +2428,7 @@ functions: - id: generate-link title: 'generateLink()' notes: | - Generates email links and OTPs to be sent via a custom email provider. + Generates email links and OTPs. This will not send links or OTPs to the end user. This function is for custom admin functionality. - The following types can be passed into `generateLink()`: `signup`, `magiclink`, `invite`, `recovery`, `emailChangeCurrent`, `emailChangeNew`, `phoneChange`. - `generateLink()` only generates the email link for `email_change_email` if the "Secure email change" setting is enabled under the "Email" provider in your Supabase project. - `generateLink()` handles the creation of the user for `signup`, `invite` and `magiclink`. diff --git a/apps/docs/spec/supabase_kt_v1.yml b/apps/docs/spec/supabase_kt_v1.yml index a1f80811d365a..02e8f5d02ec6c 100644 --- a/apps/docs/spec/supabase_kt_v1.yml +++ b/apps/docs/spec/supabase_kt_v1.yml @@ -2904,7 +2904,7 @@ functions: title: 'generateLink()' $ref: '@supabase/gotrue-js.GoTrueAdminApi.generateLink' notes: | - Generates email links and OTPs to be sent via a custom email provider. + Generates email links and OTPs. This will not send links or OTPs to the end user. This function is for custom admin functionality. examples: - id: generate-a-signup-link name: Generate a signup link diff --git a/apps/docs/spec/supabase_kt_v2.yml b/apps/docs/spec/supabase_kt_v2.yml index 3285997fb15cd..cc66c3da5e4c4 100644 --- a/apps/docs/spec/supabase_kt_v2.yml +++ b/apps/docs/spec/supabase_kt_v2.yml @@ -4186,7 +4186,7 @@ functions: title: 'generateLink()' $ref: '@supabase/gotrue-js.GoTrueAdminApi.generateLink' notes: | - Generates email links and OTPs to be sent via a custom email provider. + Generates email links and OTPs. This will not send links or OTPs to the end user. This function is for custom admin functionality. params: - name: type isOptional: false diff --git a/apps/docs/spec/supabase_kt_v3.yml b/apps/docs/spec/supabase_kt_v3.yml index 3cb465d3947d2..047700eea790d 100644 --- a/apps/docs/spec/supabase_kt_v3.yml +++ b/apps/docs/spec/supabase_kt_v3.yml @@ -4271,7 +4271,7 @@ functions: title: 'generateLink()' $ref: '@supabase/gotrue-js.GoTrueAdminApi.generateLink' notes: | - Generates email links and OTPs to be sent via a custom email provider. + Generates email links and OTPs. This will not send links or OTPs to the end user. This function is for custom admin functionality. params: - name: type isOptional: false diff --git a/apps/studio/components/interfaces/Database/Triggers/TriggerSheet.tsx b/apps/studio/components/interfaces/Database/Triggers/TriggerSheet.tsx index 6cc885670ae55..384391b597db1 100644 --- a/apps/studio/components/interfaces/Database/Triggers/TriggerSheet.tsx +++ b/apps/studio/components/interfaces/Database/Triggers/TriggerSheet.tsx @@ -412,7 +412,7 @@ export const TriggerSheet = ({
-

Function to trigger

+

Function to trigger

{function_name.length === 0 ? (
- {namespaces.length > 0 && } + {namespaces.length > 0 && } {isLoadingNamespaces || isLoading ? ( @@ -170,14 +174,14 @@ export const AnalyticBucketDetails = ({ bucket }: { bucket: AnalyticsBucket }) = Stream data from tables for archival, backups, or analytical queries.

- + ) : (
{namespaces.map(({ namespace, schema, tables }) => ( - - -
- Connection details - - Connect an Iceberg client to this bucket.{' '} - - Learn more - - -
- !option.hidden && wrapperValues[option.name] - )} - values={wrapperValues} - /> -
- - - {wrapperMeta.server.options - .filter((option) => !option.hidden && wrapperValues[option.name]) - .sort((a, b) => OPTION_ORDER.indexOf(a.name) - OPTION_ORDER.indexOf(b.name)) - .map((option) => { - return ( - - ) - })} - -
+ )} - {state === 'missing' && } + {state === 'missing' && }
@@ -249,9 +216,8 @@ export const AnalyticBucketDetails = ({ bucket }: { bucket: AnalyticsBucket }) =
@@ -259,12 +225,13 @@ export const AnalyticBucketDetails = ({ bucket }: { bucket: AnalyticsBucket }) = - + )} - setModal(null)} + onSuccess={() => router.push(`/project/${projectRef}/storage/analytics`)} /> ) @@ -276,7 +243,7 @@ const ExtensionNotInstalled = ({ wrapperMeta, wrappersExtension, }: { - bucketName: string + bucketName?: string projectRef: string wrapperMeta: WrapperMeta wrappersExtension: DatabaseExtension @@ -325,7 +292,7 @@ const ExtensionNeedsUpgrade = ({ wrapperMeta, wrappersExtension, }: { - bucketName: string + bucketName?: string projectRef: string wrapperMeta: WrapperMeta wrappersExtension: DatabaseExtension @@ -368,11 +335,12 @@ const ExtensionNeedsUpgrade = ({ ) } -const WrapperMissing = ({ bucketName }: { bucketName: string }) => { +const WrapperMissing = ({ bucketName }: { bucketName?: string }) => { const { mutateAsync: createIcebergWrapper, isLoading: isCreatingIcebergWrapper } = useIcebergWrapperCreateMutation() const onSetupWrapper = async () => { + if (!bucketName) return console.error('Bucket name is required') await createIcebergWrapper({ bucketName }) } diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBucketDetails/useAnalyticsBucketAssociatedEntities.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/useAnalyticsBucketAssociatedEntities.tsx similarity index 97% rename from apps/studio/components/interfaces/Storage/AnalyticsBucketDetails/useAnalyticsBucketAssociatedEntities.tsx rename to apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/useAnalyticsBucketAssociatedEntities.tsx index 8aa21db79fbd8..539aa0a836f14 100644 --- a/apps/studio/components/interfaces/Storage/AnalyticsBucketDetails/useAnalyticsBucketAssociatedEntities.tsx +++ b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/useAnalyticsBucketAssociatedEntities.tsx @@ -22,7 +22,7 @@ import { useAnalyticsBucketWrapperInstance } from './useAnalyticsBucketWrapperIn * Used for cleaning up analytics bucket after deletion */ export const useAnalyticsBucketAssociatedEntities = ( - { projectRef, bucketId }: { projectRef?: string; bucketId: string }, + { projectRef, bucketId }: { projectRef?: string; bucketId?: string }, options: { enabled: boolean } = { enabled: true } ) => { const { can: canReadS3Credentials } = useAsyncCheckPermissions( @@ -40,7 +40,7 @@ export const useAnalyticsBucketAssociatedEntities = ( { enabled: canReadS3Credentials && options.enabled } ) const s3AccessKey = (s3AccessKeys?.data ?? []).find( - (x) => x.description === getAnalyticsBucketS3KeyName(bucketId) + (x) => x.description === getAnalyticsBucketS3KeyName(bucketId ?? '') ) const { data: sourcesData } = useReplicationSourcesQuery( @@ -54,7 +54,7 @@ export const useAnalyticsBucketAssociatedEntities = ( { enabled: options.enabled } ) const publication = publications.find( - (p) => p.name === getAnalyticsBucketPublicationName(bucketId) + (p) => p.name === getAnalyticsBucketPublicationName(bucketId ?? '') ) return { icebergWrapper, icebergWrapperMeta, s3AccessKey, publication } diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBucketDetails/useAnalyticsBucketWrapperInstance.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/useAnalyticsBucketWrapperInstance.tsx similarity index 90% rename from apps/studio/components/interfaces/Storage/AnalyticsBucketDetails/useAnalyticsBucketWrapperInstance.tsx rename to apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/useAnalyticsBucketWrapperInstance.tsx index 409b4b8a85d85..b685e212e8717 100644 --- a/apps/studio/components/interfaces/Storage/AnalyticsBucketDetails/useAnalyticsBucketWrapperInstance.tsx +++ b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/useAnalyticsBucketWrapperInstance.tsx @@ -10,17 +10,18 @@ import { useFDWsQuery } from 'data/fdw/fdws-query' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' export const useAnalyticsBucketWrapperInstance = ( - { bucketId }: { bucketId: string }, + { bucketId }: { bucketId?: string }, options?: { enabled?: boolean } ) => { const { data: project, isLoading: isLoadingProject } = useSelectedProjectQuery() + const defaultEnabled = options?.enabled ?? true const { data, isLoading: isLoadingFDWs } = useFDWsQuery( { projectRef: project?.ref, connectionString: project?.connectionString, }, - options + { enabled: defaultEnabled && !!bucketId } ) const icebergWrapper = useMemo(() => { diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBucketDetails/useIcebergWrapper.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/useIcebergWrapper.tsx similarity index 100% rename from apps/studio/components/interfaces/Storage/AnalyticsBucketDetails/useIcebergWrapper.tsx rename to apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/useIcebergWrapper.tsx diff --git a/apps/studio/components/interfaces/Storage/CreateSpecializedBucketModal.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/CreateAnalyticsBucketModal.tsx similarity index 71% rename from apps/studio/components/interfaces/Storage/CreateSpecializedBucketModal.tsx rename to apps/studio/components/interfaces/Storage/AnalyticsBuckets/CreateAnalyticsBucketModal.tsx index 7783ebe2a57c8..2e2afe50d1309 100644 --- a/apps/studio/components/interfaces/Storage/CreateSpecializedBucketModal.tsx +++ b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/CreateAnalyticsBucketModal.tsx @@ -13,6 +13,7 @@ import { InlineLink } from 'components/ui/InlineLink' import { useIsAnalyticsBucketsEnabled } from 'data/config/project-storage-config-query' import { useDatabaseExtensionEnableMutation } from 'data/database-extensions/database-extension-enable-mutation' import { useAnalyticsBucketCreateMutation } from 'data/storage/analytics-bucket-create-mutation' +import { useAnalyticsBucketsQuery } from 'data/storage/analytics-buckets-query' import { useIcebergWrapperCreateMutation } from 'data/storage/iceberg-wrapper-create-mutation' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' @@ -37,9 +38,9 @@ import { } from 'ui' import { Admonition } from 'ui-patterns' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { inverseValidBucketNameRegex, validBucketNameRegex } from '../CreateBucketModal.utils' +import { BUCKET_TYPES } from '../Storage.constants' import { useIcebergWrapperExtension } from './AnalyticsBucketDetails/useIcebergWrapper' -import { inverseValidBucketNameRegex, validBucketNameRegex } from './CreateBucketModal.utils' -import { BUCKET_TYPES } from './Storage.constants' const FormSchema = z .object({ @@ -70,12 +71,11 @@ const FormSchema = z } }) -const formId = 'create-specialized-storage-bucket-form' +const formId = 'create-analytics-storage-bucket-form' -export type CreateSpecializedBucketForm = z.infer +export type CreateAnalyticsBucketForm = z.infer -interface CreateSpecializedBucketModalProps { - bucketType: 'analytics' | 'vectors' +interface CreateAnalyticsBucketModalProps { buttonSize?: 'tiny' | 'small' buttonType?: 'default' | 'primary' buttonClassName?: string @@ -88,14 +88,13 @@ interface CreateSpecializedBucketModalProps { } } -export const CreateSpecializedBucketModal = ({ - bucketType, +export const CreateAnalyticsBucketModal = ({ buttonSize = 'tiny', buttonType = 'default', buttonClassName, disabled = false, tooltip, -}: CreateSpecializedBucketModalProps) => { +}: CreateAnalyticsBucketModalProps) => { const router = useRouter() const { ref } = useParams() const { data: org } = useSelectedOrganizationQuery() @@ -106,6 +105,7 @@ export const CreateSpecializedBucketModal = ({ const [visible, setVisible] = useState(false) + const { data: buckets = [] } = useAnalyticsBucketsQuery({ projectRef: ref }) const icebergCatalogEnabled = useIsAnalyticsBucketsEnabled({ projectRef: ref }) const wrappersExtenstionNeedsUpgrading = wrappersExtensionState === 'needs-upgrade' @@ -126,49 +126,48 @@ export const CreateSpecializedBucketModal = ({ const config = BUCKET_TYPES['analytics'] const isCreating = isEnablingExtension || isCreatingIcebergWrapper || isCreatingAnalyticsBucket - const form = useForm({ + const form = useForm({ resolver: zodResolver(FormSchema), defaultValues: { name: '' }, }) - const onSubmit: SubmitHandler = async (values) => { + const onSubmit: SubmitHandler = async (values) => { if (!ref) return console.error('Project ref is required') if (!project) return console.error('Project details is required') if (!wrappersExtension) return console.error('Unable to find wrappers extension') + const hasExistingBucket = buckets.some((x) => x.id === values.name) + if (hasExistingBucket) return toast.error('Bucket name already exists') + try { - if (bucketType === 'analytics') { - await createAnalyticsBucket({ - projectRef: ref, - bucketName: values.name, - }) + await createAnalyticsBucket({ + projectRef: ref, + bucketName: values.name, + }) - if (wrappersExtensionState === 'not-installed') { - await enableExtension({ - projectRef: project?.ref, - connectionString: project?.connectionString, - name: wrappersExtension.name, - schema: wrappersExtension.schema ?? 'extensions', - version: wrappersExtension.default_version, - }) - await createIcebergWrapper({ bucketName: values.name }) - } else if (wrappersExtensionState === 'installed') { - await createIcebergWrapper({ bucketName: values.name }) - } - } else { - // TODO for vectors bucket + if (wrappersExtensionState === 'not-installed') { + await enableExtension({ + projectRef: project?.ref, + connectionString: project?.connectionString, + name: wrappersExtension.name, + schema: wrappersExtension.schema ?? 'extensions', + version: wrappersExtension.default_version, + }) + await createIcebergWrapper({ bucketName: values.name }) + } else if (wrappersExtensionState === 'installed') { + await createIcebergWrapper({ bucketName: values.name }) } sendEvent({ action: 'storage_bucket_created', - properties: { bucketType }, + properties: { bucketType: 'analytics' }, groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' }, }) toast.success(`Created bucket “${values.name}”`) form.reset() setVisible(false) - router.push(`/project/${ref}/storage/${bucketType}/buckets/${values.name}`) + router.push(`/project/${ref}/storage/analytics/buckets/${values.name}`) } catch (error: any) { toast.error(`Failed to create bucket: ${error.message}`) } @@ -248,44 +247,36 @@ export const CreateSpecializedBucketModal = ({ )} /> - {bucketType === 'analytics' && ( - <> - {wrappersExtenstionNeedsUpgrading ? ( - -

- Update the wrappers extension by disabling - and enabling it in{' '} - - database extensions - {' '} - before creating an Analytics bucket.{' '} - - Learn more - - . -

-
- ) : ( - -

- Supabase will install the{' '} - {wrappersExtensionState !== 'installed' ? 'Wrappers extension and ' : ''} - Iceberg Wrapper integration on your behalf.{' '} - - Learn more - - . -

-
- )} - + {wrappersExtenstionNeedsUpgrading ? ( + +

+ Update the wrappers extension by disabling and + enabling it in{' '} + + database extensions + {' '} + before creating an Analytics bucket.{' '} + + Learn more + + . +

+
+ ) : ( + +

+ Supabase will install the{' '} + {wrappersExtensionState !== 'installed' ? 'Wrappers extension and ' : ''} + Iceberg Wrapper integration on your behalf.{' '} + + Learn more + + . +

+
)} diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/DeleteAnalyticsBucketModal.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/DeleteAnalyticsBucketModal.tsx new file mode 100644 index 0000000000000..0568c24ceb0e2 --- /dev/null +++ b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/DeleteAnalyticsBucketModal.tsx @@ -0,0 +1,164 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { SubmitHandler, useForm } from 'react-hook-form' +import { toast } from 'sonner' +import z from 'zod' + +import { useParams } from 'common' +import { useAnalyticsBucketDeleteMutation } from 'data/storage/analytics-bucket-delete-mutation' +import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { + Button, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogSection, + DialogSectionSeparator, + DialogTitle, + Form_Shadcn_, + FormControl_Shadcn_, + FormField_Shadcn_, + Input_Shadcn_, +} from 'ui' +import { Admonition } from 'ui-patterns' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { + useAnalyticsBucketAssociatedEntities, + useAnalyticsBucketDeleteCleanUp, +} from './AnalyticsBucketDetails/useAnalyticsBucketAssociatedEntities' + +export interface DeleteAnalyticsBucketModalProps { + visible: boolean + bucketId?: string + onClose: () => void + onSuccess?: () => void +} + +const formId = `delete-analytics-bucket-form` + +// [Joshen] Can refactor to use TextConfirmModal + +export const DeleteAnalyticsBucketModal = ({ + visible, + bucketId, + onClose, + onSuccess, +}: DeleteAnalyticsBucketModalProps) => { + const { ref: projectRef } = useParams() + const { data: project } = useSelectedProjectQuery() + + const schema = z.object({ + confirm: z.literal(bucketId, { + errorMap: () => ({ message: `Please enter "${bucketId}" to confirm` }), + }), + }) + + const form = useForm>({ + resolver: zodResolver(schema), + }) + + const { icebergWrapper, icebergWrapperMeta, s3AccessKey, publication } = + useAnalyticsBucketAssociatedEntities({ projectRef, bucketId: bucketId }) + + const { mutateAsync: deleteAnalyticsBucketCleanUp, isLoading: isCleaningUpAnalyticsBucket } = + useAnalyticsBucketDeleteCleanUp() + + const { mutate: deleteAnalyticsBucket, isLoading: isDeletingAnalyticsBucket } = + useAnalyticsBucketDeleteMutation({ + onSuccess: async () => { + if (project?.connectionString) { + await deleteAnalyticsBucketCleanUp({ + projectRef, + connectionString: project.connectionString, + bucketId: bucketId, + icebergWrapper, + icebergWrapperMeta, + s3AccessKey, + publication, + }) + } + toast.success(`Successfully deleted analytics bucket ${bucketId}`) + onClose() + onSuccess?.() + }, + }) + + const onSubmit: SubmitHandler> = async () => { + if (!projectRef) return console.error('Project ref is required') + if (!bucketId) return console.error('No bucket is selected') + deleteAnalyticsBucket({ projectRef, id: bucketId }) + } + + const isDeleting = isDeletingAnalyticsBucket || isCleaningUpAnalyticsBucket + + return ( + { + if (!open) onClose() + }} + > + + + Confirm deletion of {bucketId} + + + + + + + +

+ Your bucket {bucketId} and all its + contents will be permanently deleted. +

+
+ + + +
+ ( + + Type {bucketId} to + confirm. + + } + > + + + + + )} + /> + +
+
+ + + + +
+
+ ) +} diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBuckets.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/index.tsx similarity index 93% rename from apps/studio/components/interfaces/Storage/AnalyticsBuckets.tsx rename to apps/studio/components/interfaces/Storage/AnalyticsBuckets/index.tsx index b95c0daba94d8..f02ae2c05e453 100644 --- a/apps/studio/components/interfaces/Storage/AnalyticsBuckets.tsx +++ b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/index.tsx @@ -22,9 +22,9 @@ import { } from 'ui' import { Admonition, TimestampInfo } from 'ui-patterns' import { Input } from 'ui-patterns/DataInputs/Input' -import { CreateSpecializedBucketModal } from './CreateSpecializedBucketModal' -import { DeleteBucketModal } from './DeleteBucketModal' -import { EmptyBucketState } from './EmptyBucketState' +import { EmptyBucketState } from '../EmptyBucketState' +import { CreateAnalyticsBucketModal } from './CreateAnalyticsBucketModal' +import { DeleteAnalyticsBucketModal } from './DeleteAnalyticsBucketModal' export const AnalyticsBuckets = () => { const { ref } = useParams() @@ -84,11 +84,7 @@ export const AnalyticsBuckets = () => { onChange={(e) => setFilterString(e.target.value)} icon={} /> - + {isLoadingBuckets ? ( @@ -167,9 +163,9 @@ export const AnalyticsBuckets = () => { )} {selectedBucket && ( - setModal(null)} /> )} diff --git a/apps/studio/components/interfaces/Storage/DeleteBucketModal.tsx b/apps/studio/components/interfaces/Storage/DeleteBucketModal.tsx index d4a723180b757..c75c54bc2348c 100644 --- a/apps/studio/components/interfaces/Storage/DeleteBucketModal.tsx +++ b/apps/studio/components/interfaces/Storage/DeleteBucketModal.tsx @@ -8,7 +8,6 @@ import z from 'zod' import { useParams } from 'common' import { useDatabasePoliciesQuery } from 'data/database-policies/database-policies-query' import { useDatabasePolicyDeleteMutation } from 'data/database-policies/database-policy-delete-mutation' -import { useAnalyticsBucketDeleteMutation } from 'data/storage/analytics-bucket-delete-mutation' import { AnalyticsBucket } from 'data/storage/analytics-buckets-query' import { useBucketDeleteMutation } from 'data/storage/bucket-delete-mutation' import { Bucket, useBucketsQuery } from 'data/storage/buckets-query' @@ -29,10 +28,6 @@ import { } from 'ui' import { Admonition } from 'ui-patterns' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' -import { - useAnalyticsBucketAssociatedEntities, - useAnalyticsBucketDeleteCleanUp, -} from './AnalyticsBucketDetails/useAnalyticsBucketAssociatedEntities' import { formatPoliciesForStorage } from './Storage.utils' export interface DeleteBucketModalProps { @@ -48,8 +43,6 @@ export const DeleteBucketModal = ({ visible, bucket, onClose }: DeleteBucketModa const { ref: projectRef, bucketId } = useParams() const { data: project } = useSelectedProjectQuery() - const isStandardBucketSelected = 'type' in bucket && bucket.type === 'STANDARD' - const schema = z.object({ confirm: z.literal(bucket.id, { errorMap: () => ({ message: `Please enter "${bucket.id}" to confirm` }), @@ -69,12 +62,6 @@ export const DeleteBucketModal = ({ visible, bucket, onClose }: DeleteBucketModa schema: 'storage', }) - const { icebergWrapper, icebergWrapperMeta, s3AccessKey, publication } = - useAnalyticsBucketAssociatedEntities( - { projectRef, bucketId: bucket.id }, - { enabled: !isStandardBucketSelected } - ) - const { mutateAsync: deletePolicy } = useDatabasePolicyDeleteMutation() const { mutate: deleteBucket, isLoading: isDeletingBucket } = useBucketDeleteMutation({ @@ -115,43 +102,12 @@ export const DeleteBucketModal = ({ visible, bucket, onClose }: DeleteBucketModa }, }) - const { mutateAsync: deleteAnalyticsBucketCleanUp, isLoading: isCleaningUpAnalyticsBucket } = - useAnalyticsBucketDeleteCleanUp() - - const { mutate: deleteAnalyticsBucket, isLoading: isDeletingAnalyticsBucket } = - useAnalyticsBucketDeleteMutation({ - onSuccess: async () => { - if (project?.connectionString) { - await deleteAnalyticsBucketCleanUp({ - projectRef, - connectionString: project.connectionString, - bucketId: bucket.id, - icebergWrapper, - icebergWrapperMeta, - s3AccessKey, - publication, - }) - } - toast.success(`Successfully deleted analytics bucket ${bucket.id}`) - if (!!bucketId) router.push(`/project/${projectRef}/storage/analytics`) - onClose() - }, - }) - const onSubmit: SubmitHandler> = async () => { if (!projectRef) return console.error('Project ref is required') if (!bucket) return console.error('No bucket is selected') - - // [Joshen] We'll need a third case to figure out for vector buckets - if (isStandardBucketSelected) { - deleteBucket({ projectRef, id: bucket.id }) - } else { - deleteAnalyticsBucket({ projectRef, id: bucket.id }) - } + deleteBucket({ projectRef, id: bucket.id }) } - const isDeleting = isDeletingBucket || isDeletingAnalyticsBucket || isCleaningUpAnalyticsBucket - return ( - - diff --git a/apps/studio/components/interfaces/Storage/EmptyBucketState.tsx b/apps/studio/components/interfaces/Storage/EmptyBucketState.tsx index 56ca9f1759294..8a917dae18ee9 100644 --- a/apps/studio/components/interfaces/Storage/EmptyBucketState.tsx +++ b/apps/studio/components/interfaces/Storage/EmptyBucketState.tsx @@ -1,8 +1,9 @@ import { BucketAdd } from 'icons' import { cn } from 'ui' +import { CreateAnalyticsBucketModal } from './AnalyticsBuckets/CreateAnalyticsBucketModal' import { CreateBucketModal } from './CreateBucketModal' -import { CreateSpecializedBucketModal } from './CreateSpecializedBucketModal' import { BUCKET_TYPES } from './Storage.constants' +import { CreateVectorBucketDialog } from './VectorBuckets/CreateVectorBucketDialog' interface EmptyBucketStateProps { bucketType: keyof typeof BUCKET_TYPES @@ -28,27 +29,17 @@ export const EmptyBucketState = ({ bucketType, className }: EmptyBucketStateProp

{config.valueProp}

- {bucketType === 'files' ? ( + {bucketType === 'files' && ( - ) : ( - )} + {bucketType === 'vectors' && } ) } diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/useSelectedBucket.ts b/apps/studio/components/interfaces/Storage/StorageExplorer/useSelectedBucket.ts index aae911f33b425..b44317600dd22 100644 --- a/apps/studio/components/interfaces/Storage/StorageExplorer/useSelectedBucket.ts +++ b/apps/studio/components/interfaces/Storage/StorageExplorer/useSelectedBucket.ts @@ -34,9 +34,7 @@ export const useSelectedBucket = () => { ? buckets.find((b) => b.id === bucketId) : page === 'analytics' ? analyticsBuckets.find((b: any) => b.id === bucketId) - : // [Joshen] Remove typecasts bucket: any once infra changes for analytics bucket is in - // [Joshen] Temp fallback to buckets for backwards compatibility old UI - buckets.find((b) => b.id === bucketId) + : buckets.find((b) => b.id === bucketId) return { bucket, isSuccess, isError, error } } diff --git a/apps/studio/components/interfaces/Storage/StorageMenuV2.tsx b/apps/studio/components/interfaces/Storage/StorageMenuV2.tsx index 8a08b41ad7070..3bd7d77596234 100644 --- a/apps/studio/components/interfaces/Storage/StorageMenuV2.tsx +++ b/apps/studio/components/interfaces/Storage/StorageMenuV2.tsx @@ -1,7 +1,10 @@ import Link from 'next/link' import { IS_PLATFORM, useParams } from 'common' -import { useIsAnalyticsBucketsEnabled } from 'data/config/project-storage-config-query' +import { + useIsAnalyticsBucketsEnabled, + useIsVectorBucketsEnabled, +} from 'data/config/project-storage-config-query' import { Badge, Menu } from 'ui' import { BUCKET_TYPES, BUCKET_TYPE_KEYS } from './Storage.constants' import { useStorageV2Page } from './Storage.utils' @@ -11,6 +14,7 @@ export const StorageMenuV2 = () => { const page = useStorageV2Page() const isAnalyticsBucketsEnabled = useIsAnalyticsBucketsEnabled({ projectRef: ref }) + const isVectorBucketsEnabled = useIsVectorBucketsEnabled({ projectRef: ref }) return ( @@ -21,15 +25,16 @@ export const StorageMenuV2 = () => { {BUCKET_TYPE_KEYS.map((bucketTypeKey) => { const isSelected = page === bucketTypeKey const config = BUCKET_TYPES[bucketTypeKey] + const isAlphaEnabled = + (bucketTypeKey === 'analytics' && isAnalyticsBucketsEnabled) || + (bucketTypeKey === 'vectors' && isVectorBucketsEnabled) return (

{config.displayName}

- {bucketTypeKey === 'analytics' && isAnalyticsBucketsEnabled && ( - ALPHA - )} + {isAlphaEnabled && ALPHA}
diff --git a/apps/studio/components/interfaces/Storage/VectorBuckets/CreateVectorBucketDialog.tsx b/apps/studio/components/interfaces/Storage/VectorBuckets/CreateVectorBucketDialog.tsx new file mode 100644 index 0000000000000..354ae2596f3af --- /dev/null +++ b/apps/studio/components/interfaces/Storage/VectorBuckets/CreateVectorBucketDialog.tsx @@ -0,0 +1,178 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { Plus } from 'lucide-react' +import { useEffect, useState } from 'react' +import { SubmitHandler, useForm } from 'react-hook-form' +import { toast } from 'sonner' +import z from 'zod' + +import { useParams } from 'common' +import { ButtonTooltip } from 'components/ui/ButtonTooltip' +import { useVectorBucketCreateMutation } from 'data/storage/vector-bucket-create-mutation' +import { useVectorBucketsQuery } from 'data/storage/vector-buckets-query' +import { useSendEventMutation } from 'data/telemetry/send-event-mutation' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' +import { + Button, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogSection, + DialogSectionSeparator, + DialogTitle, + DialogTrigger, + Form_Shadcn_, + FormControl_Shadcn_, + FormField_Shadcn_, + Input_Shadcn_, +} from 'ui' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { inverseValidBucketNameRegex, validBucketNameRegex } from '../CreateBucketModal.utils' + +const FormSchema = z.object({ + name: z + .string() + .trim() + .min(1, 'Please provide a name for your bucket') + .max(100, 'Bucket name should be below 100 characters') + .superRefine((name, ctx) => { + if (!validBucketNameRegex.test(name)) { + const [match] = name.match(inverseValidBucketNameRegex) ?? [] + ctx.addIssue({ + path: [], + code: z.ZodIssueCode.custom, + message: !!match + ? `Bucket name cannot contain the "${match}" character` + : 'Bucket name contains an invalid special character', + }) + } + }), +}) + +const formId = 'create-storage-bucket-form' + +export type CreateBucketForm = z.infer + +export const CreateVectorBucketDialog = () => { + const { ref } = useParams() + const { data: org } = useSelectedOrganizationQuery() + + const [visible, setVisible] = useState(false) + const { can: canCreateBuckets } = useAsyncCheckPermissions(PermissionAction.STORAGE_WRITE, '*') + + const { data } = useVectorBucketsQuery({ projectRef: ref }) + + const form = useForm({ + resolver: zodResolver(FormSchema), + defaultValues: { name: '' }, + }) + + const { mutate: sendEvent } = useSendEventMutation() + const { mutate: createVectorBucket, isLoading: isCreating } = useVectorBucketCreateMutation({ + onSuccess: (values) => { + sendEvent({ + action: 'storage_bucket_created', + properties: { bucketType: 'vector' }, + groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' }, + }) + toast.success(`Successfully created vector bucket ${values.name}`) + form.reset() + setVisible(false) + }, + onError: (error) => { + toast.error(`Failed to create vector bucket: ${error.message}`) + }, + }) + + const onSubmit: SubmitHandler = async (values) => { + if (!ref) return console.error('Project ref is required') + + const hasExistingBucket = (data?.vectorBuckets ?? []).some( + (x) => x.vectorBucketName === values.name + ) + if (hasExistingBucket) return toast.error('Bucket name already exists') + + createVectorBucket({ projectRef: ref, bucketName: values.name }) + } + + useEffect(() => { + if (!visible) form.reset() + }, [visible]) + + return ( + + + } + disabled={!canCreateBuckets} + onClick={() => setVisible(true)} + tooltip={{ + content: { + side: 'bottom', + text: !canCreateBuckets + ? 'You need additional permissions to create buckets' + : undefined, + }, + }} + > + New bucket + + + + + + Create vector bucket + + + + + +
+ + ( + + + + + + )} + /> + +
+
+ + + + + +
+
+ ) +} diff --git a/apps/studio/components/interfaces/Storage/VectorBuckets/CreateVectorTableSheet.tsx b/apps/studio/components/interfaces/Storage/VectorBuckets/CreateVectorTableSheet.tsx new file mode 100644 index 0000000000000..6b512720d427b --- /dev/null +++ b/apps/studio/components/interfaces/Storage/VectorBuckets/CreateVectorTableSheet.tsx @@ -0,0 +1,399 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { Lock, Plus, Trash2 } from 'lucide-react' +import { useEffect, useState } from 'react' +import { SubmitHandler, useFieldArray, useForm } from 'react-hook-form' +import { toast } from 'sonner' +import z from 'zod' + +import { useParams } from 'common' +import { ButtonTooltip } from 'components/ui/ButtonTooltip' +import { DocsButton } from 'components/ui/DocsButton' +import { useVectorBucketIndexCreateMutation } from 'data/storage/vector-bucket-index-create-mutation' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { + Button, + Form_Shadcn_, + FormControl_Shadcn_, + FormField_Shadcn_, + FormItem_Shadcn_, + FormMessage_Shadcn_, + Input_Shadcn_, + RadioGroupStacked, + RadioGroupStackedItem, + Separator, + Sheet, + SheetContent, + SheetFooter, + SheetHeader, + SheetSection, + SheetTitle, + SheetTrigger, +} from 'ui' +import { Admonition } from 'ui-patterns' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { inverseValidBucketNameRegex } from '../CreateBucketModal.utils' + +const isStagingLocal = process.env.NEXT_PUBLIC_ENVIRONMENT !== 'prod' + +const BUCKET_INDEX_NAME_REGEX = /^[a-z0-9](?:[a-z0-9.-]{1,61})?[a-z0-9]$/ + +const DISTANCE_METRICS = [ + { + value: 'cosine', + label: 'Cosine', + description: 'Measures similarity between two vectors, based on directions, not magnitude.', + }, + { + value: 'euclidean', + label: 'Euclidean', + description: + 'Measures straight-line distance between two vectors, using both directions and magnitudes.', + }, +] as const + +const FormSchema = z.object({ + name: z + .string() + .trim() + .min(3, 'Name must be at least 3 characters') + .max(63, 'Name must be below 63 characters') + .refine( + (value) => value !== 'public', + '"public" is a reserved name. Please choose another name' + ) + .superRefine((name, ctx) => { + if (!BUCKET_INDEX_NAME_REGEX.test(name)) { + const [match] = name.match(inverseValidBucketNameRegex) ?? [] + ctx.addIssue({ + path: [], + code: z.ZodIssueCode.custom, + message: !!match + ? `Bucket name cannot contain the "${match}" character` + : 'Bucket name contains an invalid special character', + }) + } + }), + targetSchema: z.string().default(''), + dimension: z + .number() + .int('Dimension must be an integer') + .min(1, 'Dimension must be at least 1') + .max(4096, 'Dimension must be at most 4096'), + distanceMetric: z.enum(['cosine', 'euclidean'], { + required_error: 'Please select a distance metric', + }), + metadataKeys: z + .array( + z.object({ + value: z.string().min(1, 'The metadata key needs to be at least 1 character long'), + }) + ) + .default([]), +}) + +const formId = 'create-vector-table-form' + +export type CreateVectorTableForm = z.infer + +interface CreateVectorTableSheetProps { + bucketName?: string +} + +export const CreateVectorTableSheet = ({ bucketName }: CreateVectorTableSheetProps) => { + const { ref } = useParams() + const { data: project } = useSelectedProjectQuery() + + const [visible, setVisible] = useState(false) + const { can: canCreateBuckets } = useAsyncCheckPermissions(PermissionAction.STORAGE_WRITE, '*') + + // [Joshen] Can remove this once this restriction is removed + const showIndexCreationNotice = isStagingLocal && !!project && project?.region !== 'us-east-1' + + const defaultValues = { + name: '', + targetSchema: bucketName, + dimension: undefined, + distanceMetric: 'cosine' as 'cosine' | 'euclidean', + metadataKeys: [], + } + const form = useForm({ + resolver: zodResolver(FormSchema), + defaultValues, + values: defaultValues as any, + }) + + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: 'metadataKeys', + }) + + const { mutate: createVectorBucketTable, isLoading: isCreating } = + useVectorBucketIndexCreateMutation({ + onSuccess: (values) => { + toast.success(`Successfully created vector table ${values.name}`) + form.reset() + + setVisible(false) + }, + onError: (error) => { + // For other errors, show a toast as fallback + toast.error(`Failed to create vector table: ${error.message}`) + }, + }) + + const onSubmit: SubmitHandler = async (values) => { + if (!ref) return console.error('Project ref is required') + + createVectorBucketTable({ + projectRef: ref, + bucketName: values.targetSchema, + indexName: values.name, + dataType: 'float32', + dimension: values.dimension!, + distanceMetric: values.distanceMetric, + metadataKeys: values.metadataKeys.map((key) => key.value), + }) + } + + useEffect(() => { + if (!visible) { + form.reset() + } + }, [visible]) + + return ( + + + } + disabled={!canCreateBuckets} + onClick={() => setVisible(true)} + tooltip={{ + content: { + side: 'bottom', + text: !canCreateBuckets + ? 'You need additional permissions to create buckets' + : undefined, + }, + }} + > + Create table + + + + + + Create vector table + + + {showIndexCreationNotice && ( + + )} + + +
+ + ( + + + + + + )} + /> + + ( + + +
+ +
+ +
+
+
+
+ )} + /> +
+ + + ( + + + { + const value = e.target.value + field.onChange(value === '' ? undefined : Number(value)) + }} + value={field.value ?? ''} + /> + + + )} + /> + + ( + + + + {DISTANCE_METRICS.map((metric) => ( + +
+

{metric.label}

+

+ {metric.description} +

+
+
+ ))} +
+
+
+ )} + /> +
+ + +
+ + +
+ +
+ {fields.map((field, index) => ( +
+ ( + + + + + + + )} + /> +
+ ))} +
+
+ +
+
+ +
+ + + + + +
+
+ ) +} diff --git a/apps/studio/components/interfaces/Storage/VectorBuckets/DeleteVectorBucketModal.tsx b/apps/studio/components/interfaces/Storage/VectorBuckets/DeleteVectorBucketModal.tsx new file mode 100644 index 0000000000000..9d66974412c0d --- /dev/null +++ b/apps/studio/components/interfaces/Storage/VectorBuckets/DeleteVectorBucketModal.tsx @@ -0,0 +1,161 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { SubmitHandler, useForm } from 'react-hook-form' +import { toast } from 'sonner' +import z from 'zod' + +import { useParams } from 'common' +import { useVectorBucketDeleteMutation } from 'data/storage/vector-bucket-delete-mutation' +import { deleteVectorBucketIndex } from 'data/storage/vector-bucket-index-delete-mutation' +import { useVectorBucketsIndexesQuery } from 'data/storage/vector-buckets-indexes-query' +import { + Button, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogSection, + DialogSectionSeparator, + DialogTitle, + Form_Shadcn_, + FormControl_Shadcn_, + FormField_Shadcn_, + Input_Shadcn_, +} from 'ui' +import { Admonition } from 'ui-patterns/admonition' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' + +export interface DeleteVectorBucketModalProps { + visible: boolean + bucketName?: string + onCancel: () => void + onSuccess: () => void +} + +const formId = `delete-storage-vector-bucket-form` + +// [Joshen] Can refactor to use TextConfirmModal + +export const DeleteVectorBucketModal = ({ + visible, + bucketName, + onCancel, + onSuccess, +}: DeleteVectorBucketModalProps) => { + const { ref: projectRef } = useParams() + + const schema = z.object({ + confirm: z.literal(bucketName, { + errorMap: () => ({ message: `Please enter "${bucketName}" to confirm` }), + }), + }) + + const form = useForm>({ + resolver: zodResolver(schema), + }) + + const { mutate: deleteBucket, isLoading } = useVectorBucketDeleteMutation({ + onSuccess: async () => { + toast.success(`Bucket "${bucketName}" deleted successfully`) + onSuccess() + }, + }) + + const { data: { indexes = [] } = {} } = useVectorBucketsIndexesQuery({ + projectRef, + vectorBucketName: bucketName, + }) + + const onSubmit: SubmitHandler> = async () => { + if (!projectRef) return console.error('Project ref is required') + if (!bucketName) return console.error('No bucket is selected') + + try { + // delete all indexes from the bucket first + const promises = indexes.map((index) => + deleteVectorBucketIndex({ + projectRef, + bucketName: bucketName, + indexName: index.indexName, + }) + ) + await Promise.all(promises) + + deleteBucket({ projectRef, bucketName }) + } catch (error) { + toast.error( + `Failed to delete bucket: ${error instanceof Error ? error.message : 'Unknown error'}` + ) + } + } + + return ( + { + if (!open) onCancel() + }} + > + + + Confirm deletion of {bucketName} + + + + + + + +

+ Your bucket {bucketName} and all its + contents will be permanently deleted. +

+
+ + + +
+ ( + + Type {bucketName} to + confirm. + + } + > + + + + + )} + /> + +
+
+ + + + +
+
+ ) +} diff --git a/apps/studio/components/interfaces/Storage/VectorBuckets/DeleteVectorTableModal.tsx b/apps/studio/components/interfaces/Storage/VectorBuckets/DeleteVectorTableModal.tsx new file mode 100644 index 0000000000000..f92519807186c --- /dev/null +++ b/apps/studio/components/interfaces/Storage/VectorBuckets/DeleteVectorTableModal.tsx @@ -0,0 +1,52 @@ +import { toast } from 'sonner' + +import { useParams } from 'common' +import { useVectorBucketIndexDeleteMutation } from 'data/storage/vector-bucket-index-delete-mutation' +import { VectorBucketIndex } from 'data/storage/vector-buckets-indexes-query' +import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' + +interface DeleteVectorTableModalProps { + visible: boolean + table?: VectorBucketIndex + onClose: () => void +} + +export const DeleteVectorTableModal = ({ + visible, + table, + onClose, +}: DeleteVectorTableModalProps) => { + const { ref: projectRef } = useParams() + + const { mutate: deleteIndex, isLoading: isDeleting } = useVectorBucketIndexDeleteMutation({ + onSuccess: (_, vars) => { + toast.success(`Table "${vars.indexName}" deleted successfully`) + onClose() + }, + }) + + const onConfirmDelete = () => { + if (!projectRef) return console.error('Project ref is required') + if (!table) return console.error('Vector table is required') + + deleteIndex({ + projectRef, + bucketName: table.vectorBucketName, + indexName: table.indexName, + }) + } + + return ( + + {/* [Joshen] Can probably beef up more details here - what are potential side effects of deleting a table */} +

This action cannot be undone.

+
+ ) +} diff --git a/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketDetails.tsx b/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketDetails.tsx new file mode 100644 index 0000000000000..527819a055cc4 --- /dev/null +++ b/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketDetails.tsx @@ -0,0 +1,249 @@ +import { Eye, MoreVertical, Search, Trash2 } from 'lucide-react' +import Link from 'next/link' +import { useRouter } from 'next/router' +import { useState } from 'react' + +import { useParams } from 'common' +import { + ScaffoldContainer, + ScaffoldHeader, + ScaffoldSection, + ScaffoldSectionDescription, + ScaffoldSectionTitle, +} from 'components/layouts/Scaffold' +import AlertError from 'components/ui/AlertError' +import { useVectorBucketQuery } from 'data/storage/vector-bucket-query' +import { + useVectorBucketsIndexesQuery, + VectorBucketIndex, +} from 'data/storage/vector-buckets-indexes-query' +import { + Button, + Card, + CardContent, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from 'ui' +import { Input } from 'ui-patterns/DataInputs/Input' +import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' +import { CreateVectorTableSheet } from './CreateVectorTableSheet' +import { DeleteVectorBucketModal } from './DeleteVectorBucketModal' +import { DeleteVectorTableModal } from './DeleteVectorTableModal' + +export const VectorBucketDetails = () => { + const router = useRouter() + const { ref: projectRef, bucketId } = useParams() + + const [filterString, setFilterString] = useState('') + const [showDeleteModal, setShowDeleteModal] = useState(false) + const [selectedTableToDelete, setSelectedTableToDelete] = useState() + + const { + data: bucket, + error: bucketError, + isSuccess: isSuccessBucket, + isError: isErrorBucket, + } = useVectorBucketQuery({ projectRef, vectorBucketName: bucketId }) + + const { data, isLoading: isLoadingIndexes } = useVectorBucketsIndexesQuery({ + projectRef, + vectorBucketName: bucket?.vectorBucketName, + }) + const allIndexes = data?.indexes ?? [] + + const filteredList = + filterString.length === 0 + ? allIndexes + : allIndexes.filter((index) => + index.indexName.toLowerCase().includes(filterString.toLowerCase()) + ) + + return ( + <> + {isErrorBucket ? ( + + + + + + ) : ( + + + + Tables + + Vector tables stored in this bucket. + + +
+ setFilterString(e.target.value)} + icon={} + className="w-48" + /> + +
+ + {isLoadingIndexes ? ( + + ) : ( + + + + + + Name + + + Dimension + + + Distance metric + + + + + + {filteredList.length === 0 ? ( + + + {filterString.length > 0 ? ( + <> +

No results found

+

+ Your search for "{filterString}" did not return any results +

+ + ) : ( + <> +

No tables yet

+

+ Create your first table to get started +

+ + )} +
+
+ ) : ( + filteredList.map((index, idx: number) => { + const id = `index-${idx}` + const name = index.indexName + + return ( + + {name} + +

{index.dimension}

+
+ +

{index.distanceMetric}

+
+ +
+ + + +
+
+
+ ) + }) + )} +
+
+
+ )} +
+ + +
+ Manage +
+ + +
+

Delete bucket

+

+ This will also delete any data in your bucket. Make sure you have a backup if + you want to keep your data. +

+
+ +
+
+
+
+ )} + + setSelectedTableToDelete(undefined)} + /> + + setShowDeleteModal(false)} + onSuccess={() => { + setShowDeleteModal(false) + router.push(`/project/${projectRef}/storage/vectors`) + }} + /> + + ) +} diff --git a/apps/studio/components/interfaces/Storage/VectorBuckets/index.tsx b/apps/studio/components/interfaces/Storage/VectorBuckets/index.tsx new file mode 100644 index 0000000000000..16cc1e006a21a --- /dev/null +++ b/apps/studio/components/interfaces/Storage/VectorBuckets/index.tsx @@ -0,0 +1,184 @@ +import { ExternalLink, MoreVertical, Search, Trash2 } from 'lucide-react' +import Link from 'next/link' +import { useState } from 'react' + +import { useParams } from 'common' +import { ScaffoldHeader, ScaffoldSection, ScaffoldSectionTitle } from 'components/layouts/Scaffold' +import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' +import { useVectorBucketsQuery } from 'data/storage/vector-buckets-query' +import { + Button, + Card, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from 'ui' +import { Admonition } from 'ui-patterns' +import { Input } from 'ui-patterns/DataInputs/Input' +import { TimestampInfo } from 'ui-patterns/TimestampInfo' +import { EmptyBucketState } from '../EmptyBucketState' +import { CreateVectorBucketDialog } from './CreateVectorBucketDialog' +import { DeleteVectorBucketModal } from './DeleteVectorBucketModal' + +/** + * [Joshen] Low-priority refactor: We should use a virtualized table here as per how we do it + * for the files buckets for consistency. Not pressing, just an optimization area. + */ + +export const VectorsBuckets = () => { + const { ref: projectRef } = useParams() + + const [filterString, setFilterString] = useState('') + const [bucketForDeletion, setBucketForDeletion] = useState<{ + vectorBucketName: string + creationTime: string + } | null>(null) + + const { data, isLoading: isLoadingBuckets } = useVectorBucketsQuery({ projectRef }) + const bucketsList = data?.vectorBuckets ?? [] + + const filteredBuckets = + filterString.length === 0 + ? bucketsList + : bucketsList.filter((bucket) => + bucket.vectorBucketName.toLowerCase().includes(filterString.toLowerCase()) + ) + + return ( + + }> + + Leave feedback + + + } + > +

+ Expect rapid changes, limited features, and possible breaking updates as we expand access. +

+

Please share feedback as we refine the experience!

+
+ + {!isLoadingBuckets && bucketsList.length === 0 ? ( + + ) : ( +
+ + Buckets + +
+ setFilterString(e.target.value)} + icon={} + /> + +
+ + {isLoadingBuckets ? ( + + ) : ( + + + + + Name + Created at + + + + + {filteredBuckets.length === 0 && filterString.length > 0 && ( + + +

No results found

+

+ Your search for "{filterString}" did not return any results +

+
+
+ )} + {filteredBuckets.map((bucket, idx: number) => { + const id = `bucket-${idx}` + const name = bucket.vectorBucketName + // the creation time is in seconds, convert it to milliseconds + const created = +bucket.creationTime * 1000 + + return ( + + {name} + +

+ +

+
+ +
+ + + +
+
+
+ ) + })} +
+
+
+ )} +
+ )} + + setBucketForDeletion(null)} + onSuccess={() => setBucketForDeletion(null)} + /> +
+ ) +} diff --git a/apps/studio/components/interfaces/Storage/VectorsBuckets.tsx b/apps/studio/components/interfaces/Storage/VectorsBuckets.tsx deleted file mode 100644 index 87e2dbbab12b1..0000000000000 --- a/apps/studio/components/interfaces/Storage/VectorsBuckets.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { EmptyBucketState } from './EmptyBucketState' - -export const VectorsBuckets = () => { - // Placeholder component - will be implemented in a later PR - return -} diff --git a/apps/studio/components/ui/AIEditor/index.tsx b/apps/studio/components/ui/AIEditor/index.tsx index faf29dccb1473..a08c717dfef42 100644 --- a/apps/studio/components/ui/AIEditor/index.tsx +++ b/apps/studio/components/ui/AIEditor/index.tsx @@ -5,8 +5,10 @@ import { editor as monacoEditor } from 'monaco-editor' import { useCallback, useEffect, useRef, useState } from 'react' import { toast } from 'sonner' +import { SIDEBAR_KEYS } from 'components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' import { constructHeaders } from 'data/fetchers' import { detectOS } from 'lib/helpers' +import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state' import ResizableAIWidget from './ResizableAIWidget' interface AIEditorProps { @@ -28,6 +30,7 @@ interface AIEditorProps { onChange?: (value: string) => void onClose?: () => void closeShortcutEnabled?: boolean + openAIAssistantShortcutEnabled?: boolean executeQuery?: () => void } @@ -49,9 +52,11 @@ const AIEditor = ({ onChange, onClose, closeShortcutEnabled = true, + openAIAssistantShortcutEnabled = true, executeQuery, }: AIEditorProps) => { const os = detectOS() + const { toggleSidebar } = useSidebarManagerSnapshot() const editorRef = useRef(null) const diffEditorRef = useRef(null) const monacoRef = useRef(null) @@ -221,6 +226,18 @@ const AIEditor = ({ refreshCloseAction() + // Add AI Assistant toggle keybinding (Cmd+I) + if (openAIAssistantShortcutEnabled) { + editor.addAction({ + id: 'toggle-ai-assistant', + label: 'Toggle AI Assistant', + keybindings: [monaco.KeyMod.CtrlCmd + monaco.KeyCode.KeyI], + run: () => { + toggleSidebar(SIDEBAR_KEYS.AI_ASSISTANT) + }, + }) + } + editor.addAction({ id: 'generate-ai', label: 'Generate with AI', diff --git a/apps/studio/components/ui/EditorPanel/EditorPanel.tsx b/apps/studio/components/ui/EditorPanel/EditorPanel.tsx index ea19b47c2da74..912a4906af5b9 100644 --- a/apps/studio/components/ui/EditorPanel/EditorPanel.tsx +++ b/apps/studio/components/ui/EditorPanel/EditorPanel.tsx @@ -67,6 +67,10 @@ export const EditorPanel = () => { LOCAL_STORAGE_KEYS.HOTKEY_SIDEBAR(SIDEBAR_KEYS.EDITOR_PANEL), true ) + const [isAIAssistantHotkeyEnabled] = useLocalStorageQuery( + LOCAL_STORAGE_KEYS.HOTKEY_SIDEBAR(SIDEBAR_KEYS.AI_ASSISTANT), + true + ) const currentValue = value || '' @@ -294,6 +298,7 @@ export const EditorPanel = () => { executeQuery={onExecuteSql} onClose={handleClosePanel} closeShortcutEnabled={isInlineEditorHotkeyEnabled} + openAIAssistantShortcutEnabled={isAIAssistantHotkeyEnabled} /> diff --git a/apps/studio/data/config/project-storage-config-query.ts b/apps/studio/data/config/project-storage-config-query.ts index aab5b975dc965..ab1bc6350a284 100644 --- a/apps/studio/data/config/project-storage-config-query.ts +++ b/apps/studio/data/config/project-storage-config-query.ts @@ -1,5 +1,6 @@ import { useQuery } from '@tanstack/react-query' +import { useFlag } from 'common' import { components } from 'data/api' import { get, handleError } from 'data/fetchers' import { IS_PLATFORM } from 'lib/constants' @@ -56,3 +57,9 @@ export const useIsAnalyticsBucketsEnabled = ({ projectRef }: { projectRef?: stri const { data } = useProjectStorageConfigQuery({ projectRef }) return !!data?.features.icebergCatalog?.enabled } + +export const useIsVectorBucketsEnabled = ({ projectRef }: { projectRef?: string }) => { + // [Joshen] Temp using feature flag - will need to shift to storage config like analytics bucket once ready + const isVectorBucketsEnabled = useFlag('storageAnalyticsVector') + return isVectorBucketsEnabled +} diff --git a/apps/studio/data/storage/iceberg-wrapper-create-mutation.ts b/apps/studio/data/storage/iceberg-wrapper-create-mutation.ts index 0b2c08edf9763..d8703c1824180 100644 --- a/apps/studio/data/storage/iceberg-wrapper-create-mutation.ts +++ b/apps/studio/data/storage/iceberg-wrapper-create-mutation.ts @@ -4,7 +4,7 @@ import { WRAPPERS } from 'components/interfaces/Integrations/Wrappers/Wrappers.c import { getAnalyticsBucketFDWName, getAnalyticsBucketS3KeyName, -} from 'components/interfaces/Storage/AnalyticsBucketDetails/AnalyticsBucketDetails.utils' +} from 'components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/AnalyticsBucketDetails.utils' import { getCatalogURI, getConnectionURL, diff --git a/apps/studio/data/storage/keys.ts b/apps/studio/data/storage/keys.ts index 5d796b07b0dcc..2ff2c226d0ef1 100644 --- a/apps/studio/data/storage/keys.ts +++ b/apps/studio/data/storage/keys.ts @@ -2,6 +2,12 @@ export const storageKeys = { buckets: (projectRef: string | undefined) => ['projects', projectRef, 'buckets'] as const, analyticsBuckets: (projectRef: string | undefined) => ['projects', projectRef, 'analytics-buckets'] as const, + vectorBuckets: (projectRef: string | undefined) => + ['projects', projectRef, 'vector-buckets'] as const, + vectorBucket: (projectRef: string | undefined, vectorbucketName: string | undefined) => + ['projects', projectRef, 'vector-bucket', vectorbucketName] as const, + vectorBucketsIndexes: (projectRef: string | undefined, vectorBucketName: string | undefined) => + ['projects', projectRef, 'vector-buckets', vectorBucketName, 'indexes'] as const, archive: (projectRef: string | undefined) => ['projects', projectRef, 'archive'] as const, icebergNamespaces: (catalog: string, warehouse: string) => ['catalog', catalog, 'warehouse', warehouse, 'namespaces'] as const, diff --git a/apps/studio/data/storage/vector-bucket-create-mutation.ts b/apps/studio/data/storage/vector-bucket-create-mutation.ts new file mode 100644 index 0000000000000..749b2a906e169 --- /dev/null +++ b/apps/studio/data/storage/vector-bucket-create-mutation.ts @@ -0,0 +1,56 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { handleError, post } from 'data/fetchers' +import type { ResponseError } from 'types' +import { storageKeys } from './keys' + +type VectorBucketCreateVariables = { + projectRef: string + bucketName: string +} + +async function createVectorBucket({ projectRef, bucketName }: VectorBucketCreateVariables) { + if (!projectRef) throw new Error('projectRef is required') + if (!bucketName) throw new Error('Bucket name is required') + + const { data, error } = await post('/platform/storage/{ref}/vector-buckets', { + params: { path: { ref: projectRef } }, + body: { bucketName: bucketName }, + }) + + if (error) handleError(error) + + // [Joshen] JFYI typed incorrectly in API, to fix + return data as { name: string } +} + +type VectorBucketCreateData = Awaited> + +export const useVectorBucketCreateMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (vars) => createVectorBucket(vars), + async onSuccess(data, variables, context) { + const { projectRef } = variables + await queryClient.invalidateQueries(storageKeys.vectorBuckets(projectRef)) + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to create vector bucket: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + }) +} diff --git a/apps/studio/data/storage/vector-bucket-delete-mutation.ts b/apps/studio/data/storage/vector-bucket-delete-mutation.ts new file mode 100644 index 0000000000000..2627fb9dd7550 --- /dev/null +++ b/apps/studio/data/storage/vector-bucket-delete-mutation.ts @@ -0,0 +1,55 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { del, handleError } from 'data/fetchers' +import type { ResponseError } from 'types' +import { storageKeys } from './keys' + +type VectorBucketDeleteVariables = { + projectRef: string + bucketName: string +} + +async function deleteVectorBucket({ projectRef, bucketName }: VectorBucketDeleteVariables) { + if (!projectRef) throw new Error('projectRef is required') + if (!bucketName) throw new Error('Bucket name is required') + + const { data, error } = await del('/platform/storage/{ref}/vector-buckets/{id}', { + params: { path: { ref: projectRef, id: bucketName } }, + }) + + if (error) handleError(error) + + // [Joshen] JFYI typed incorrectly in API, to fix + return data as { name: string } +} + +type VectorBucketDeleteData = Awaited> + +export const useVectorBucketDeleteMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (vars) => deleteVectorBucket(vars), + async onSuccess(data, variables, context) { + const { projectRef } = variables + await queryClient.invalidateQueries(storageKeys.vectorBuckets(projectRef)) + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to delete vector bucket: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + }) +} diff --git a/apps/studio/data/storage/vector-bucket-index-create-mutation.ts b/apps/studio/data/storage/vector-bucket-index-create-mutation.ts new file mode 100644 index 0000000000000..ed78cbb21fbe3 --- /dev/null +++ b/apps/studio/data/storage/vector-bucket-index-create-mutation.ts @@ -0,0 +1,67 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import type { components } from 'api-types' +import { handleError, post } from 'data/fetchers' +import type { ResponseError } from 'types' +import { storageKeys } from './keys' + +type VectorBucketIndexCreateVariables = components['schemas']['CreateBucketIndexBody'] & { + projectRef: string + bucketName: string +} + +async function createVectorBucketIndex({ + projectRef, + bucketName, + ...rest +}: VectorBucketIndexCreateVariables) { + if (!projectRef) throw new Error('projectRef is required') + if (!bucketName) throw new Error('Bucket name is required') + + const { data, error } = await post('/platform/storage/{ref}/vector-buckets/{id}/indexes', { + params: { path: { ref: projectRef, id: bucketName } }, + body: { + dataType: 'float32', + dimension: rest.dimension, + distanceMetric: rest.distanceMetric, + indexName: rest.indexName, + metadataKeys: rest.metadataKeys, + }, + }) + + if (error) handleError(error) + + // [Joshen] JFYI typed incorrectly in API, to fix + return data as { name: string } +} + +type VectorBucketIndexCreateData = Awaited> + +export const useVectorBucketIndexCreateMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (vars) => createVectorBucketIndex(vars), + async onSuccess(data, variables, context) { + const { projectRef, bucketName } = variables + await queryClient.invalidateQueries(storageKeys.vectorBucketsIndexes(projectRef, bucketName)) + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to create vector bucket index: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + }) +} diff --git a/apps/studio/data/storage/vector-bucket-index-delete-mutation.ts b/apps/studio/data/storage/vector-bucket-index-delete-mutation.ts new file mode 100644 index 0000000000000..1f3f8514f5152 --- /dev/null +++ b/apps/studio/data/storage/vector-bucket-index-delete-mutation.ts @@ -0,0 +1,64 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { del, handleError } from 'data/fetchers' +import type { ResponseError } from 'types' +import { storageKeys } from './keys' + +type VectorBucketIndexDeleteVariables = { + projectRef: string + bucketName: string + indexName: string +} + +export async function deleteVectorBucketIndex({ + projectRef, + bucketName, + indexName, +}: VectorBucketIndexDeleteVariables) { + if (!projectRef) throw new Error('projectRef is required') + if (!bucketName) throw new Error('Bucket name is required') + if (!indexName) throw new Error('Index name is required') + + const { data, error } = await del( + '/platform/storage/{ref}/vector-buckets/{id}/indexes/{indexName}', + { + params: { path: { ref: projectRef, id: bucketName, indexName } }, + } + ) + + if (error) handleError(error) + + // [Joshen] JFYI typed incorrectly in API, to fix + return data as { name: string } +} + +type VectorBucketIndexDeleteData = Awaited> + +export const useVectorBucketIndexDeleteMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (vars) => deleteVectorBucketIndex(vars), + async onSuccess(data, variables, context) { + const { projectRef, bucketName } = variables + await queryClient.invalidateQueries(storageKeys.vectorBucketsIndexes(projectRef, bucketName)) + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to delete vector bucket index: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + }) +} diff --git a/apps/studio/data/storage/vector-bucket-query.ts b/apps/studio/data/storage/vector-bucket-query.ts new file mode 100644 index 0000000000000..7d27003619960 --- /dev/null +++ b/apps/studio/data/storage/vector-bucket-query.ts @@ -0,0 +1,49 @@ +import { useQuery, UseQueryOptions } from '@tanstack/react-query' + +import { components } from 'api-types' +import { get, handleError } from 'data/fetchers' +import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { PROJECT_STATUS } from 'lib/constants' +import type { ResponseError } from 'types' +import { storageKeys } from './keys' + +// [Joshen] JFYI typed incorrectly in API, to fix by adding creationTime to APi +export type VectorBucket = components['schemas']['StorageVectorBucketResponse'] & { + creationTime: string +} + +export type VectorBucketVariables = { projectRef?: string; vectorBucketName?: string } + +export async function getVectorBucket( + { projectRef, vectorBucketName }: VectorBucketVariables, + signal?: AbortSignal +) { + if (!projectRef) throw new Error('projectRef is required') + if (!vectorBucketName) throw new Error('vectorBucketName is required') + + const { data, error } = await get('/platform/storage/{ref}/vector-buckets/{id}', { + params: { path: { ref: projectRef, id: vectorBucketName } }, + signal, + }) + + if (error) handleError(error) + return data as VectorBucket +} + +export type VectorBucketData = Awaited> +export type VectorBucketError = ResponseError + +export const useVectorBucketQuery = ( + { projectRef, vectorBucketName }: VectorBucketVariables, + { enabled = true, ...options }: UseQueryOptions = {} +) => { + const { data: project } = useSelectedProjectQuery() + const isActive = project?.status === PROJECT_STATUS.ACTIVE_HEALTHY + + return useQuery({ + queryKey: storageKeys.vectorBucket(projectRef, vectorBucketName), + queryFn: ({ signal }) => getVectorBucket({ projectRef, vectorBucketName }, signal), + enabled: enabled && typeof projectRef !== 'undefined' && isActive, + ...options, + }) +} diff --git a/apps/studio/data/storage/vector-buckets-indexes-query.ts b/apps/studio/data/storage/vector-buckets-indexes-query.ts new file mode 100644 index 0000000000000..16804b1babeca --- /dev/null +++ b/apps/studio/data/storage/vector-buckets-indexes-query.ts @@ -0,0 +1,53 @@ +import { useQuery, UseQueryOptions } from '@tanstack/react-query' + +import { components } from 'api-types' +import { get, handleError } from 'data/fetchers' +import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { PROJECT_STATUS } from 'lib/constants' +import type { ResponseError } from 'types' +import { storageKeys } from './keys' + +export type VectorBucketIndex = + components['schemas']['StorageVectorBucketListIndexesResponse']['indexes'][number] +export type GetVectorBucketsIndexesVariables = { projectRef?: string; vectorBucketName?: string } + +export async function getVectorBucketsIndexes( + { projectRef, vectorBucketName }: GetVectorBucketsIndexesVariables, + signal?: AbortSignal +) { + if (!projectRef) throw new Error('projectRef is required') + if (!vectorBucketName) throw new Error('vectorBucketName is required') + + const { data, error } = await get('/platform/storage/{ref}/vector-buckets/{id}/indexes', { + params: { path: { ref: projectRef, id: vectorBucketName } }, + signal, + }) + + if (error) handleError(error) + return data +} + +export type VectorBucketsIndexesData = Awaited> +export type VectorBucketsIndexesError = ResponseError + +export const useVectorBucketsIndexesQuery = ( + { projectRef, vectorBucketName }: GetVectorBucketsIndexesVariables, + { + enabled = true, + ...options + }: UseQueryOptions = {} +) => { + const { data: project } = useSelectedProjectQuery() + const isActive = project?.status === PROJECT_STATUS.ACTIVE_HEALTHY + + return useQuery({ + queryKey: storageKeys.vectorBucketsIndexes(projectRef, vectorBucketName), + queryFn: ({ signal }) => getVectorBucketsIndexes({ projectRef, vectorBucketName }, signal), + enabled: + enabled && + isActive && + typeof projectRef !== 'undefined' && + typeof vectorBucketName !== 'undefined', + ...options, + }) +} diff --git a/apps/studio/data/storage/vector-buckets-query.ts b/apps/studio/data/storage/vector-buckets-query.ts new file mode 100644 index 0000000000000..79ca60b04e46c --- /dev/null +++ b/apps/studio/data/storage/vector-buckets-query.ts @@ -0,0 +1,44 @@ +import { useQuery, UseQueryOptions } from '@tanstack/react-query' + +import { get, handleError } from 'data/fetchers' +import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { PROJECT_STATUS } from 'lib/constants' +import type { ResponseError } from 'types' +import { storageKeys } from './keys' + +export type VectorBucketsVariables = { projectRef?: string } + +export async function getVectorBuckets( + { projectRef }: VectorBucketsVariables, + signal?: AbortSignal +) { + if (!projectRef) throw new Error('projectRef is required') + + const { data, error } = await get('/platform/storage/{ref}/vector-buckets', { + params: { path: { ref: projectRef } }, + signal, + }) + + if (error) handleError(error) + + // [Joshen] JFYI typed incorrectly in API, to fix by adding creationTime to API + return data as { vectorBuckets: { vectorBucketName: string; creationTime: string }[] } +} + +export type VectorBucketsData = Awaited> +export type VectorBucketsError = ResponseError + +export const useVectorBucketsQuery = ( + { projectRef }: VectorBucketsVariables, + { enabled = true, ...options }: UseQueryOptions = {} +) => { + const { data: project } = useSelectedProjectQuery() + const isActive = project?.status === PROJECT_STATUS.ACTIVE_HEALTHY + + return useQuery({ + queryKey: storageKeys.vectorBuckets(projectRef), + queryFn: ({ signal }) => getVectorBuckets({ projectRef }, signal), + enabled: enabled && typeof projectRef !== 'undefined' && isActive, + ...options, + }) +} diff --git a/apps/studio/pages/project/[ref]/reports/query-performance.tsx b/apps/studio/pages/project/[ref]/reports/query-performance.tsx index ebd2672286049..18744de603b5f 100644 --- a/apps/studio/pages/project/[ref]/reports/query-performance.tsx +++ b/apps/studio/pages/project/[ref]/reports/query-performance.tsx @@ -1,4 +1,4 @@ -import { parseAsArrayOf, parseAsString, useQueryStates } from 'nuqs' +import { parseAsArrayOf, parseAsInteger, parseAsString, useQueryStates } from 'nuqs' import { useParams } from 'common' import { useIndexAdvisorStatus } from 'components/interfaces/QueryPerformance/hooks/useIsIndexAdvisorStatus' @@ -37,11 +37,12 @@ const QueryPerformanceReport: NextPageWithLayout = () => { handleDatePickerChange, } = useReportDateRange(REPORT_DATERANGE_HELPER_LABELS.LAST_60_MINUTES) - const [{ search: searchQuery, roles }] = useQueryStates({ + const [{ search: searchQuery, roles, minCalls }] = useQueryStates({ sort: parseAsString, order: parseAsString, search: parseAsString.withDefault(''), roles: parseAsArrayOf(parseAsString).withDefault([]), + minCalls: parseAsInteger, }) const config = PRESET_CONFIG[Presets.QUERY_PERFORMANCE] @@ -55,6 +56,7 @@ const QueryPerformanceReport: NextPageWithLayout = () => { preset: 'unified', roles, runIndexAdvisor: isIndexAdvisorEnabled, + minCalls: minCalls ?? undefined, }) const isPgStatMonitorEnabled = project?.dbVersion === '17.4.1.076-psml-1' diff --git a/apps/studio/pages/project/[ref]/storage/analytics/buckets/[bucketId].tsx b/apps/studio/pages/project/[ref]/storage/analytics/buckets/[bucketId].tsx index 8330211db67eb..81f27b65765ed 100644 --- a/apps/studio/pages/project/[ref]/storage/analytics/buckets/[bucketId].tsx +++ b/apps/studio/pages/project/[ref]/storage/analytics/buckets/[bucketId].tsx @@ -1,51 +1,31 @@ -import Link from 'next/link' - import { useParams } from 'common' -import { AnalyticBucketDetails } from 'components/interfaces/Storage/AnalyticsBucketDetails' -import StorageBucketsError from 'components/interfaces/Storage/StorageBucketsError' -import { useSelectedBucket } from 'components/interfaces/Storage/StorageExplorer/useSelectedBucket' +import { AnalyticBucketDetails } from 'components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails' +import { BUCKET_TYPES } from 'components/interfaces/Storage/Storage.constants' import DefaultLayout from 'components/layouts/DefaultLayout' +import { PageLayout } from 'components/layouts/PageLayout/PageLayout' import StorageLayout from 'components/layouts/StorageLayout/StorageLayout' -import { AnalyticsBucket } from 'data/storage/analytics-buckets-query' +import { DocsButton } from 'components/ui/DocsButton' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' -import { useStorageExplorerStateSnapshot } from 'state/storage-explorer' import type { NextPageWithLayout } from 'types' -import { Button } from 'ui' -import { Admonition } from 'ui-patterns' const AnalyticsBucketPage: NextPageWithLayout = () => { + const config = BUCKET_TYPES.analytics const { bucketId } = useParams() const { data: project } = useSelectedProjectQuery() - const { projectRef } = useStorageExplorerStateSnapshot() - const { bucket, error, isSuccess, isError } = useSelectedBucket() - - // [Joshen] Checking against projectRef from storage explorer to check if the store has initialized - // We can probably replace this with a better skeleton loader that's more representative of the page layout - if (!project || !projectRef) return null return ( -
- {isError && } - - {isSuccess ? ( - !bucket ? ( -
- - - -
- ) : ( - - ) - ) : null} -
+ ] : []} + > + + ) } diff --git a/apps/studio/pages/project/[ref]/storage/vectors/buckets/[bucketId].tsx b/apps/studio/pages/project/[ref]/storage/vectors/buckets/[bucketId].tsx new file mode 100644 index 0000000000000..7dc4b3559b76b --- /dev/null +++ b/apps/studio/pages/project/[ref]/storage/vectors/buckets/[bucketId].tsx @@ -0,0 +1,35 @@ +import { useParams } from 'common' +import { BUCKET_TYPES } from 'components/interfaces/Storage/Storage.constants' +import { VectorBucketDetails } from 'components/interfaces/Storage/VectorBuckets/VectorBucketDetails' +import DefaultLayout from 'components/layouts/DefaultLayout' +import { PageLayout } from 'components/layouts/PageLayout/PageLayout' +import StorageLayout from 'components/layouts/StorageLayout/StorageLayout' +import { DocsButton } from 'components/ui/DocsButton' +import { useStorageExplorerStateSnapshot } from 'state/storage-explorer' +import type { NextPageWithLayout } from 'types' + +const VectorsBucketPage: NextPageWithLayout = () => { + const config = BUCKET_TYPES['vectors'] + const { bucketId } = useParams() + const { projectRef } = useStorageExplorerStateSnapshot() + + return ( + ] : []} + > +
+ +
+
+ ) +} + +VectorsBucketPage.getLayout = (page) => ( + + {page} + +) + +export default VectorsBucketPage diff --git a/apps/studio/pages/project/[ref]/storage/vectors/index.tsx b/apps/studio/pages/project/[ref]/storage/vectors/index.tsx index 5064b24ef35cf..0177e3311f7cb 100644 --- a/apps/studio/pages/project/[ref]/storage/vectors/index.tsx +++ b/apps/studio/pages/project/[ref]/storage/vectors/index.tsx @@ -1,12 +1,15 @@ +import { useParams } from 'common' import { BucketsComingSoon } from 'components/interfaces/Storage/BucketsComingSoon' -import { VectorsBuckets } from 'components/interfaces/Storage/VectorsBuckets' +import { VectorsBuckets } from 'components/interfaces/Storage/VectorBuckets' import DefaultLayout from 'components/layouts/DefaultLayout' import { StorageBucketsLayout } from 'components/layouts/StorageLayout/StorageBucketsLayout' import StorageLayout from 'components/layouts/StorageLayout/StorageLayout' +import { useIsVectorBucketsEnabled } from 'data/config/project-storage-config-query' import type { NextPageWithLayout } from 'types' const StorageVectorsPage: NextPageWithLayout = () => { - const isVectorBucketsEnabled = false + const { ref: projectRef } = useParams() + const isVectorBucketsEnabled = useIsVectorBucketsEnabled({ projectRef }) return isVectorBucketsEnabled ? : } diff --git a/apps/www/_events/2025-12-02__supabase-athens-meetup.mdx b/apps/www/_events/2025-12-02__supabase-athens-meetup.mdx new file mode 100644 index 0000000000000..eb46efc7a56f9 --- /dev/null +++ b/apps/www/_events/2025-12-02__supabase-athens-meetup.mdx @@ -0,0 +1,12 @@ +--- +title: 'Supabase Athens Meetup' +type: 'meetup' +onDemand: false +disable_page_build: true +link: { href: https://www.meetup.com/supabase-athens-community/events/311840751/, target: '_blank' } +date: '2025-12-02T19:00:00.000+02:00' +timezone: 'Europe/Athens' +duration: '2 hours' +categories: + - meetup +--- diff --git a/apps/www/components/Blog/BlogFilters.tsx b/apps/www/components/Blog/BlogFilters.tsx index be159916a1d3c..3bcbdbf3afc28 100644 --- a/apps/www/components/Blog/BlogFilters.tsx +++ b/apps/www/components/Blog/BlogFilters.tsx @@ -2,8 +2,7 @@ import { LOCAL_STORAGE_KEYS, useBreakpoint } from 'common' import { startCase } from 'lib/helpers' -import { useSearchParams } from 'next/navigation' -import { useRouter } from 'next/compat/router' +import { useSearchParams, useRouter } from 'next/navigation' import { useEffect, useState } from 'react' import type { BlogView } from 'app/blog/BlogClient' import type PostTypes from 'types/post' @@ -75,14 +74,13 @@ function BlogFilters({ allPosts, setPosts, view, setView }: Props) { const handleReplaceRouter = () => { if (!searchTerm && category !== 'all' && router) { - router.query.category = category - router?.replace(router, undefined, { shallow: true, scroll: false }) + router?.replace(`/blog?category=${category}`, { scroll: false }) } } const handlePosts = () => { // construct an array of blog posts - // not inluding the first blog post + // not including the first blog post const shiftedBlogs = [...allPosts] shiftedBlogs.shift() @@ -113,19 +111,19 @@ function BlogFilters({ allPosts, setPosts, view, setView }: Props) { }, [isMobile]) useEffect(() => { - if (router?.isReady && q) { + if (q) { setSearchTerm(q) } - if (router?.isReady && activeCategory && activeCategory !== 'all') { + if (activeCategory && activeCategory !== 'all') { setCategory(activeCategory) } - }, [activeCategory, router?.isReady, q]) + }, [activeCategory, q]) function handleSearchByText(text: string) { setSearchTerm(text) - searchParams?.has('q') && router?.replace('/blog', undefined, { shallow: true, scroll: false }) - router?.replace(`/blog?q=${text}`, undefined, { shallow: true, scroll: false }) - if (text.length < 1) router?.replace('/blog', undefined, { shallow: true, scroll: false }) + searchParams?.has('q') && router?.replace('/blog', { scroll: false }) + router?.replace(`/blog?q=${text}`, { scroll: false }) + if (text.length < 1) router?.replace('/blog', { scroll: false }) const matches = allPosts.filter((post: any) => { const found = @@ -143,9 +141,8 @@ function BlogFilters({ allPosts, setPosts, view, setView }: Props) { searchTerm && setSearchTerm('') setCategory(category) category === 'all' - ? router?.replace('/blog', undefined, { shallow: true, scroll: false }) - : router?.replace(`/blog?category=${category}`, undefined, { - shallow: true, + ? router?.replace('/blog', { scroll: false }) + : router?.replace(`/blog?category=${category}`, { scroll: false, }) } diff --git a/e2e/studio/features/table-editor.spec.ts b/e2e/studio/features/table-editor.spec.ts index 106a928f067e7..3578160635684 100644 --- a/e2e/studio/features/table-editor.spec.ts +++ b/e2e/studio/features/table-editor.spec.ts @@ -205,23 +205,11 @@ test.describe.serial('table editor', () => { await expect(page.getByLabel(`View ${authTableSso}`)).toBeVisible() await expect(page.getByLabel(`View ${authTableMfa}`)).toBeVisible() - // filter by querying + // can find auth tables await page.getByRole('textbox', { name: 'Search tables...' }).fill('mfa') await waitForTableToLoad(page, ref, 'auth') // load tables await expect(page.getByLabel(`View ${authTableSso}`)).not.toBeVisible() await expect(page.getByLabel(`View ${authTableMfa}`)).toBeVisible() - - // navigate to policies page when view policies action is clicked - await page.getByRole('button', { name: `View ${authTableMfa}` }).click() - await page.waitForURL(/\/editor\/\d+\?schema=auth$/) - await page - .getByRole('button', { name: `View ${authTableMfa}` }) - .getByRole('button') - .nth(1) - .click() - await page.getByRole('menuitem', { name: 'View policies' }).click() - await page.waitForURL(/.*\/policies\?schema=auth/) - expect(page.url()).toContain('auth/policies?schema=auth') }) test('should show rls accordingly', async ({ ref }) => { diff --git a/packages/api-types/types/api.d.ts b/packages/api-types/types/api.d.ts index 89941bc713e6c..c5085b443b441 100644 --- a/packages/api-types/types/api.d.ts +++ b/packages/api-types/types/api.d.ts @@ -471,10 +471,12 @@ export interface paths { * Gets project's logs * @description Executes a SQL query on the project's logs. * - * Either the 'iso_timestamp_start' and 'iso_timestamp_end' parameters must be provided. + * Either the `iso_timestamp_start` and `iso_timestamp_end` parameters must be provided. * If both are not provided, only the last 1 minute of logs will be queried. * The timestamp range must be no more than 24 hours and is rounded to the nearest minute. If the range is more than 24 hours, a validation error will be thrown. * + * Note: Unless the `sql` parameter is provided, only edge_logs will be queried. See the [log query docs](/docs/guides/telemetry/logs?queryGroups=product&product=postgres&queryGroups=source&source=edge_logs#querying-with-the-logs-explorer:~:text=logs%20from%20the-,Sources,-drop%2Ddown%3A) for all available sources. + * */ get: operations['v1-get-project-logs'] put?: never @@ -2537,6 +2539,7 @@ export interface components { /** @enum {string} */ message: 'ok' } + DeleteSecretsBody: string[] DeployFunctionResponse: { /** Format: int64 */ created_at?: number @@ -3349,6 +3352,7 @@ export interface components { iceberg_catalog: boolean list_v2: boolean } + databasePoolMode: string external: { /** @enum {string} */ upstreamTarget: 'main' | 'canary' @@ -3366,6 +3370,7 @@ export interface components { } /** Format: int64 */ fileSizeLimit: number + migrationVersion: string } StreamableFile: Record SubdomainAvailabilityResponse: { @@ -4300,7 +4305,7 @@ export interface components { V1RestorePointResponse: { name: string /** @enum {string} */ - status: 'AVAILABLE' | 'PENDING' | 'REMOVED' + status: 'AVAILABLE' | 'PENDING' | 'REMOVED' | 'FAILED' } V1RunQueryBody: { parameters?: unknown[] @@ -5517,6 +5522,7 @@ export interface operations { query?: { iso_timestamp_end?: string iso_timestamp_start?: string + /** @description Custom SQL query to execute on the logs. See [querying logs](/docs/guides/telemetry/logs?queryGroups=product&product=postgres&queryGroups=source&source=edge_logs#querying-with-the-logs-explorer) for more details. */ sql?: string } header?: never @@ -10399,7 +10405,7 @@ export interface operations { } requestBody: { content: { - 'application/json': string[] + 'application/json': components['schemas']['DeleteSecretsBody'] } } responses: { diff --git a/packages/api-types/types/platform.d.ts b/packages/api-types/types/platform.d.ts index 9c9e5ade4b4ec..2a9186388b709 100644 --- a/packages/api-types/types/platform.d.ts +++ b/packages/api-types/types/platform.d.ts @@ -2029,23 +2029,6 @@ export interface paths { patch?: never trace?: never } - '/platform/profile/password-check': { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - get?: never - put?: never - /** Check password strength */ - post: operations['PasswordCheckController_checkPassword'] - delete?: never - options?: never - head?: never - patch?: never - trace?: never - } '/platform/profile/permissions': { parameters: { query?: never @@ -5232,7 +5215,7 @@ export interface components { * @description Namespace * @example my-namespace */ - namespace: string + namespace?: string /** * @description Project ref * @example abcdefghijklmnopqrst @@ -5308,7 +5291,7 @@ export interface components { * @description Namespace * @example my-namespace */ - namespace: string + namespace?: string /** * @description Project ref * @example abcdefghijklmnopqrst @@ -5585,6 +5568,9 @@ export interface components { id: string secret_key: string } + CreateStorageVectorBucketBody: { + bucketName: string + } CreateTaxIdBody: { country?: string type: string @@ -7534,18 +7520,6 @@ export interface components { organization_id: number overdue_invoice_count: number } - PasswordCheckBody: { - password: string - } - PasswordCheckResponse: { - result: { - feedback: { - suggestions: string[] - warning: string - } - score: number - } - } PauseStatusResponse: { can_restore: boolean last_paused_on: string | null @@ -8326,7 +8300,7 @@ export interface components { * @description Namespace * @example my-namespace */ - namespace: string + namespace?: string /** * @description Project ref * @example abcdefghijklmnopqrst @@ -8414,7 +8388,7 @@ export interface components { * @description Namespace * @example my-namespace */ - namespace: string + namespace?: string /** * @description Project ref * @example abcdefghijklmnopqrst @@ -9112,6 +9086,7 @@ export interface components { iceberg_catalog: boolean list_v2: boolean } + databasePoolMode: string external: { /** @enum {string} */ upstreamTarget: 'main' | 'canary' @@ -9129,6 +9104,7 @@ export interface components { } /** Format: int64 */ fileSizeLimit: number + migrationVersion: string } StorageListResponseV2: { folders: { @@ -10054,7 +10030,7 @@ export interface components { * @description Namespace * @example my-namespace */ - namespace: string + namespace?: string /** * @description Project ref * @example abcdefghijklmnopqrst @@ -10130,7 +10106,7 @@ export interface components { * @description Namespace * @example my-namespace */ - namespace: string + namespace?: string /** * @description Project ref * @example abcdefghijklmnopqrst @@ -17006,36 +16982,6 @@ export interface operations { } } } - PasswordCheckController_checkPassword: { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - requestBody: { - content: { - 'application/json': components['schemas']['PasswordCheckBody'] - } - } - responses: { - 201: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['PasswordCheckResponse'] - } - } - /** @description Failed to check password strength */ - 500: { - headers: { - [name: string]: unknown - } - content?: never - } - } - } PermissionsController_getPermissions: { parameters: { query?: never @@ -25280,7 +25226,9 @@ export interface operations { } StorageVectorBucketsController_getBuckets: { parameters: { - query?: never + query?: { + nextToken?: string + } header?: never path: { /** @description Project ref */ @@ -25338,7 +25286,11 @@ export interface operations { } cookie?: never } - requestBody?: never + requestBody: { + content: { + 'application/json': components['schemas']['CreateStorageVectorBucketBody'] + } + } responses: { 201: { headers: { diff --git a/packages/common/hooks/useBreakpoint.tsx b/packages/common/hooks/useBreakpoint.tsx index 8bdcb89044921..bb52792f4ecac 100644 --- a/packages/common/hooks/useBreakpoint.tsx +++ b/packages/common/hooks/useBreakpoint.tsx @@ -17,7 +17,7 @@ const twBreakpointMap = { sm: 639, md: 767, lg: 1023, - xl: 1027, + xl: 1279, '2xl': 1535, } diff --git a/packages/pg-meta/src/sql/studio/get-users-paginated.ts b/packages/pg-meta/src/sql/studio/get-users-paginated.ts index edd2b9e821e9c..280aa9b36ac4c 100644 --- a/packages/pg-meta/src/sql/studio/get-users-paginated.ts +++ b/packages/pg-meta/src/sql/studio/get-users-paginated.ts @@ -42,19 +42,28 @@ function prefixToUUID(prefix: string, max: boolean) { return mapped.join('') } -function stringRange(prefix: string) { +function stringRange(prefix: string): [string, string | undefined] { if (!prefix) { return [prefix, undefined] } - const lastChar = prefix.charCodeAt(prefix.length - 1) + const lastCharCode = prefix.charCodeAt(prefix.length - 1) + const TILDE_CHAR_CODE = 126 // '~' + const Z_CHAR_CODE = 122 // 'z' - if (lastChar >= `~`.charCodeAt(0)) { - // not ASCII - return [prefix, prefix] + // 'z' (122): append '~' to avoid PostgreSQL collation issues with '{' + if (lastCharCode === Z_CHAR_CODE) { + return [prefix, prefix + '~'] } - return [prefix, prefix.substring(0, prefix.length - 1) + String.fromCharCode(lastChar + 1)] + // '~' (126) or beyond: append space since we can't increment further + if (lastCharCode >= TILDE_CHAR_CODE) { + return [prefix, prefix + ' '] + } + + // All other characters: increment the last character + const upperBound = prefix.substring(0, prefix.length - 1) + String.fromCharCode(lastCharCode + 1) + return [prefix, upperBound] } export const getPaginatedUsersSQL = ({ diff --git a/supa-mdx-lint/Rule001HeadingCase.toml b/supa-mdx-lint/Rule001HeadingCase.toml index 3aec277295e0a..068b291e34a00 100644 --- a/supa-mdx-lint/Rule001HeadingCase.toml +++ b/supa-mdx-lint/Rule001HeadingCase.toml @@ -125,6 +125,8 @@ may_uppercase = [ "LlamaIndex", "Llamafile", "Logs Explorer", + "Lovable", + "Lovable Cloud", "Magic Link", "Mailpit", "Management API", diff --git a/supa-mdx-lint/Rule003Spelling.toml b/supa-mdx-lint/Rule003Spelling.toml index b0f91abca1a65..c8830de5c2ee3 100644 --- a/supa-mdx-lint/Rule003Spelling.toml +++ b/supa-mdx-lint/Rule003Spelling.toml @@ -224,6 +224,8 @@ allow_list = [ "LlamaIndex", "Llamafile", "Logflare", + "Lovable", + "Lovable Cloud", "Lua", "Mailgun", "Mailpit",