diff --git a/apps/design-system/__registry__/index.tsx b/apps/design-system/__registry__/index.tsx index 1ea214369aa90..12c8f4c965be6 100644 --- a/apps/design-system/__registry__/index.tsx +++ b/apps/design-system/__registry__/index.tsx @@ -1677,6 +1677,39 @@ export const Index: Record = { subcategory: "undefined", chunks: [] }, + "success-check-demo": { + name: "success-check-demo", + type: "components:example", + registryDependencies: undefined, + component: React.lazy(() => import("@/registry/default/example/success-check-demo")), + source: "", + files: ["registry/default/example/success-check-demo.tsx"], + category: "undefined", + subcategory: "undefined", + chunks: [] + }, + "success-check-selected": { + name: "success-check-selected", + type: "components:example", + registryDependencies: undefined, + component: React.lazy(() => import("@/registry/default/example/success-check-selected")), + source: "", + files: ["registry/default/example/success-check-selected.tsx"], + category: "undefined", + subcategory: "undefined", + chunks: [] + }, + "success-check-progress": { + name: "success-check-progress", + type: "components:example", + registryDependencies: undefined, + component: React.lazy(() => import("@/registry/default/example/success-check-progress")), + source: "", + files: ["registry/default/example/success-check-progress.tsx"], + category: "undefined", + subcategory: "undefined", + chunks: [] + }, "switch-demo": { name: "switch-demo", type: "components:example", diff --git a/apps/design-system/config/docs.ts b/apps/design-system/config/docs.ts index bfb6de815792a..6e72373cf3900 100644 --- a/apps/design-system/config/docs.ts +++ b/apps/design-system/config/docs.ts @@ -487,6 +487,11 @@ export const docsConfig: DocsConfig = { href: '/docs/components/sonner', items: [], }, + { + title: 'Success Check', + href: '/docs/components/success-check', + items: [], + }, { title: 'Switch', href: '/docs/components/switch', diff --git a/apps/design-system/content/docs/components/success-check.mdx b/apps/design-system/content/docs/components/success-check.mdx new file mode 100644 index 0000000000000..42987ea74f98f --- /dev/null +++ b/apps/design-system/content/docs/components/success-check.mdx @@ -0,0 +1,33 @@ +--- +title: Success Check +description: A brand-coloured success indicator with a check mark. +component: true +--- + +Use `SuccessCheck` when you need a compact, consistent mark for: + +- **Selected state:** e.g. the chosen organisation in a list of cards +- **Completion progress:** e.g. finished steps in an upgrade or setup flow + + + +## Usage + +```tsx +import { SuccessCheck } from 'ui' +; +``` + +## Examples + +### Selected state + +Show which option is currently selected. Position the check absolutely in the trailing corner of the selectable item. + + + +### Completion progress + +Mark finished steps in a linear progress list. Incomplete steps use a plain circle instead. + + diff --git a/apps/design-system/registry/default/example/success-check-demo.tsx b/apps/design-system/registry/default/example/success-check-demo.tsx new file mode 100644 index 0000000000000..d778a8ea26834 --- /dev/null +++ b/apps/design-system/registry/default/example/success-check-demo.tsx @@ -0,0 +1,5 @@ +import { SuccessCheck } from 'ui' + +export default function SuccessCheckDemo() { + return +} diff --git a/apps/design-system/registry/default/example/success-check-progress.tsx b/apps/design-system/registry/default/example/success-check-progress.tsx new file mode 100644 index 0000000000000..09552bccd5cd2 --- /dev/null +++ b/apps/design-system/registry/default/example/success-check-progress.tsx @@ -0,0 +1,41 @@ +import { SuccessCheck } from 'ui' + +const STEPS = ['Preparing', 'Upgrading', 'Finalising'] + +export default function SuccessCheckProgress() { + const completedThrough = 1 + + return ( +
+ {STEPS.map((step, index) => { + const isCompleted = index < completedThrough + const isCurrent = index === completedThrough + + return ( +
+ {isCompleted ? ( + + ) : ( + + )} + + {isCurrent ? `${step}…` : isCompleted ? `${step} complete` : step} + +
+ ) + })} +
+ ) +} diff --git a/apps/design-system/registry/default/example/success-check-selected.tsx b/apps/design-system/registry/default/example/success-check-selected.tsx new file mode 100644 index 0000000000000..a6ecd541264be --- /dev/null +++ b/apps/design-system/registry/default/example/success-check-selected.tsx @@ -0,0 +1,38 @@ +'use client' + +import { useState } from 'react' +import { cn, SuccessCheck } from 'ui' + +const OPTIONS = ['Production', 'Staging', 'Development'] + +export default function SuccessCheckSelected() { + const [selected, setSelected] = useState(OPTIONS[0]) + + return ( +
+ {OPTIONS.map((option) => { + const isSelected = selected === option + + return ( + + ) + })} +
+ ) +} diff --git a/apps/design-system/registry/examples.ts b/apps/design-system/registry/examples.ts index 7e05ca4755ff8..064555d0c1773 100644 --- a/apps/design-system/registry/examples.ts +++ b/apps/design-system/registry/examples.ts @@ -910,6 +910,21 @@ export const examples: Registry = [ registryDependencies: ['sonner'], files: ['example/sonner-upload.tsx'], }, + { + name: 'success-check-demo', + type: 'components:example', + files: ['example/success-check-demo.tsx'], + }, + { + name: 'success-check-selected', + type: 'components:example', + files: ['example/success-check-selected.tsx'], + }, + { + name: 'success-check-progress', + type: 'components:example', + files: ['example/success-check-progress.tsx'], + }, { name: 'switch-demo', type: 'components:example', diff --git a/apps/docs/.env.development b/apps/docs/.env.development index 56120419134e7..d10a8699d76fa 100644 --- a/apps/docs/.env.development +++ b/apps/docs/.env.development @@ -7,16 +7,20 @@ NEXT_PUBLIC_BASE_PATH="/docs" # Whether to enable features available only on Supabase's own hosted services # Setting this to true requires certain secret keys -NEXT_PUBLIC_IS_PLATFORM="false" +NEXT_PUBLIC_IS_PLATFORM="true" # Base URL for the hosted MCP server. Overrides the DEFAULT_MCP_URL_PLATFORM # fallback in @ui-patterns/McpUrlBuilder. NEXT_PUBLIC_MCP_URL="http://localhost:8080/mcp" -# Supabase project containing integration information +# Supabase project containing integration information (legacy) NEXT_PUBLIC_MISC_URL="https://obuldanrptloktxcffvn.supabase.co" NEXT_PUBLIC_MISC_ANON_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9idWxkYW5ycHRsb2t0eGNmZnZuIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MTg2MTQ2ODUsImV4cCI6MjAzNDE5MDY4NX0.NFt49g6DFkc1X5khCzN5p01iAVo2TMxlx88cY1V0E2M" +# Supabase project backing the Partner Catalog / Dashboard Integrations Marketplace +NEXT_PUBLIC_MARKETPLACE_API_URL="https://fgxbxpvumhvzrhqngsyu.supabase.co" +NEXT_PUBLIC_MARKETPLACE_PUBLISHABLE_KEY="sb_publishable_VuF5ZvGqj6ODhZgN1J_vMw_YbiEs1R6" + # Supabase project containing docs content information -NEXT_PUBLIC_SUPABASE_URL="https://xguihxuzqibwxjnimxev.supabase.co" -NEXT_PUBLIC_SUPABASE_ANON_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InhndWloeHV6cWlid3hqbmlteGV2Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3MTgzNzc1MTgsImV4cCI6MjAzMzk1MzUxOH0.aIqjQ9V7djMxYit-DT1fYNV_VWMHSqldh_18XfX2_BE" +NEXT_PUBLIC_MARKETPLACE_API_URL="https://otqhrpbxhxkrhrnjqbba.supabase.co/" +NEXT_PUBLIC_MARKETPLACE_PUBLISHABLE_KEY="sb_publishable_ZVVKKu1s88KsSBWVYlou-g_phb2OJVQ" diff --git a/apps/docs/app/guides/integrations/layout.tsx b/apps/docs/app/guides/integrations/layout.tsx index c319779007f32..efd9c36710be8 100644 --- a/apps/docs/app/guides/integrations/layout.tsx +++ b/apps/docs/app/guides/integrations/layout.tsx @@ -1,9 +1,14 @@ -import { IS_PLATFORM } from 'common' -import { unstable_cache } from 'next/cache' import { type NavMenuSection } from '~/components/Navigation/Navigation.types' import { REVALIDATION_TAGS } from '~/features/helpers.fetch' import Layout from '~/layouts/guides' -import { supabaseMisc } from '~/lib/supabaseMisc' +import { IS_PLATFORM } from 'common' +import { + createMarketplaceClient, + SUPABASE_LISTING_OVERRIDES, + SUPABASE_PARTNER_SLUG, + type CatalogListing, +} from 'common/marketplace-client' +import { unstable_cache } from 'next/cache' export default async function IntegrationsLayout({ children }: { children: React.ReactNode }) { const additionalNavItems = { integrations: await getPartners() } @@ -16,26 +21,82 @@ export default async function IntegrationsLayout({ children }: { children: React const getPartners = unstable_cache(getPartnersImpl, [], { tags: [REVALIDATION_TAGS.PARTNERS], }) + async function getPartnersImpl() { if (!IS_PLATFORM) return [] - const { data, error } = await supabaseMisc() - .from('partners') - .select('slug, title') - .eq('approved', true) - .eq('type', 'technology') - .order('title') + const marketplaceClient = createMarketplaceClient() + + const [{ data: partners, error }, { data: listings }] = await Promise.all([ + marketplaceClient.from('partners').select('slug, name, type'), + marketplaceClient.from('catalog_listings').select('*'), + ]) if (error) { console.error(new Error('Error fetching partners', { cause: error })) } - const partnerNavItems = (data ?? []).map( - (partner) => - ({ - name: partner.title, - url: `https://supabase.com/partners/integrations/${partner.slug}` as `https://${string}`, - }) as Partial - ) + // Only "technology" partners belong in this list, not agencies/experts. + const technologyPartnerSlugs = new Set() + const partnerNameBySlug = new Map() + for (const partner of partners ?? []) { + if (!partner.slug) continue + partnerNameBySlug.set(partner.slug, partner.name ?? partner.slug) + if (partner.type === 'technology') technologyPartnerSlugs.add(partner.slug) + } + + // Supabase-owned listings still get their own nav item, at their clean catalog page slug. + const supabaseOwnedNavItems: Partial[] = [] + const listingsByPartnerSlug = new Map() + + for (const listing of listings ?? []) { + if (!listing.slug) continue + + const override = SUPABASE_LISTING_OVERRIDES[listing.slug] + if (override) { + supabaseOwnedNavItems.push({ + name: listing.title || override.name, + url: `https://supabase.com/partners/catalog/${override.slug}` as `https://${string}`, + }) + continue + } + + const partnerSlug = listing.partner_slug + if ( + !partnerSlug || + partnerSlug === SUPABASE_PARTNER_SLUG || + !technologyPartnerSlugs.has(partnerSlug) + ) { + continue + } + + const partnerListings = listingsByPartnerSlug.get(partnerSlug) ?? [] + partnerListings.push(listing) + listingsByPartnerSlug.set(partnerSlug, partnerListings) + } + + // One nav item per listing (a partner with multiple listings — e.g. an FDW plus a + // Dashboard Integration — gets one entry per listing), linking straight to the tab + // that listing occupies on its Partner Catalog page. + const partnerNavItems: Partial[] = [ + ...Array.from(listingsByPartnerSlug.entries()).flatMap(([partnerSlug, partnerListings]) => { + const partnerName = partnerNameBySlug.get(partnerSlug) ?? partnerSlug + const catalogUrl = `https://supabase.com/partners/catalog/${partnerSlug}` + + // Show only listing name if partner has one listing + if (partnerListings.length === 1) { + const listingName = partnerListings[0].title || partnerName + return [{ name: listingName, url: catalogUrl as `https://${string}` }] + } + + // otherwise show listing name (or slug if no title) plus partner name. + return partnerListings.map((listing) => ({ + name: `${listing.title || listing.slug} (${partnerName})`, + url: `${catalogUrl}?tab=${listing.slug}` as `https://${string}`, + })) + }), + ...supabaseOwnedNavItems, + ] + partnerNavItems.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? '')) return partnerNavItems } diff --git a/apps/docs/components/Navigation/Navigation.commands.tsx b/apps/docs/components/Navigation/Navigation.commands.tsx index 2a97ee8fc8cd2..0a00c936c9945 100644 --- a/apps/docs/components/Navigation/Navigation.commands.tsx +++ b/apps/docs/components/Navigation/Navigation.commands.tsx @@ -146,8 +146,8 @@ const navCommands = [ }, { id: 'nav-integrations', - name: 'Go to Integrations', - route: 'https://supabase.com/partners/integrations', + name: 'Go to Partner Catalog', + route: 'https://supabase.com/partners/catalog', icon: () => , enabled: isFeatureEnabled('integrations:partners'), }, diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts index d341727d4de69..dbcfb17e11240 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts @@ -2452,25 +2452,14 @@ export const local_development: NavMenuConstant = { title: 'Local Dev / CLI', url: '/guides/local-development', items: [ - { name: 'Overview', url: '/guides/local-development' }, + { name: 'Overview & quickstart', url: '/guides/local-development' }, + { name: 'Install and run the CLI', url: '/guides/local-development/cli/getting-started' }, + { name: 'Local development workflow', url: '/guides/local-development/cli-workflows' }, { - name: 'CLI', - url: undefined, - items: [ - { name: 'Getting started', url: '/guides/local-development/cli/getting-started' }, - { - name: 'Configuration', - url: '/guides/local-development/cli/config', - enabled: localDevelopmentEnabled, - }, - { name: 'CLI commands', url: '/reference/cli' }, - ], - }, - { - name: 'Local development', + name: 'Guides', url: undefined, items: [ - { name: 'Getting started', url: '/guides/local-development/overview' }, + { name: 'Database migrations', url: '/guides/local-development/database-migrations' }, { name: 'Declarative database schemas', url: '/guides/local-development/declarative-database-schemas' as `/${string}`, @@ -2495,6 +2484,18 @@ export const local_development: NavMenuConstant = { }, ], }, + { + name: 'Reference', + url: undefined, + items: [ + { + name: 'CLI configuration', + url: '/guides/local-development/cli/config', + enabled: localDevelopmentEnabled, + }, + { name: 'CLI commands', url: '/reference/cli' }, + ], + }, { name: 'Testing', url: undefined, @@ -3233,12 +3234,16 @@ export const integrations: NavMenuConstant = { url: '/guides/integrations', }, { - name: 'Vercel Marketplace', - url: '/guides/integrations/vercel-marketplace', + name: 'Dashboard Integrations', + url: '/guides/integrations#dashboard-integrations', }, { - name: 'Supabase Marketplace', - url: '/guides/integrations/supabase-marketplace', + name: 'Partner Catalog', + url: '/guides/integrations/partner-catalog', + }, + { + name: 'Vercel Marketplace', + url: '/guides/integrations/vercel-marketplace', }, { name: 'Build Your Own', diff --git a/apps/docs/content/guides/ai-tools/plugins.mdx b/apps/docs/content/guides/ai-tools/plugins.mdx index 4e7a1f3b5fed8..1dd20b5f908df 100644 --- a/apps/docs/content/guides/ai-tools/plugins.mdx +++ b/apps/docs/content/guides/ai-tools/plugins.mdx @@ -22,7 +22,19 @@ Bundling the [MCP server](/docs/guides/getting-started/mcp) and [agent skills](/ ## Installation -Choose your AI coding agent and follow the installation steps: +### Quick install + +The [`plugins`](https://www.npmjs.com/package/plugins) package installs the Supabase plugin into any supported AI coding agent with a single command. It auto-detects which agent tools you have installed and installs the plugin to all of them. + +```bash +npx plugins add supabase-community/supabase-plugin +``` + +Add the `--yes` flag to install the Supabase plugin without the confirmation prompt/UI. + +### Manual install + +Alternatively, choose your AI coding agent and follow the installation steps: diff --git a/apps/docs/content/guides/database/extensions/pgsodium.mdx b/apps/docs/content/guides/database/extensions/pgsodium.mdx index 73f7e71ccdc8d..12bcd02127b60 100644 --- a/apps/docs/content/guides/database/extensions/pgsodium.mdx +++ b/apps/docs/content/guides/database/extensions/pgsodium.mdx @@ -4,13 +4,15 @@ title: 'pgsodium (pending deprecation): Encryption Features' description: 'Encryption library for Postgres' --- -Supabase DOES NOT RECOMMEND any new usage of [`pgsodium`](https://github.com/michelp/pgsodium). +Supabase **does not recommend** the usage of [pgsodium](https://github.com/michelp/pgsodium) as it will be deprecated. Use [Supabase Vault](/docs/guides/database/vault) instead. -The [`pgsodium`](https://github.com/michelp/pgsodium) extension is expected to go through a deprecation cycle in the near future. We will reach out to owners of impacted projects to assist with migrations away from [`pgsodium`](https://github.com/michelp/pgsodium) once the deprecation process begins. +We will reach out to owners of impacted projects to assist with migrations away from [pgsodium](https://github.com/michelp/pgsodium) once the deprecation process begins. + +Vault and `pgsodium` are **separate** extensions. Vault doesn't depend on `pgsodium` and **is not** affected by this deprecation. -The [Vault extension](/docs/guides/database/vault) won’t be impacted. Its internal implementation will shift away from pgsodium, but the interface and API will remain unchanged. +Vault is self-contained and doesn't depend on `pgsodium`. It shares the same per-project [root key](/docs/guides/database/vault#encryption-key-location) (same format and location) but exposes its own interface - the `vault.secrets` table and `decrypted_secrets` view - so switching to Vault does not change how your key is managed. @@ -22,14 +24,11 @@ Note that Supabase projects are encrypted at rest by default which likely is suf ## Get the root encryption key for your Supabase project -Encryption requires keys. Keeping the keys in the same database as the encrypted data would be unsafe. For more information about managing the `pgsodium` root encryption key on your Supabase project see **[encryption key location](/docs/guides/database/vault#encryption-key-location)**. This key is required to decrypt values stored in [Supabase Vault](/docs/guides/database/vault) and data encrypted with Transparent Column Encryption. +Encryption requires keys. Keeping the keys in the same database as the encrypted data would be unsafe. Supabase Vault and `pgsodium` share the same per-project root encryption key; for more information about managing it see **[encryption key location](/docs/guides/database/vault#encryption-key-location)**. This key is required to decrypt values stored in [Supabase Vault](/docs/guides/database/vault) and data encrypted with Transparent Column Encryption. ## Resources - [Supabase Vault](/docs/guides/database/vault) - Read more about Supabase Vault in the [blog post](/blog/vault-now-in-beta) - [Supabase Vault on GitHub](https://github.com/supabase/vault) - -## Resources - - Official [`pgsodium` documentation](https://github.com/michelp/pgsodium) diff --git a/apps/docs/content/guides/database/vault.mdx b/apps/docs/content/guides/database/vault.mdx index 8d703a5d50928..3d129d73579fd 100644 --- a/apps/docs/content/guides/database/vault.mdx +++ b/apps/docs/content/guides/database/vault.mdx @@ -173,11 +173,11 @@ updated_at | 2022-12-14 02:51:13.938396+00 > -As we mentioned, Vault uses Transparent Column Encryption (TCE) to store secrets in an authenticated encrypted form. There are some details around that you may be curious about. What does authenticated mean? Where is the encryption key stored? This section explains those details. +As we mentioned, Vault stores secrets in an authenticated encrypted form. There are some details around that you may be curious about. What does authenticated mean? Where is the encryption key stored? This section explains those details. ### Authenticated encryption with associated data -The first important feature of TCE is that it uses an [Authenticated Encryption with Associated Data]() encryption algorithm (based on `libsodium`). +The first important feature is that it uses an [Authenticated Encryption with Associated Data]() encryption algorithm (based on `libsodium`). ### Encryption key location @@ -189,12 +189,31 @@ Another important feature is that the encryption key is never stored in the data This is an important safety precaution - there is little value in storing the encryption key in the database itself as this would be like locking your front door but leaving the key in the lock! Storing the key outside the database fixes this issue. -Where is the key stored? Supabase creates and manages the encryption key in our secured backend systems. We keep this key safe and separate from your data. You remain in control of your key - a separate API endpoint is available that you can use to access the key if you want to decrypt your data outside of Supabase. +Where is the key stored? Supabase creates and manages a unique encryption key for each project in our secured backend systems. We keep this key safe and separate from your data. You remain in control of your key - the [Management API endpoint](/docs/reference/api/v1-get-pgsodium-config) returns your project's 64-character hex root key so you can decrypt your data outside of Supabase or copy it to another project. -Which roles should have access to the `vault.secrets` table should be carefully considered. There are two ways to grant access, the first is that the `postgres` user can explicitly grant access to the vault table itself. +Which roles should have access to the `vault.secrets` table should be carefully considered. One example would be the `postgres` user explicitly granting access to the vault table. + +### Key portability and migration + +Each Supabase project has its own root encryption key. Same-project operations - pausing and restoring, and Point-in-Time or in-place restores - keep the same key, so your secrets stay readable automatically. The [Restore to a new project](/docs/guides/platform/clone-project) and [Branching](/docs/guides/deployment/branching) flows also copy the key to the new project. + +However, if you migrate to a **new** project with a manual `pg_dump` / `pg_restore`, that project is created with its own fresh key and **cannot decrypt** secrets copied from the old project. Before relying on the migrated data, copy the old project's root key to the new project. The `pgsodium` Management API endpoint returns and accepts the 64-character hex root key, and is only available for active (not paused or removed) projects: + +```bash +export OLD_PROJECT_REF="" +export NEW_PROJECT_REF="" +export SUPABASE_ACCESS_TOKEN="" + +curl "https://api.supabase.com/v1/projects/$OLD_PROJECT_REF/pgsodium" \ + -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" | +curl "https://api.supabase.com/v1/projects/$NEW_PROJECT_REF/pgsodium" \ + -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ + -X PUT --json @- +``` + +See [Backup and restore using the CLI](/docs/guides/platform/migrating-within-supabase/backup-restore) for the full restore procedure. ### Resources - Read more about Supabase Vault in the [blog post](/blog/vault-now-in-beta) - [Supabase Vault on GitHub](https://github.com/supabase/vault) -- [Column Encryption](/docs/guides/database/column-encryption) diff --git a/apps/docs/content/guides/functions/limits.mdx b/apps/docs/content/guides/functions/limits.mdx index 4546969659bff..a7db02f15fca9 100644 --- a/apps/docs/content/guides/functions/limits.mdx +++ b/apps/docs/content/guides/functions/limits.mdx @@ -17,7 +17,7 @@ subtitle: "Limits applied Edge Functions in Supabase's hosted platform." ## Platform limits -- Maximum Function Size: 20MB (After bundling using CLI) +- Maximum Function Size: 20MB (bundled locally via the CLI) or 5MB (bundled server-side, e.g. via the Management API or Dashboard) - Maximum no. of Functions per project: - Free: 100 - Pro: 500 diff --git a/apps/docs/content/guides/integrations.mdx b/apps/docs/content/guides/integrations.mdx index 579b0fdcd36da..8b0144d761001 100644 --- a/apps/docs/content/guides/integrations.mdx +++ b/apps/docs/content/guides/integrations.mdx @@ -4,10 +4,14 @@ title: Integrations Supabase integrates with many of your favorite third-party services. -## Vercel Marketplace +## Dashboard Integrations -Create and manage your Supabase projects directly through Vercel. [Get started with Vercel](/docs/guides/integrations/vercel-marketplace). +Install and manage extensions, wrappers, and Postgres Modules directly into your project in a couple of clicks. [Browse Dashboard Integrations](/dashboard/project/_/integrations) + +## Partner Catalog -## Supabase Marketplace +Browse a curated list of Partners who offer one-click integrations, guides or other ways to extend your Supabase project. [Browse the Partner Catalog](/partners/catalog) -Browse tools for extending your Supabase project. [Browse the Supabase Marketplace](/partners/integrations). +## Vercel Marketplace + +Create and manage your Supabase projects directly through Vercel. [Get started with Vercel](/docs/guides/integrations/vercel-marketplace). diff --git a/apps/docs/content/guides/integrations/supabase-marketplace.mdx b/apps/docs/content/guides/integrations/partner-catalog.mdx similarity index 71% rename from apps/docs/content/guides/integrations/supabase-marketplace.mdx rename to apps/docs/content/guides/integrations/partner-catalog.mdx index b196fca240f38..e6e59da9cfb6d 100644 --- a/apps/docs/content/guides/integrations/supabase-marketplace.mdx +++ b/apps/docs/content/guides/integrations/partner-catalog.mdx @@ -1,13 +1,12 @@ --- id: 'integrations' -title: 'Supabase Marketplace' +title: 'Partner Catalog' description: 'Integrations and Partners' --- -The Supabase Marketplace brings together all the tools you need to extend your Supabase project. This includes: +The [Partner Catalog](/partners/catalog) is Supabase's public directory of third-party integrations. It lists tools that extend your Supabase project. These tools cover Auth, Caching, Hosting, and Low-code categories. -- [Experts](/partners/experts) - partners to help you build and support your Supabase project. -- [Integrations](/partners/integrations) - extend your projects with external Auth, Caching, Hosting, and Low-code tools. +The Partner Catalog is different from [Dashboard Integrations](/guides/integrations#dashboard-integrations). You install Dashboard Integrations directly from a project in the Supabase Dashboard. ## Build an integration @@ -20,12 +19,12 @@ Supabase provides several integration points: ## List your integration -[Apply to the Partners program](/partners/integrations#become-a-partner) to list your integration in the Partners marketplace and in the Supabase docs. +[Apply to the Partners program](/partners/catalog#become-a-partner) to list your integration in the Partner Catalog and in the Supabase docs. Integrations are assessed on the following criteria: - **Business viability** - While we welcome everyone to built an integration, we only list companies that are deemed to be long-term viable. This includes an official business registration and bank account, meaningful revenue, or Venture Capital backing. We require this criteria to ensure the health of the marketplace. + While we welcome everyone to built an integration, we only list companies that are deemed to be long-term viable. This includes an official business registration and bank account, meaningful revenue, or Venture Capital backing. We require this criteria to ensure the health of the catalog. - **Compliance** Integrations should not infringe on the Supabase brand/trademark. In short, you cannot use "Supabase" in the name. As the listing appears on the Supabase domain, we don't want to mislead developers into thinking that an integration is an official product. - **Service Level Agreements** diff --git a/apps/docs/content/guides/local-development.mdx b/apps/docs/content/guides/local-development.mdx index 6b9490c59cfd1..4be1f5231634b 100644 --- a/apps/docs/content/guides/local-development.mdx +++ b/apps/docs/content/guides/local-development.mdx @@ -3,13 +3,13 @@ title: Local Development & CLI subtitle: Learn how to develop locally and use the Supabase CLI --- -Develop locally while running the Supabase stack on your machine. +To develop your applications using the locally running Supabase stack, you'll need to install the [Supabase CLI](#cli) and a container runtime. -As a prerequisite, you must install a container runtime compatible with Docker APIs. +A container manager compatible with Docker APIs is a prerequisite: -- [Docker Desktop](https://docs.docker.com/desktop/) (macOS, Windows, Linux) +- [Docker Desktop](https://docs.docker.com/desktop/) (macOS, Windows, Linux) - preferred option - [Rancher Desktop](https://rancherdesktop.io/) (macOS, Windows, Linux) - [Podman](https://podman.io/) (macOS, Windows, Linux) - [OrbStack](https://orbstack.dev/) (macOS) @@ -18,6 +18,12 @@ As a prerequisite, you must install a container runtime compatible with Docker A ## Quickstart + + +Pick an install method and use the same tab in every step below. **Homebrew** gives you a global `supabase` command. **npm, pnpm, and yarn** install the CLI into your project as a dev dependency, so you run it through your package runner (`npx supabase`, `pnpm supabase`, or `yarn supabase`). See [Install and run the CLI](/docs/guides/local-development/cli/getting-started) for details. + + + 1. Install the Supabase CLI: @@ -52,7 +58,7 @@ As a prerequisite, you must install a container runtime compatible with Docker A -2. In your repo, initialize the Supabase project: +2. In your repo, initialize the local Supabase project: @@ -69,7 +75,7 @@ As a prerequisite, you must install a container runtime compatible with Docker A ```sh - pnpx supabase init + pnpm supabase init ``` @@ -82,7 +88,7 @@ As a prerequisite, you must install a container runtime compatible with Docker A -3. Start the Supabase stack: +3. Start the local Supabase stack: @@ -99,7 +105,7 @@ As a prerequisite, you must install a container runtime compatible with Docker A ```sh - pnpx supabase start + pnpm supabase start ``` @@ -135,15 +141,13 @@ Local development with Supabase allows you to work on your projects in a self-co 2. Offline work: You can continue development even without an internet connection. 3. Cost-effective: Local development is free and doesn't consume your project's quota. 4. Enhanced privacy: Sensitive data remains on your local machine during development. -5. Easy testing: You can experiment with different configurations and features without affecting your production environment. - -To get started with local development, you'll need to install the [Supabase CLI](#cli) and Docker. The Supabase CLI allows you to start and manage your local Supabase stack, while Docker is used to run the necessary services. +5. Safe testing: You can experiment with different configurations and features without affecting your production environment. Once set up, you can initialize a new Supabase project, start the local stack, and begin developing your application using local Supabase services. This includes access to a local Postgres database, Auth, Storage, and other Supabase features. ## CLI -The Supabase CLI is a powerful tool that enables developers to manage their Supabase projects directly from the terminal. It provides a suite of commands for various tasks, including: +The Supabase CLI is a tool that enables developers to run Supabase services locally and manage hosted projects directly from the terminal. It provides a suite of commands for various tasks, including: - Setting up and managing local development environments - Generating TypeScript types for your database schema diff --git a/apps/docs/content/guides/local-development/cli-workflows.mdx b/apps/docs/content/guides/local-development/cli-workflows.mdx new file mode 100644 index 0000000000000..226ad9c936b89 --- /dev/null +++ b/apps/docs/content/guides/local-development/cli-workflows.mdx @@ -0,0 +1,480 @@ +--- +id: 'cli-workflows' +title: 'Local development workflow' +description: 'Set up and run your day-to-day local development workflow with the Supabase CLI.' +subtitle: 'Set up and run your day-to-day local development workflow with the Supabase CLI.' +--- + +This guide walks through two common starting points for local development with the Supabase CLI, and shows how they converge into the same daily workflow. By the end, you'll have a `./supabase` directory in your repo that anyone can clone to recreate the full project, locally or on a fresh remote instance. + +There are two starting points, both leading to the same place: database schema and migrations tracked in version control, with seed data for local development. + +- **[Move an existing project to local development](#move-an-existing-project-to-local-development)**: you have a project on the Supabase platform and want to bring it into a proper local development workflow. +- **[Start a new project from scratch](#start-a-new-project-from-scratch)**: you're building locally and will eventually push to a remote instance. + +## Before you begin + +You need the Supabase CLI installed and a Docker-compatible runtime running. If you haven't set these up yet, see [Install and run the CLI](/docs/guides/local-development/cli/getting-started) for installation across macOS, Windows, and Linux, and for the details of what `supabase start` brings up and how to access each service. + +Keep in mind that **the local stack is for development only**. It is not hardened for production use and must never be exposed to external traffic. It has no TLS, no rate limiting, and default credentials. Use it to develop and test, then deploy to the [Supabase Platform](https://supabase.com) or a proper self-hosted setup for anything beyond that. + + + +How you invoke the CLI depends on how you installed it: + +- Installed globally with **Homebrew or Scoop**: run `supabase `. +- Added as a **project dependency** with npm, pnpm, yarn, or bun: run it through your package runner instead, for example `npx supabase ` (or `pnpm supabase`, `yarn supabase`, `bunx supabase`). + +Every example in this guide is written as `supabase `. Translate it to whichever form matches your install. See [Install and run the CLI](/docs/guides/local-development/cli/getting-started) for the full setup. + + + + + +If you want a working project to explore rather than an empty one, `supabase bootstrap` scaffolds a starter application (Next.js, Flutter, and more) with schema, migrations, and config already wired up. It's an alternative entry point to `supabase init` when starting a new project from scratch. + + + +## The `./supabase` directory + +After `supabase init`, your project contains a `./supabase` directory. Here's what goes in it and what to commit: + +| Path | Purpose | Commit? | +| ---------------------- | ----------------------------------------------------------------- | ------- | +| `config.toml` | Local stack configuration (ports, auth settings, etc.) | Yes | +| `migrations/` | Timestamped SQL migration files, applied in order | Yes | +| `seed.sql` | Dev/test data, applied after migrations on `start` and `db reset` | Yes | +| `schemas/` | Declarative schema files (if using that approach) | Yes | +| `.temp/`, `.branches/` | CLI internal state | No | + +The `config.toml` is safe to commit. It contains no secrets by default. If you add sensitive values (OAuth credentials, API keys), use the `env()` function to reference environment variables instead of hardcoding them. See [Managing config and secrets](/docs/guides/local-development/managing-config). + + + +Many database commands accept `--local` and `--linked` flags to choose what they act on. The defaults are not the same across commands: `db diff` and `db reset` default to `--local`, while `db pull`, `db push`, and `db dump` default to `--linked`. When in doubt, pass the flag explicitly. + + + +## Move an existing project to local development + +You've built a project on the Supabase platform, with tables created via the Dashboard, SQL editor, or client libraries. Now you want a local dev setup with everything in version control. + +### Step 1: Initialize + +In your project root: + +```bash +supabase init +``` + +This creates `./supabase/config.toml`. If you already have a project directory with application code, run this at the root. The `supabase/` directory will sit alongside your app code. + +### Step 2: Authenticate + +```bash +supabase login +``` + +Opens a browser to generate an access token. The token is stored locally and used for all subsequent CLI commands that interact with the platform. + +### Step 3: Link to your remote project + +```bash +supabase link --project-ref +``` + +Find your project ID in the Supabase Dashboard URL: `https://supabase.com/dashboard/project/`. + +This tells the CLI which remote project to connect to for `db pull`, `db push`, and other remote operations. You'll be prompted for the database password, which is the password set when you created the project. + +### Step 4: Pull the remote schema + +```bash +supabase db pull +``` + +This connects to your remote database, dumps the entire schema, and saves it as a migration file: + +``` +supabase/migrations/_remote_schema.sql +``` + +This initial migration is your baseline. It represents the current state of your database, and all future changes build on top of it. `db pull` also records this migration as already applied in the remote migration history (the `supabase_migrations.schema_migrations` table), so a later `db push` won't try to reapply it. + + + +`db pull` diffs your remote database against the CLI's default local stack, so the generated file can include statements you didn't expect. A common example is `DROP EXTENSION pg_net;`, emitted when your remote project has an extension disabled that the local stack enables by default. These statements apply silently on `db reset` and change your local schema, so read the file before committing it. See [Cleaning up generated migrations](#cleaning-up-generated-migrations) for what to look for. + + + + + +If you also use Supabase Auth or Storage and have customized their schemas, pull them separately: + +```bash +supabase db pull --schema auth -f pull-auth-schema +supabase db pull --schema storage -f pull-storage-schema +``` + +These schemas are managed by Supabase and typically don't need to be pulled unless you've made custom modifications. + + + +### Step 5: Create seed data + +You have two options: + +**Option A: Dump existing data from remote** (then clean it up): + +```bash +supabase db dump --data-only --linked > supabase/seed.sql +``` + + + +Review and clean up the dump before committing. Remove production user data, secrets, personal information, and anything sensitive. Keep only representative test data that a developer needs to work with the project. + + + +**Option B: Write seed data by hand** (recommended for most projects): + +Create `supabase/seed.sql` with INSERT statements that set up a useful local development state: a few test users, sample data, and so on. This is often better than dumping production data because you control exactly what's in it. + +For more on organizing seed files, glob patterns, and generating realistic data, see [Seeding your database](/docs/guides/local-development/seeding-your-database). + +### Step 6: Verify + +```bash +supabase start +supabase db reset +``` + +`db reset` destroys the local database and recreates it from scratch: it applies all migrations in order, then runs `seed.sql`. If this succeeds, your setup is reproducible. Anyone who clones the repo can do the same. + +### Step 7: Commit + +```bash +git add supabase/ +git commit -m "add supabase local development setup" +``` + +Your project now has a fully reproducible local development environment. + + + +For an existing project, the pulled migration already serves as your schema baseline. You don't need to also create a `schemas/` directory, because that would mean maintaining two representations of the same schema. If you want to adopt declarative schemas later, see [Declarative database schemas](/docs/guides/local-development/declarative-database-schemas). For day-to-day changes going forward, see [The daily workflow](#the-daily-workflow) below. + + + +## Start a new project from scratch + +No remote project yet. You're building from scratch and want to do it right from the start. + +### Step 1: Initialize + +```bash +supabase init +``` + +### Step 2: Start the local stack + +```bash +supabase start +``` + +On first run, Docker images are pulled, which takes a few minutes. Subsequent starts are fast. Once running, the CLI outputs local service URLs and credentials, including the Studio URL for a local instance of the Dashboard. See [Install and run the CLI](/docs/guides/local-development/cli/getting-started#access-your-projects-services) for the full output and how to reach each service. + +### Step 3: Create your schema + +Two approaches, pick one: + +**Option A: Declarative schema** (recommended for new projects) + +Declare the state you want your database to be in as a file in `supabase/schemas/`, for example: + +```sql title="supabase/schemas/schema.sql" +create table public.todos ( + id bigint generated by default as identity primary key, + created_at timestamptz default now() not null, + title text not null, + is_complete boolean default false not null, + user_id uuid references auth.users (id) default auth.uid() not null +); + +alter table public.todos enable row level security; + +create policy "Users can read their own todos" + on public.todos for select + using (auth.uid() = user_id); + +create policy "Users can create their own todos" + on public.todos for insert + with check (auth.uid() = user_id); +``` + +Then generate a migration from it: + +```bash +supabase db diff -f initial-schema +``` + +This compares your declared schema against the current (empty) database and generates a migration file in `supabase/migrations/`. For the full declarative workflow, including managing views and functions, ordering schema files, and known caveats, see [Declarative database schemas](/docs/guides/local-development/declarative-database-schemas). + +**Option B: Write the migration directly** + +```bash +supabase migration new initial-schema +``` + +This creates an empty file at `supabase/migrations/_initial-schema.sql`. Write your SQL in it, then apply: + +```bash +supabase db reset +``` + +### Step 4: Add seed data + +Create `supabase/seed.sql`: + +```sql title="supabase/seed.sql" +-- Create a test user (Supabase Auth) +-- Note: this is a placeholder row so seeded data has a user_id to reference. +-- It has no password, so it can't be used to sign in. To create a +-- login-capable user, use the Auth admin API or the local Studio. +insert into auth.users (id, email, raw_user_meta_data) +values ('d0e3c8f0-1234-5678-9abc-def012345678', 'test@example.com', '{}'); + +-- Seed application data +insert into public.todos (title, user_id) +values + ('Buy groceries', 'd0e3c8f0-1234-5678-9abc-def012345678'), + ('Write documentation', 'd0e3c8f0-1234-5678-9abc-def012345678'); +``` + +### Step 5: Verify + +```bash +supabase db reset +``` + +Drops everything, applies migrations, runs seed. If this passes, your project is reproducible. + +### Step 6: Commit + +```bash +git add supabase/ +git commit -m "add supabase local development setup" +``` + +## The daily workflow + +Both starting points converge here. You have a working `./supabase` directory in your repo. Here's how day-to-day development works. + +### Making schema changes + +Which approach you use is a project-level decision, set when you first created your schema - not a per-change choice. It depends on whether you keep declarative files in `supabase/schemas/`. Pick the tab that matches your project. + + + + +1. Edit your schema file(s) in `supabase/schemas/` (add a table, a column, a policy, etc.) +2. Generate a migration: `supabase db diff -f add-due-date-to-todo` +3. Review the generated migration file. See [Cleaning up generated migrations](#cleaning-up-generated-migrations) +4. Verify the full chain: `supabase db reset` +5. Commit the schema file **and** the migration together + + + +`db diff` compares your `supabase/schemas/` files against your existing migrations; it does **not** read the live local database. Changes you make directly in Studio or via SQL are ignored, so `db diff` reports "No schema changes found" and silently drops them. Always edit the schema files, then diff. + + + + + + +**If you made changes through the local Studio UI:** + +```bash +supabase db diff -f add-due-date-to-todo +``` + +This captures your UI changes as a migration file. This works only when your project has **no** declarative files in `supabase/schemas/`: `db diff` then compares the live local database against your migrations. If you use declarative schemas, don't edit through Studio expecting `db diff` to catch it - see the **Declarative schemas** tab. + +**If you prefer to write SQL directly:** + +```bash +supabase migration new add-due-date-to-todo +``` + +Write the SQL in the generated file. Then verify: + +```bash +supabase db reset +``` + +Commit the migration. + + + + +### Generating types + +If your app uses the generated TypeScript types, regenerate them whenever your schema changes: + +```bash +supabase gen types --lang typescript --local > database.types.ts +``` + +Use `--linked` instead of `--local` to generate from your remote project. TypeScript is the default language; pass `--lang go`, `--lang swift`, or `--lang python` for others. + +For working with the generated types (helper types, JSON inference, type-safe queries) and automating regeneration in CI, see [Generating types](/docs/guides/api/rest/generating-types). + +### Staying in sync with your team + +When someone else pushes new migrations: + +```bash +git pull +supabase db reset +``` + +`db reset` replays all migrations from scratch, so you'll always match the current state of the repo. + +## Pushing to a remote project + +When you're ready to deploy your schema to a remote Supabase instance: + +```bash +# Authenticate (if not already) +supabase login + +# Link to the remote project (if not already) +supabase link --project-ref + +# Preview what will be applied +supabase db push --dry-run + +# Apply migrations +supabase db push +``` + +`db push` applies only migrations that haven't been applied to the remote yet. It tracks this via the `supabase_migrations.schema_migrations` table created automatically on the remote database. + +To also seed a fresh remote instance (dev/staging environments only): + +```bash +supabase db push --include-seed +``` + + + +Never use `--include-seed` on a production database. Seed data is for development and testing. + + + +### Resetting a remote dev or staging project + +If a dev or staging remote drifts or gets into a messy state, you can wipe it and rebuild it from your local migrations: + +```bash +supabase db reset --linked +``` + +Unlike the default `supabase db reset`, which targets your local database, the `--linked` flag runs against the remote project you connected with `supabase link`: it drops the remote schema, then replays every local migration in order. Add `--include-seed` to reload seed data as well. + + + +`db reset --linked` is destructive: it erases all data in the linked remote database. Only run it against throwaway dev or staging projects, and double-check which project you're linked to (`supabase projects list` shows the linked one) before running it. Never use it on production. + + + +For multi-environment setups with CI/CD (feature branches, staging, production), see [Managing Environments](/docs/guides/deployment/managing-environments). + +## Key commands at a glance + +| Command | What it does | +| -------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| `supabase init` | Creates `./supabase/config.toml` | +| `supabase start` | Starts the local stack, applies migrations + seed | +| `supabase stop` | Stops the local stack (data persists until `db reset`) | +| `supabase db reset` | Destroys local DB, applies all migrations + seed from scratch | +| `supabase db reset --linked` | Destroys the **linked remote** DB and rebuilds it from local migrations (destructive; dev/staging only) | +| `supabase db diff -f ` | Generates a migration by diffing current DB state against a shadow database | +| `supabase db pull` | Pulls remote schema into a new local migration file | +| `supabase db push` | Applies pending local migrations to the remote database | +| `supabase db dump` | Exports remote DB schema (or `--data-only` for data) via `pg_dump` | +| `supabase migration new ` | Creates an empty migration file | +| `supabase migration list` | Compares local migrations against remote migration history | +| `supabase gen types --lang typescript` | Generates TypeScript types from your database schema | +| `supabase link --project-ref` | Connects local project to a remote Supabase project | +| `supabase login` | Authenticates with the Supabase platform | + +For the full command reference and every flag, see the [CLI reference](/docs/reference/cli). + +## Cleaning up generated migrations + +When `supabase db diff` generates a migration, it may include statements that are technically correct but noisy. Review every generated migration before committing. + +### Grants + +You may see lines like: + +```sql +GRANT MAINTAIN, REFERENCES, TRIGGER, TRUNCATE ON public.todos TO anon; +GRANT MAINTAIN, REFERENCES, TRIGGER, TRUNCATE ON public.todos TO authenticated; +GRANT MAINTAIN, REFERENCES, TRIGGER, TRUNCATE ON public.todos TO service_role; +``` + +These appear because the diff tool treats permissions as part of the schema state. For tables in the `public` schema, these grants are applied by default and the lines are redundant. They're harmless, but if you want clean migrations, you can remove them. Be consistent across your team about whether you keep or remove them. + +### Revoke/re-grant patterns + +Sometimes a diff produces: + +```sql +REVOKE ALL ON TABLE public.todos FROM anon; +GRANT ALL ON TABLE public.todos TO anon; +``` + +This is the diff tool being overly cautious. If you haven't changed permissions, these lines can be safely removed. + +### Extension statements + +`CREATE EXTENSION IF NOT EXISTS ...` may appear. Keep these if the extension is required by your migration. Remove them if the extension is already created by a previous migration or is part of the default Supabase setup. + +### Known limitations of `db diff` + +The diff is generated by `pg-delta`, the default schema diff engine. (The older [`migra`](https://github.com/djrobstep/migra) engine is still available: set `enabled = false` under `[experimental.pgdelta]` in `config.toml`, or pass `--use-migra`.) No diff engine captures everything. Most notably, DML (INSERT, UPDATE, DELETE) is not tracked, so data changes must be added to the migration manually, and some entities like RLS policy renames and certain view properties don't diff cleanly. See the [full list of caveats](/docs/guides/local-development/declarative-database-schemas#known-caveats) in the declarative schemas guide. + +Treat `db diff` output as a draft, not a final migration. When in doubt, review the generated SQL and adjust it manually. + +## Troubleshooting + +**`db reset` fails with a migration error** + +The output will show which migration file failed and the SQL error. Fix the migration file, then run `db reset` again. + +**`db push` says migrations are already applied** + +The remote database already has those migrations in its history. Run `supabase migration list` to compare local vs. remote state. If they're out of sync, use `supabase migration repair` to correct the remote history. + +**Schema drift: remote was changed outside of migrations** + +If someone modified the remote database directly (via Dashboard, SQL editor, etc.), run `supabase db pull` to capture those changes as a new migration file. Then `supabase db reset` locally to verify everything still works. + +**Docker issues on `supabase start`** + +Ensure Docker is running and has at least 7 GB of RAM allocated. If containers fail health checks, try: + +```bash +supabase stop +supabase start +``` + +If problems persist, `supabase stop --no-backup` for a clean restart (this removes local database data). diff --git a/apps/docs/content/guides/local-development/cli/getting-started.mdx b/apps/docs/content/guides/local-development/cli/getting-started.mdx index 10838693b1450..b752929f3edcf 100644 --- a/apps/docs/content/guides/local-development/cli/getting-started.mdx +++ b/apps/docs/content/guides/local-development/cli/getting-started.mdx @@ -9,6 +9,19 @@ The Supabase CLI enables you to run the entire Supabase stack locally, on your m 1. `supabase init` to create a new local project 2. `supabase start` to launch the Supabase services + + +There are two ways to install the CLI, and they change the command you type: + +- **Project dependency** with `npm`, `pnpm`, or `yarn` installs the CLI into a single project (there is no global `supabase` command with this method). Run it through your package runner instead, for example `npx supabase `. +- **Global install** with Homebrew, Scoop, or Linux packages. Run commands as `supabase `. + +Either way, the CLI is **project-scoped**: most commands (including `start`) expect to run inside a directory that has been initialized with `supabase init`, which creates the `supabase/` folder and `config.toml`. Run `init` first, then the other commands from the same directory. + +The rest of this page writes examples as `supabase `; translate them to `npx supabase ` if you installed the CLI as a project dependency. + + + ## Installing the Supabase CLI + + +Install the CLI as a project dev dependency. This adds it to a single project rather than installing a global command: + +```sh +npm install supabase --save-dev +# or: pnpm add -D supabase / yarn add -D supabase / bun add -D supabase +``` + +Pin the version in `package.json` so your whole team uses the same CLI version. Then run every command through your package runner: + +```sh +npx supabase --help +# or: pnpm supabase / yarn supabase / bunx supabase +``` + + + +The Supabase CLI requires **Node.js 20 or later** when run via `npx` or `npm`. Older Node.js versions, such as 16, are not supported and fail to start the CLI. + + + + Install the CLI with [Homebrew](https://brew.sh): @@ -39,7 +74,7 @@ scoop install supabase -The CLI is available through [Homebrew](https://brew.sh) and Linux packages. +The CLI is available via [Homebrew](https://brew.sh) and Linux packages. #### Homebrew @@ -57,27 +92,6 @@ and run one of the following: - `sudo dpkg -i <...>.deb` - `sudo rpm -i <...>.rpm` - - - -Run the CLI by prefixing each command with `npx` or `bunx`: - -```sh -npx supabase --help -``` - - - -The Supabase CLI requires **Node.js 20 or later** when run via `npx` or `npm`. Older Node.js versions, such as 16, are not supported and fail to start the CLI. - - - -You can also install the CLI as dev dependency via [npm](https://www.npmjs.com/package/supabase): - -```sh -npm install supabase --save-dev -``` - @@ -90,8 +104,22 @@ Pre-release CLI builds ship from the development branch (`X.Y.Z-beta.N` versions size="small" type="underlined" defaultActiveId="npm" - queryGroup="platform" > + + +Install as a dev dependency: + +```sh +npm install supabase@beta --save-dev +``` + +Or run without installing: + +```sh +npx supabase@beta --help +``` + + ```sh @@ -121,21 +149,6 @@ brew link --overwrite supabase-beta Beta builds are attached to [GitHub pre-releases](https://github.com/supabase/cli/releases). Download the `.apk`, `.deb`, or `.rpm` for your platform and install with the same commands as [Linux packages](#linux-packages) above. - - - -Install as a dev dependency: - -```sh -npm install supabase@beta --save-dev -``` - -Or run without installing: - -```sh -npx supabase@beta --help -``` - @@ -147,8 +160,7 @@ When a new [version](https://github.com/supabase/cli/releases) is released, you scrollable size="small" type="underlined" - defaultActiveId="npm" - queryGroup="platform" + defaultActiveId="macos" > @@ -199,7 +211,7 @@ brew upgrade supabase-beta - `sudo rpm -i <...>.rpm` - + If you have installed the CLI as dev dependency via [npm](https://www.npmjs.com/package/supabase), you can update it with: @@ -230,9 +242,9 @@ supabase stop --no-backup -## Running Supabase locally +## Running a local Supabase project -The Supabase CLI uses Docker containers to manage the local development stack. Follow the official guide to install and configure [Docker Desktop](https://docs.docker.com/desktop) on your machine. +The most common thing you'll do with the CLI is run the full Supabase stack (Postgres, Auth, Storage, and the rest) on your own machine. That stack runs in Docker containers, so you need a container runtime installed first. Follow the official guide to install and configure [Docker Desktop](https://docs.docker.com/desktop) on your machine. Alternately, you can use a different container tool that offers Docker compatible APIs. @@ -241,7 +253,7 @@ Alternately, you can use a different container tool that offers Docker compatibl - [OrbStack](https://orbstack.dev/) (macOS) - [colima](https://github.com/abiosoft/colima) (macOS) -Inside the folder where you want to create your project, run: +With a container runtime running, go to the folder where you want to create your project and initialize it: ```bash supabase init @@ -249,12 +261,18 @@ supabase init This creates a new `supabase` folder. It's safe to commit this folder to version control. -Now, to start the Supabase stack, run: +Now, from the same folder, start the Supabase stack: ```bash supabase start ``` + + +If you installed the CLI as a project dependency (npm, pnpm, yarn, or bun), run these as `npx supabase init` and `npx supabase start` instead. See the [note above](#installing-the-supabase-cli). + + + This takes time on your first run because the CLI needs to download the Docker images to your local machine. The CLI includes the entire Supabase stack, and a few additional images useful for local development (like a local SMTP server and a database diff tool). ## Access your project's services @@ -362,7 +380,7 @@ http://localhost:54321/auth/v1/ # Auth (GoTrue) -Local logs rely on the Supabase Analytics Server which accesses the docker logging driver by either volume mounting `/var/run/docker.sock` domain socket on Linux and macOS, or exposing `tcp://localhost:2375` daemon socket on Windows. These settings must be configured manually after [installing](/docs/guides/cli/getting-started#installing-the-supabase-cli) the Supabase CLI. +Local logs rely on the Supabase Analytics Server which accesses the docker logging driver by either volume mounting `/var/run/docker.sock` domain socket on Linux and macOS, or exposing `tcp://localhost:2375` daemon socket on Windows. These settings must be configured manually after [installing](/docs/guides/local-development/cli/getting-started#installing-the-supabase-cli) the Supabase CLI. diff --git a/apps/docs/content/guides/local-development/overview.mdx b/apps/docs/content/guides/local-development/database-migrations.mdx similarity index 88% rename from apps/docs/content/guides/local-development/overview.mdx rename to apps/docs/content/guides/local-development/database-migrations.mdx index 0c2b6f001690a..530fb2941a0b8 100644 --- a/apps/docs/content/guides/local-development/overview.mdx +++ b/apps/docs/content/guides/local-development/database-migrations.mdx @@ -1,8 +1,8 @@ --- -id: 'overview' -title: 'Local development with schema migrations' -description: 'Develop locally with the Supabase CLI and schema migrations.' -subtitle: 'Develop locally with the Supabase CLI and schema migrations.' +id: 'database-migrations' +title: 'Database migrations' +description: 'Track and version your database schema changes with migrations.' +subtitle: 'Track and version your database schema changes with migrations.' video: 'https://www.youtube-nocookie.com/v/vyHyYpvjaks' tocVideo: 'vyHyYpvjaks' --- @@ -13,6 +13,12 @@ Develop locally using the CLI to run a local Supabase stack. You can use the int Alternatively, if you're comfortable with migration files and SQL, you can write your own migrations and push them to the local database for testing before sharing your changes. + + +This page is a focused tutorial on migrations. If you want to move an existing platform project to local development, or set up a reproducible project from scratch and take it all the way to a remote deploy, see the [Local development workflow](/docs/guides/local-development/cli-workflows) guide. It covers both starting points, the daily development loop, pushing to production, cleaning up generated migrations, and troubleshooting. + + + ## Database migrations Database changes are managed through "migrations." Database migrations are a common way of tracking changes to your database over time. @@ -231,33 +237,23 @@ npx supabase login ### Link your project -Associate your project with your remote project using [`supabase link`](/docs/reference/cli/usage#supabase-link). +Associate your local project with your remote project using [`supabase link`](/docs/reference/cli/usage#supabase-link). ```bash supabase link --project-ref # You can get from your project's dashboard URL: https://supabase.com/dashboard/project/ - -supabase db pull -# Capture any changes that you have made to your remote database before you went through the steps above -# If you have not made any changes to the remote database, skip this step ``` -`supabase/migrations` is now populated with a migration in `_remote_schema.sql`. -This migration captures any changes required for your local database to match the schema of your remote Supabase project. + -Review the generated migration file and once happy, apply the changes to your local instance: +If your remote database already has schema changes that aren't in your local migrations (for example, tables you created directly in the Dashboard), capture them before you push: ```bash -# To apply the new migration to your local database: -supabase migration up - -# To reset your local database completely: +supabase db pull supabase db reset ``` - - -There are a few commands required to link your project. We are in the process of consolidating these commands into a single command. Bear with us! +`db pull` writes those changes to a `_remote_schema.sql` migration so your local and remote histories line up, and `db reset` re-applies your migrations locally to confirm they're consistent. For a brand-new remote project with nothing in it yet, skip this step. diff --git a/apps/docs/content/guides/local-development/declarative-database-schemas.mdx b/apps/docs/content/guides/local-development/declarative-database-schemas.mdx index bcbacbe281a78..e820b4971b394 100644 --- a/apps/docs/content/guides/local-development/declarative-database-schemas.mdx +++ b/apps/docs/content/guides/local-development/declarative-database-schemas.mdx @@ -11,6 +11,8 @@ Declarative schemas provide a developer-friendly way to maintain + +With declarative schemas, the files in `supabase/schemas/` are the source of truth. `supabase db diff` compares **those files** against your migrations - it does **not** read the live database. Changes you make directly (Studio, the SQL editor, `psql`) are invisible to the diff: it reports "No schema changes found" and the change is silently dropped. Always edit the schema files, then run `db diff`. + + + @@ -341,7 +349,7 @@ SQL statements generated in a down migration are usually destructive. You must r ## Known caveats -The `migra` diff tool used for generating schema diff is capable of tracking most database changes. However, there are edge cases where it can fail. +Schema diffs are generated by `pg-delta`, the default diff engine, which tracks most database changes. However, there are edge cases where schema diff can fail. The known cases below were documented against the legacy [`migra`](https://github.com/djrobstep/migra) engine (still available via `enabled = false` under `[experimental.pgdelta]` in `config.toml`, or `--use-migra`); some, such as duplicated grants from default privileges, also apply to `pg-delta`. Review every generated migration regardless of engine. If you need to use any of the entities below, remember to add them through [versioned migrations](/docs/guides/deployment/database-migrations) instead. diff --git a/apps/docs/content/guides/local-development/seeding-your-database.mdx b/apps/docs/content/guides/local-development/seeding-your-database.mdx index 48e55d531a250..777239a1fe269 100644 --- a/apps/docs/content/guides/local-development/seeding-your-database.mdx +++ b/apps/docs/content/guides/local-development/seeding-your-database.mdx @@ -58,7 +58,13 @@ The CLI processes seed files in the order they are declared in the `sql_paths` a ## Generating seed data -You can generate seed data for local development using [Snaplet](https://github.com/snaplet/seed). +For most projects, a hand-written `supabase/seed.sql` (see [Using seed files](#using-seed-files) above) is the simplest and most reliable approach. If you need large volumes of realistic data, you can generate it with [Snaplet Seed](https://github.com/supabase-community/seed). + + + +Snaplet wound down as a company in 2024 and open-sourced its tooling. `@snaplet/seed` is now community-maintained at [supabase-community/seed](https://github.com/supabase-community/seed) and receives only occasional fixes, so treat it as an optional convenience rather than a required part of the workflow. + + @@ -160,23 +166,22 @@ Running `npx tsx seed.ts > supabase/seed.sql` generates the relevant SQL stateme ```sql -- The `Post.createdBy` user with an email address ending in `"@acme.org"` -INSERT INTO "User" (name, email) VALUES ("John Snow", "snow@acme.org") +insert into "User" (name, email) values ('John Snow', 'snow@acme.org'); ---- A `Post` with the title `"There is a lot of snow around here!"` -INSERT INTO "Post" (title, content, createdBy) VALUES ( - "There is a lot of snow around here!", - "Lorem ipsum dolar", - 1) +-- - A `Post` with the title `"There is a lot of snow around here!"` +insert into "Post" (title, content, createdBy) +values + ('There is a lot of snow around here!', 'Lorem ipsum dolar', 1); ---- Three `Post.Comment` from three different users. -INSERT INTO "User" (name, email) VALUES ("Stephanie Shadow", "shadow@domain.com") -INSERT INTO "Comment" (text, userId, postId) VALUES ("I love cheese", 2, 1) +-- - Three `Post.Comment` from three different users. +insert into "User" (name, email) values ('Stephanie Shadow', 'shadow@domain.com'); +insert into "Comment" (text, userId, postId) values ('I love cheese', 2, 1); -INSERT INTO "User" (name, email) VALUES ("John Rambo", "rambo@trymore.dev") -INSERT INTO "Comment" (text, userId, postId) VALUES ("Lorem ipsum dolar sit", 3, 1) +insert into "User" (name, email) values ('John Rambo', 'rambo@trymore.dev'); +insert into "Comment" (text, userId, postId) values ('Lorem ipsum dolar sit', 3, 1); -INSERT INTO "User" (name, email) VALUES ("Steven Plank", "s@plank.org") -INSERT INTO "Comment" (text, userId, postId) VALUES ("Actually, that's not correct...", 4, 1) +insert into "User" (name, email) values ('Steven Plank', 's@plank.org'); +insert into "Comment" (text, userId, postId) values ('Actually, that''s not correct...', 4, 1); ``` Whenever your database structure changes, you will need to regenerate `@snaplet/seed` to keep it in sync with the new structure. You can do this by running: @@ -199,4 +204,4 @@ npx @snaplet/seed sync npx tsx seed.ts > supabase/seed.sql ``` -For more information, check out Snaplet's [seed documentation](https://snaplet-seed.netlify.app/seed/integrations/supabase) +For more information, see the [Snaplet Seed repository](https://github.com/supabase-community/seed). diff --git a/apps/docs/content/guides/platform/clone-project.mdx b/apps/docs/content/guides/platform/clone-project.mdx index 833091c0f1b54..3cbf16324d6d8 100644 --- a/apps/docs/content/guides/platform/clone-project.mdx +++ b/apps/docs/content/guides/platform/clone-project.mdx @@ -15,6 +15,7 @@ You can clone your Supabase project by restoring your data from an existing proj - All data and indexes - Database roles, permissions and users - Auth user data (user accounts, hashed passwords, and authentication records from the auth schema) +- Encryption root key (so [Vault](/docs/guides/database/vault) secrets and encrypted columns remain readable in the new project) **What needs manual reconfiguration?** diff --git a/apps/docs/content/guides/platform/migrating-within-supabase/backup-restore.mdx b/apps/docs/content/guides/platform/migrating-within-supabase/backup-restore.mdx index cd989bbccc685..444fb1e514d34 100644 --- a/apps/docs/content/guides/platform/migrating-within-supabase/backup-restore.mdx +++ b/apps/docs/content/guides/platform/migrating-within-supabase/backup-restore.mdx @@ -90,6 +90,12 @@ breadcrumb: 'Migrations' ### Restore backup using CLI + + +These steps cover a manual logical restore (`pg_dump` / `psql`) into a project you create yourself. The [Restore to a new project](/docs/guides/platform/clone-project) and [Branching](/docs/guides/deployment/branching) flows copy your encryption root key to the new project automatically, so the key-copy step below does not apply to them. + + + @@ -147,7 +153,7 @@ breadcrumb: 'Migrations' type="underlined" defaultActiveId="no-column-encryption" > - + Run these commands after replacing ```[CONNECTION_STRING]``` with your connection string from the previous steps: @@ -163,8 +169,17 @@ breadcrumb: 'Migrations' ``` - - If you use [column encryption](/docs/guides/database/column-encryption), copy the root encryption key to your new project using your [Personal Access Token](/dashboard/account/tokens). + + + + + Retrieve the root encryption key from the **old** project _before_ you pause or delete it. The API below only returns the key for active projects - once the old project is paused or removed, the key (and any data encrypted with it) can no longer be retrieved. + + Backup files never contain the root key; they hold only encrypted data. A newly created project is initialized with its own fresh root key, so Vault secrets and encrypted columns restored from the old project cannot be decrypted until you copy the old key across. Overwriting a project's root key makes any data encrypted under a different key inaccessible. + + + + If you use [Supabase Vault](/docs/guides/database/vault) or [pgsodium](/docs/guides/database/extensions/pgsodium), copy the root encryption key to your new project using your [Personal Access Token](/dashboard/account/tokens). Both rely on the same per-project root key. You can restore the project using both the old and new project ref (the project ref is the value between "https://" and ".supabase.co" in the URL) instead of the URL. @@ -179,6 +194,8 @@ breadcrumb: 'Migrations' -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ -X PUT --json @- ``` + + The endpoint both returns and expects the 64-character hex root key. diff --git a/apps/docs/content/troubleshooting/edge-function-bundle-size-issues.mdx b/apps/docs/content/troubleshooting/edge-function-bundle-size-issues.mdx index 6f30b06e7b50b..baa6fc1772a9d 100644 --- a/apps/docs/content/troubleshooting/edge-function-bundle-size-issues.mdx +++ b/apps/docs/content/troubleshooting/edge-function-bundle-size-issues.mdx @@ -1,14 +1,19 @@ --- title = "Edge Function bundle size issues" topics = [ "functions" ] -keywords = [ "bundle", "size", "limit", "dependencies", "edge function", "10MB" ] +keywords = [ "bundle", "size", "limit", "dependencies", "edge function", "5MB", "20MB" ] database_id = "aaf9e673-64ae-460a-88e0-b83ea4963382" [api] cli = [ "supabase-functions-deploy" ] --- -Edge Functions have a 20MB source code limit. If your function exceeds this limit, deployment will fail. +The maximum size of a deployed Edge Function depends on how it's bundled: + +- **Local bundling (Supabase CLI):** up to **20 MB**. The CLI bundles your function and its dependencies on your machine before uploading. +- **Server-side bundling (Management API or Dashboard):** up to **5 MB**. When you deploy without local bundling, bundling runs on the server, which has a lower infrastructure limit. + +If your function exceeds the applicable limit, deployment fails with an error such as `Function source code exceeds the maximum deployment size`. ## Check your bundle size @@ -48,6 +53,10 @@ Consider breaking large functions into smaller, more focused functions. Each fun Research smaller packages that provide the same functionality. Many NPM packages designed for Node.js include unnecessary polyfills that increase bundle size. +## Deploying larger functions + +If your function is under 20 MB but exceeds the 5 MB server-side limit, bundle it locally with the Supabase CLI to get the higher limit. Run `supabase functions deploy` with the `--use-docker` flag to force local bundling. + ## Additional resources - [Unable to deploy Edge Function](./unable-to-deploy-edge-function) diff --git a/apps/docs/lib/supabaseMisc.ts b/apps/docs/lib/supabaseMisc.ts deleted file mode 100644 index 2c70c480b9dd2..0000000000000 --- a/apps/docs/lib/supabaseMisc.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { createClient, type SupabaseClient } from '@supabase/supabase-js' - -let _supabaseMisc: SupabaseClient - -export function supabaseMisc() { - if (!_supabaseMisc) { - _supabaseMisc = createClient( - process.env.NEXT_PUBLIC_MISC_URL!, - process.env.NEXT_PUBLIC_MISC_ANON_KEY! - ) - } - - return _supabaseMisc -} diff --git a/apps/docs/turbo.jsonc b/apps/docs/turbo.jsonc index 6ffd9e5257a6c..f46ce1cc5df78 100644 --- a/apps/docs/turbo.jsonc +++ b/apps/docs/turbo.jsonc @@ -24,6 +24,8 @@ "NEXT_PUBLIC_SUPABASE_ANON_KEY", "NEXT_PUBLIC_MISC_URL", "NEXT_PUBLIC_MISC_ANON_KEY", + "NEXT_PUBLIC_MARKETPLACE_API_URL", + "NEXT_PUBLIC_MARKETPLACE_PUBLISHABLE_KEY", "NODE_ENV", "NEXT_PUBLIC_API_URL", "NEXT_PUBLIC_BASE_PATH", diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx index aa5065a4f98f7..ce32634141ea4 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx @@ -94,10 +94,11 @@ export const useUnifiedLogsPreview = () => { const { flags, isInitialized, onUpdateFlag } = useFeaturePreviewContext() const isLoading = !isInitialized - const isEnabled = flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_UNIFIED_LOGS] + const isEnabled = IS_PLATFORM && flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_UNIFIED_LOGS] const hasToggledPreview = !!safeLocalStorage.getItem(LOCAL_STORAGE_KEYS.UI_PREVIEW_UNIFIED_LOGS) - const isDefaultOptIn = isInitialized && unifiedLogsDefaultOptIn && !hasToggledPreview + const isDefaultOptIn = + IS_PLATFORM && isInitialized && unifiedLogsDefaultOptIn && !hasToggledPreview const enable = () => onUpdateFlag(LOCAL_STORAGE_KEYS.UI_PREVIEW_UNIFIED_LOGS, true) const disable = () => onUpdateFlag(LOCAL_STORAGE_KEYS.UI_PREVIEW_UNIFIED_LOGS, false) diff --git a/apps/studio/components/interfaces/Connect/OrganizationSelector.tsx b/apps/studio/components/interfaces/Connect/OrganizationSelector.tsx index feac48d13a252..42fd4aad6b4d6 100644 --- a/apps/studio/components/interfaces/Connect/OrganizationSelector.tsx +++ b/apps/studio/components/interfaces/Connect/OrganizationSelector.tsx @@ -1,6 +1,6 @@ -import { Check, ChevronDown } from 'lucide-react' +import { ChevronDown } from 'lucide-react' import { useMemo, useState, type ReactNode } from 'react' -import { cn, Collapsible, CollapsibleContent, CollapsibleTrigger } from 'ui' +import { cn, Collapsible, CollapsibleContent, CollapsibleTrigger, SuccessCheck } from 'ui' import { CreateOrganizationCard, @@ -213,9 +213,7 @@ const ConnectOrganizationButton = ({ )} /> {selected && ( - - - + )} ) diff --git a/apps/studio/components/interfaces/DiskManagement/ui/DiskSpaceBar.tsx b/apps/studio/components/interfaces/DiskManagement/ui/DiskSpaceBar.tsx index c47a8bb2da869..1e7491d44e976 100644 --- a/apps/studio/components/interfaces/DiskManagement/ui/DiskSpaceBar.tsx +++ b/apps/studio/components/interfaces/DiskManagement/ui/DiskSpaceBar.tsx @@ -76,7 +76,14 @@ export const DiskSpaceBar = ({ form }: DiskSpaceBarProps) => { const newUsedPercentageSystem = Math.min((usedSizeSystem / newTotalSize) * 100, 100) const resizePercentage = AUTOSCALING_THRESHOLD * 100 - const newResizePercentage = AUTOSCALING_THRESHOLD * 100 + // Anchored to the actual right edge of the highlighted "border-r" zone below, rather than + // trusting resizePercentage alone, since usedPercentage* are each rounded independently and + // can drift a fraction of a percent from usedTotalPercentage. + const resizeMarkerLeftPercentage = + usedPercentageDatabase + + usedPercentageWAL + + usedPercentageSystem + + Math.max(0, resizePercentage - usedTotalPercentage) return (
@@ -166,45 +173,40 @@ export const DiskSpaceBar = ({ form }: DiskSpaceBarProps) => { )} -
- {!showNewSize && ( - -
- - -
- Autoscaling -
-
- -

- Supabase expands your disk storage automatically when the database reaches 90% - of the disk size. However, disk modifications, including auto-scaling, are - limited to 4 within a rolling 24-hour window. -

-

- If you exhaust these modifications and reach 95% of the disk space, your - project{' '} - will enter read-only mode. -

-
-
-
-
- + + +
+ +
+
+ +

Autoscaling

+

+ Supabase expands your disk storage automatically when the database reaches 90% + of the disk size. However, disk modifications, including auto-scaling, are + limited to 4 within a rolling 24-hour window. +

+

+ If you exhaust these modifications and reach 95% of the disk space, your project{' '} + will enter read-only mode. +

+
+
+
)} -
+ {!showNewSize && (
diff --git a/apps/studio/components/interfaces/Home/ProjectList/EmptyStates.tsx b/apps/studio/components/interfaces/Home/ProjectList/EmptyStates.tsx index e8be56338fff4..7290e60e85edf 100644 --- a/apps/studio/components/interfaces/Home/ProjectList/EmptyStates.tsx +++ b/apps/studio/components/interfaces/Home/ProjectList/EmptyStates.tsx @@ -1,7 +1,6 @@ import { BoxPlus } from 'icons' import { Plus } from 'lucide-react' import Link from 'next/link' -import { ReactNode } from 'react' import { Button, Card, @@ -66,10 +65,9 @@ export const LoadingTableView = () => { ) } -export const LoadingCardView = ({ prependCard }: { prependCard?: ReactNode }) => { +export const LoadingCardView = () => { return (
    - {prependCard}
diff --git a/apps/studio/components/interfaces/Home/ProjectList/ProjectList.tsx b/apps/studio/components/interfaces/Home/ProjectList/ProjectList.tsx index 9fd6fdf81a29b..0a364a45b533d 100644 --- a/apps/studio/components/interfaces/Home/ProjectList/ProjectList.tsx +++ b/apps/studio/components/interfaces/Home/ProjectList/ProjectList.tsx @@ -2,7 +2,7 @@ import { keepPreviousData } from '@tanstack/react-query' import { useDebounce } from '@uidotdev/usehooks' import { LOCAL_STORAGE_KEYS, useParams } from 'common' import { parseAsArrayOf, parseAsString, parseAsStringLiteral, useQueryState } from 'nuqs' -import { ReactNode, useMemo } from 'react' +import { useMemo } from 'react' import { Card, cn, @@ -40,19 +40,9 @@ import type { Organization } from '@/types' export interface ProjectListProps { organization?: Organization rewriteHref?: (projectRef: string) => string - /** - * Optional content rendered as the first `
  • ` inside the grid view, before the - * project cards. Used by the upgrade CTA placement experiment to slot a card-shaped - * usage tile in the project list. Ignored in table view. - */ - prependCard?: ReactNode } -export const ProjectList = ({ - organization: organization_, - rewriteHref, - prependCard, -}: ProjectListProps) => { +export const ProjectList = ({ organization: organization_, rewriteHref }: ProjectListProps) => { const { slug: urlSlug } = useParams() const { data: selectedOrganization } = useSelectedOrganizationQuery() @@ -160,11 +150,7 @@ export const ProjectList = ({ } if (isLoadingPermissions || isLoadingProjects || !organization) { - return viewMode === 'table' ? ( - - ) : ( - - ) + return viewMode === 'table' ? : } if (isEmpty) { @@ -299,7 +285,6 @@ export const ProjectList = ({ 'sm:grid-cols-1 md:grid-cols-1 lg:grid-cols-2 xl:grid-cols-3' )} > - {prependCard} {orgProjects?.map((project) => ( = { - observability: BarChart3, - security: ShieldCheck, - billing: CreditCard, - secrets: KeyRound, - email: Mail, - wrappers: Database, - ai: Cpu, - ai_vectors: Cpu, - storage: Package2, - postgres_extension: Database, - wrapper: Cable, - devtools: Wrench, - auth: Fingerprint, - 'low-code': MousePointerClick, - 'data-platform': Server, - api: Webhook, - 'caching-offline-first': Zap, - messaging: MessageSquare, -} - export const FEATURED_CATEGORIES: Array<{ slug: string; name: string }> = [ { slug: 'observability', name: 'Observability' }, { slug: 'security', name: 'Security' }, @@ -87,11 +44,6 @@ export const EXCLUDED_CATEGORY_SLUGS = new Set([ 'app-templates', ]) -export const getCategoryIcon = (slug: string | null | undefined): LucideIcon => { - if (!slug) return Boxes - return CATEGORY_ICONS[slug] ?? Boxes -} - export const formatCategoryLabel = ( slug: string | null | undefined, categoryOptions?: Array<{ slug: string; name: string }> diff --git a/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceIndex.tsx b/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceIndex.tsx index db457a626566d..2314874e1dca8 100644 --- a/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceIndex.tsx +++ b/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceIndex.tsx @@ -229,7 +229,7 @@ export const MarketplaceIndex = () => {
    - +
    diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/ExitSurveyModal.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/ExitSurveyModal.tsx index c039131e10fad..f34dc2f63adf9 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/ExitSurveyModal.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/ExitSurveyModal.tsx @@ -34,6 +34,12 @@ export const ExitSurveyModal = ({ visible, projects, onClose }: ExitSurveyModalP const [message, setMessage] = useState('') const [selectedReason, setSelectedReason] = useState([]) + const { mutateAsync: sendExitSurvey, isPending: isSubmittingFeedback } = + useSendDowngradeFeedbackMutation({ + // the user should not get a toast saying "submitting survey failed" + onError: () => {}, + }) + const subscriptionUpdateDisabled = useFlag('disableProjectCreationAndUpdate') const { mutate: updateOrgSubscription, isPending: isUpdating } = useOrgSubscriptionUpdateMutation( { @@ -43,10 +49,28 @@ export const ExitSurveyModal = ({ visible, projects, onClose }: ExitSurveyModalP dismissible: true, }) }, + onSuccess: async () => { + toast.success( + hasProjectsWithComputeDowngrade + ? 'Successfully downgraded organization to the Free Plan. Your projects are currently restarting to update their compute instances.' + : 'Successfully downgraded organization to the Free Plan', + { duration: hasProjectsWithComputeDowngrade ? 8000 : 4000 } + ) + + if (slug) { + sendExitSurvey({ + orgSlug: slug, + reasons: selectedReason.reduce((a, b) => `${a}- ${b}\n`, ''), + message, + exitAction: 'downgrade', + }).catch(() => {}) + } + + onClose(true) + window.scrollTo({ top: 0, left: 0, behavior: 'smooth' }) + }, } ) - const { mutateAsync: sendExitSurvey, isPending: isSubmittingFeedback } = - useSendDowngradeFeedbackMutation() const isSubmitting = isUpdating || isSubmittingFeedback const projectsWithComputeDowngrade = projects.filter((project) => { @@ -85,33 +109,7 @@ export const ExitSurveyModal = ({ visible, projects, onClose }: ExitSurveyModalP // Update the subscription first, followed by posting the exit survey if successful // If compute instance is present within the existing subscription, then a restart will be triggered if (!slug) return console.error('Slug is required') - - updateOrgSubscription( - { slug, tier: 'tier_free' }, - { - onSuccess: async () => { - try { - await sendExitSurvey({ - orgSlug: slug, - reasons: selectedReason.reduce((a, b) => `${a}- ${b}\n`, ''), - message, - exitAction: 'downgrade', - }) - } catch (error) { - // [Joshen] In this case we don't raise any errors if the exit survey fails to send since it shouldn't block the user - } finally { - toast.success( - hasProjectsWithComputeDowngrade - ? 'Successfully downgraded organization to the Free Plan. Your projects are currently restarting to update their compute instances.' - : 'Successfully downgraded organization to the Free Plan', - { duration: hasProjectsWithComputeDowngrade ? 8000 : 4000 } - ) - onClose(true) - window.scrollTo({ top: 0, left: 0, behavior: 'smooth' }) - } - }, - } - ) + updateOrgSubscription({ slug, tier: 'tier_free' }) } return ( diff --git a/apps/studio/components/interfaces/ProjectHome/PlanUsageCard.tsx b/apps/studio/components/interfaces/ProjectHome/PlanUsageCard.tsx index dff70bbf0ef28..740ff099c64b2 100644 --- a/apps/studio/components/interfaces/ProjectHome/PlanUsageCard.tsx +++ b/apps/studio/components/interfaces/ProjectHome/PlanUsageCard.tsx @@ -113,8 +113,8 @@ const ProgressRing = ({ ) } -// The upgrade CTA placement experiment variant this card represents. Used as the telemetry -// `source` + `placement` value. Kept as a constant so the tracking stays explicit. +// The upgrade CTA surface this card represents. Used as the telemetry `source` + +// `placement` value. Kept as a constant so the tracking stays explicit. const PLACEMENT = 'org_projects_list' const CompactMetricRow = ({ @@ -179,10 +179,10 @@ const SkeletonMetricRow = ({ label }: { label: string }) => ( ) /** - * Renders the upgrade CTA's plan-usage card as the first tile in the org project list - * (the `org_projects_list` experiment variant). The parent is responsible for gating on - * the experiment variant + free plan — this component only renders the visual sections - * once usage data is available. Shaped like a `ProjectCard` so it reads as another tile. + * Renders the upgrade CTA's plan-usage card in the org project list (the + * `org_projects_list` CTA placement). The parent is responsible for gating on free plan — + * this component only renders the visual sections once usage data is available. Shaped + * like a `ProjectCard` so it reads as another tile. */ export const PlanUsageCard = () => { const track = useTrack() diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.ts b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.ts index 5a5d6d7e5abf5..ed7c5385af9fc 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.ts +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.ts @@ -351,7 +351,7 @@ const applySearchParamsFilter = (search: QuerySearchParamsType): SafeLogSqlFragm */ export const getUnifiedLogsQuery = (search: QuerySearchParamsType): SafeLogSqlFragment => { const conditions = buildBaseWhere(search) - return safeSql` + return safeSql`-- unified logs: row list SELECT ${rowProjection()} FROM logs ${whereClause(conditions)} @@ -397,7 +397,7 @@ export const getFacetCountQuery = ({ conditions.push(safeSql`(${facetExpr}) LIKE ${lit('%' + facetSearch + '%')}`) } - return safeSql` + return safeSql`-- unified logs: single-facet counts (${lit(facet)}) SELECT ${lit(facet)} AS facet, (${facetExpr}) AS value, count() AS count FROM logs ${whereClause(conditions)} @@ -466,7 +466,8 @@ HAVING value != '' // rejects LIMIT BY inside the shared arrayJoin). blocks.push(safeSql`(${getFacetCountQuery({ search, facet: 'pathname' })})`) - return joinSqlFragments(blocks, ' UNION ALL ') + return safeSql`-- unified logs: sidebar facet counts +${joinSqlFragments(blocks, ' UNION ALL ')}` } /** @@ -477,7 +478,7 @@ export const getLogsChartQuery = (search: QuerySearchParamsType): SafeLogSqlFrag const truncFn = truncationFunction(truncationLevel) const conditions = buildBaseWhere(search) - return safeSql` + return safeSql`-- unified logs: severity chart (${truncFn} buckets) SELECT ${truncFn}(timestamp) AS time_bucket, countIf((${LEVEL_EXPR}) = 'success') AS success, diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogsBanner.tsx b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogsBanner.tsx index f42e66ef45f98..43d1c936c5ae5 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogsBanner.tsx +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogsBanner.tsx @@ -8,6 +8,7 @@ import { useUnifiedLogsPreview, } from '../App/FeaturePreview/FeaturePreviewContext' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' +import { IS_PLATFORM } from '@/lib/constants' interface UnifiedLogsBannerProps { className?: string @@ -20,6 +21,8 @@ export function UnifiedLogsBanner({ className = 'mx-4 mt-4' }: UnifiedLogsBanner const { selectFeaturePreview } = useFeaturePreviewModal() const { enable, disable, isDefaultOptIn, isEnabled } = useUnifiedLogsPreview() + if (!IS_PLATFORM) return null + const cardClassName = cn( 'rounded-lg border p-4 space-y-3 text-left', 'bg-muted/10 border-border/50', diff --git a/apps/studio/components/interfaces/UserDropdown.tsx b/apps/studio/components/interfaces/UserDropdown.tsx index ae004c1a9c107..f9cb865fcd840 100644 --- a/apps/studio/components/interfaces/UserDropdown.tsx +++ b/apps/studio/components/interfaces/UserDropdown.tsx @@ -23,7 +23,7 @@ import { TimezoneDropdown } from './UserDropdown/TimezoneDropdown' import { ProfileImage } from '@/components/ui/ProfileImage' import { UpgradePlanButton } from '@/components/ui/UpgradePlanButton' import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' -import { useUpgradeCtaExperiment } from '@/hooks/misc/useUpgradeCtaExperiment' +import { useShowUpgradeCta } from '@/hooks/misc/useShowUpgradeCta' import { IS_PLATFORM } from '@/lib/constants' import { useProfileNameAndPicture } from '@/lib/profile' import { useTrack } from '@/lib/telemetry/track' @@ -45,12 +45,12 @@ export function UserDropdown({ const { toggleFeaturePreviewModal } = useFeaturePreviewModal() const track = useTrack() - const { variant: upgradeCtaVariant } = useUpgradeCtaExperiment() - // Per Slack feedback (Jonny): the upgrade CTA is org-scoped, so only show it on routes - // where an org is in scope. Excludes /account/*, /organizations, /new, marketing routes, etc. + // The upgrade CTA is org-scoped, so only enable it on routes where an org is in scope. + // Excludes /account/*, /organizations, /new, marketing routes, etc. Gating the hook here + // also skips fetching organization data on routes where the CTA can't render. const isOrgScopedRoute = router.pathname.startsWith('/project/') || router.pathname.startsWith('/org/') - const showUpgradeCta = upgradeCtaVariant === 'user_dropdown' && isOrgScopedRoute + const { showUpgradeCta } = useShowUpgradeCta({ enabled: isOrgScopedRoute }) const [isOpen, setIsOpen] = useState(false) diff --git a/apps/studio/components/layouts/DefaultLayout.tsx b/apps/studio/components/layouts/DefaultLayout.tsx index 09e2c1c0b313d..9b4c23f424aa4 100644 --- a/apps/studio/components/layouts/DefaultLayout.tsx +++ b/apps/studio/components/layouts/DefaultLayout.tsx @@ -1,7 +1,7 @@ import { useBreakpoint, useParams } from 'common' import { useRouter } from 'next/router' import { PropsWithChildren, useEffect, useState } from 'react' -import { ResizablePanel, ResizablePanelGroup, SidebarProvider } from 'ui' +import { ResizablePanel, ResizablePanelGroup, SidebarProvider, usePanelRef } from 'ui' import { BannerStack } from '../ui/BannerStack/BannerStack' import { LayoutHeader } from './Navigation/LayoutHeader/LayoutHeader' @@ -9,7 +9,10 @@ import MobileNavigationBar from './Navigation/NavigationBar/MobileNavigationBar' import { MobileSheetProvider } from './Navigation/NavigationBar/MobileSheetContext' import { StudioMobileSheetNav } from './Navigation/NavigationBar/StudioMobileSheetNav' import { LayoutSidebar } from './ProjectLayout/LayoutSidebar' -import { LayoutSidebarProvider } from './ProjectLayout/LayoutSidebar/LayoutSidebarProvider' +import { + LayoutSidebarProvider, + SIDEBAR_KEYS, +} from './ProjectLayout/LayoutSidebar/LayoutSidebarProvider' import { ProjectContextProvider } from './ProjectLayout/ProjectContext' import { AppBannerWrapper } from '@/components/interfaces/App/AppBannerWrapper' import { Sidebar } from '@/components/interfaces/Sidebar' @@ -17,6 +20,7 @@ import { useLastVisitedOrganization } from '@/hooks/misc/useLastVisitedOrganizat import { useCheckLatestDeploy } from '@/hooks/use-check-latest-deploy' import { IS_PLATFORM } from '@/lib/constants' import { useAppStateSnapshot } from '@/state/app-state' +import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state' export interface DefaultLayoutProps { headerTitle?: string @@ -38,12 +42,18 @@ export const DefaultLayout = ({ headerTitle, hideMobileMenu, }: PropsWithChildren) => { + useCheckLatestDeploy() + const { ref } = useParams() const router = useRouter() + const panelRef = usePanelRef() + const isMobile = useBreakpoint('md') const appSnap = useAppStateSnapshot() - + const { isMaximised, activeSidebar } = useSidebarManagerSnapshot() const { lastVisitedOrganization } = useLastVisitedOrganization() + const [isMounted, setIsMounted] = useState(false) + const backToDashboardURL = router.pathname.startsWith('/account') ? appSnap.lastRouteBeforeVisitingAccountPage.length > 0 ? appSnap.lastRouteBeforeVisitingAccountPage @@ -54,19 +64,22 @@ export const DefaultLayout = ({ : '/project/default' : undefined - useCheckLatestDeploy() - - const isMobile = useBreakpoint('md') - const contentMinSizePercentage = 50 const contentMaxSizePercentage = 70 - const [isMounted, setIsMounted] = useState(false) - useEffect(() => { setIsMounted(true) }, []) + useEffect(() => { + if (!isMounted || !panelRef.current || !activeSidebar) return + if (isMaximised) { + panelRef.current.collapse() + } else { + panelRef.current.resize(`${contentMaxSizePercentage}%`) + } + }, [isMounted, isMaximised, panelRef, activeSidebar]) + // This is required to prevent layout shift when rendering resizable panels (they initially render at 50%, then shift // to whatever is specified). if (!isMounted) { @@ -106,6 +119,8 @@ export const DefaultLayout = ({ diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/index.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/index.tsx index 0c70576cc2d82..4c243020fcac2 100644 --- a/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/index.tsx +++ b/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/index.tsx @@ -24,8 +24,8 @@ export const LayoutSidebar = ({ maxSize = '50', defaultSize = '30', }: LayoutSidebarProps) => { - const { activeSidebar } = useSidebarManagerSnapshot() const isMobile = useBreakpoint('md') + const { activeSidebar } = useSidebarManagerSnapshot() const { content: sheetContent, setContent: setMobileSheetContent } = useMobileSheet() useEffect(() => { diff --git a/apps/studio/components/layouts/ProjectLayout/UpgradingState/index.tsx b/apps/studio/components/layouts/ProjectLayout/UpgradingState/index.tsx index d77b9dda74a9d..a909b77457fed 100644 --- a/apps/studio/components/layouts/ProjectLayout/UpgradingState/index.tsx +++ b/apps/studio/components/layouts/ProjectLayout/UpgradingState/index.tsx @@ -4,7 +4,6 @@ import { useParams } from 'common' import dayjs from 'dayjs' import { AlertCircle, - Check, CheckCircle, Circle, Loader, @@ -14,7 +13,7 @@ import { } from 'lucide-react' import { useSearchParams } from 'next/navigation' import { useState } from 'react' -import { Button, Tooltip, TooltipContent, TooltipTrigger } from 'ui' +import { Button, SuccessCheck, Tooltip, TooltipContent, TooltipTrigger } from 'ui' import { DATABASE_UPGRADE_MESSAGES } from './UpgradingState.constants' import { SupportLink } from '@/components/interfaces/Support/SupportLink' @@ -202,9 +201,7 @@ export const UpgradingState = () => { />
  • ) : isCompleted ? ( -
    - -
    + ) : (
    )} diff --git a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx index 22855bcedd581..dffa621856ecb 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx @@ -433,7 +433,7 @@ export const AIAssistant = ({ className }: AIAssistantProps) => { /> {hasMessages ? ( - + {renderedMessages} {error && ( <> @@ -555,9 +555,9 @@ export const AIAssistant = ({ className }: AIAssistantProps) => { )} -
    +
    {isSupportChat && !isSupportChatClosed && ( -
    +
    )} + {disablePrompts && ( { form>textarea]:text-base [&>form>textarea]:md:text-sm [&>form>textarea]:border [&>form>textarea]:rounded-md [&>form>textarea]:outline-hidden! [&>form>textarea]:ring-offset-0! [&>form>textarea]:ring-0!' + 'z-20', + '[&>form>textarea]:text-base [&>form>textarea]:md:text-sm [&>form>textarea]:border', + '[&>form>textarea]:rounded-md [&>form>textarea]:outline-hidden!', + '[&>form>textarea]:ring-offset-0! [&>form>textarea]:ring-0!' )} loading={isChatLoading} isEditing={!!editingMessageId} diff --git a/apps/studio/components/ui/AIAssistantPanel/AIAssistantHeader.tsx b/apps/studio/components/ui/AIAssistantPanel/AIAssistantHeader.tsx index 131c071d8ae06..8725e83dcb1ba 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AIAssistantHeader.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/AIAssistantHeader.tsx @@ -1,4 +1,13 @@ -import { Clipboard, Edit, MessageCirclePlus, MoreVertical, Settings, X } from 'lucide-react' +import { + Clipboard, + Edit, + Maximize, + MessageCirclePlus, + Minimize, + MoreVertical, + Settings, + X, +} from 'lucide-react' import { KeyboardEvent, useState } from 'react' import { toast } from 'sonner' import { @@ -20,6 +29,7 @@ import { AIOptInModal } from './AIOptInModal' import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state' import { SHORTCUT_DEFINITIONS, SHORTCUT_IDS } from '@/state/shortcuts/registry' import { useShortcut } from '@/state/shortcuts/useShortcut' +import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state' interface AIAssistantHeaderProps { isChatLoading: boolean @@ -41,6 +51,7 @@ export const AIAssistantHeader = ({ aiOptInLevel, }: AIAssistantHeaderProps) => { const snap = useAiAssistantStateSnapshot() + const { isMaximised, toggleMaximise } = useSidebarManagerSnapshot() const [value, setValue] = useState(snap.activeChat?.name) const [isEditingName, setIsEditingName] = useState(false) const [isOptInModalOpen, setIsOptInModalOpen] = useState(false) @@ -83,6 +94,10 @@ export const AIAssistantHeader = ({ enabled: !isChatLoading, }) + useShortcut(SHORTCUT_IDS.AI_ASSISTANT_MAXIMIZE, toggleMaximise, { + enabled: !isChatLoading, + }) + return (
    @@ -130,6 +145,21 @@ export const AIAssistantHeader = ({ /> + + )} - + {isLoadingCounts ? ( ) : facetedValue?.has(option.value) ? ( @@ -144,15 +144,17 @@ export function DataTableFilterCheckbox({ '0' ) : null} +
    diff --git a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCheckboxAsync.tsx b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCheckboxAsync.tsx index 57b1f1663061c..443cc14538bf1 100644 --- a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCheckboxAsync.tsx +++ b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCheckboxAsync.tsx @@ -107,7 +107,7 @@ export function DataTableFilterCheckboxAsync({ {option.label} )}
    - + {isLoadingCounts ? ( ) : facetedValue?.has(option.value) ? ( @@ -120,11 +120,12 @@ export function DataTableFilterCheckboxAsync({ type="button" onClick={() => column?.setFilterValue([option.value])} className={cn( - 'absolute inset-y-0 right-0 hidden font-normal text-muted-foreground backdrop-blur-xs hover:text-foreground group-hover:block', - 'rounded-md ring-offset-background focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2' + 'text-xs text-muted-foreground hover:text-foreground', + 'absolute inset-y-0 right-0 hidden bg-surface-100 group-hover:flex items-center cursor-pointer', + 'ring-offset-background focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2' )} > - only + Only
    diff --git a/apps/studio/components/ui/DataTable/TimelineChart.tsx b/apps/studio/components/ui/DataTable/TimelineChart.tsx index 64524e9cc1d56..3532853b4e387 100644 --- a/apps/studio/components/ui/DataTable/TimelineChart.tsx +++ b/apps/studio/components/ui/DataTable/TimelineChart.tsx @@ -48,8 +48,7 @@ export function TimelineChart({ const { table } = useDataTable() const chartHighlight = useChartHighlight() - const showHighlight = - chartHighlight?.left && chartHighlight?.right && chartHighlight?.left !== chartHighlight?.right + const showHighlight = !!chartHighlight?.left && !!chartHighlight?.right // REMINDER: date has to be a string for tooltip label to work - don't ask me why const chart = useMemo( @@ -69,6 +68,11 @@ export function TimelineChart({ return { interval, period: calculatePeriod(interval) } }, [data]) + const bucketWidthMs = useMemo( + () => (data.length > 1 ? Math.abs(data[1].timestamp - data[0].timestamp) : 0), + [data] + ) + const highlightActions: ChartHighlightAction[] = useMemo( () => [ { @@ -76,7 +80,9 @@ export function TimelineChart({ label: 'Filter logs to selected range', icon: , onSelect: ({ start, end, clear }) => { - const [left, right] = [start, end].sort( + const resolvedEnd = + start === end ? new Date(new Date(start).getTime() + bucketWidthMs).toString() : end + const [left, right] = [start, resolvedEnd].sort( (a, b) => new Date(a).getTime() - new Date(b).getTime() ) table.getColumn(resolvedFilterColumnId)?.setFilterValue([new Date(left), new Date(right)]) @@ -101,13 +107,23 @@ export function TimelineChart({ { + onMouseDown={({ activeLabel, activeTooltipIndex, chartX, chartY }) => { if (activeTooltipIndex === undefined || activeTooltipIndex === null) return - chartHighlight.handleMouseDown({ activeLabel, coordinates: activeLabel }) + chartHighlight.handleMouseDown({ + activeLabel, + coordinates: activeLabel, + chartX, + chartY, + }) }} - onMouseMove={({ activeLabel, activeTooltipIndex }) => { + onMouseMove={({ activeLabel, activeTooltipIndex, chartX, chartY }) => { if (activeTooltipIndex === undefined || activeTooltipIndex === null) return - chartHighlight.handleMouseMove({ activeLabel, coordinates: activeLabel }) + chartHighlight.handleMouseMove({ + activeLabel, + coordinates: activeLabel, + chartX, + chartY, + }) }} onMouseUp={chartHighlight.handleMouseUp} style={{ cursor: 'crosshair' }} @@ -131,26 +147,37 @@ export function TimelineChart({ return format(date, 'LLL dd, y') }} /> - {!chartHighlight.popoverPosition && ( - { - const date = new Date(value) - if (isNaN(date.getTime())) return 'N/A' - if (timerange.period === '10m') { - return format(date, 'LLL dd, HH:mm:ss') - } - return format(date, 'LLL dd, y HH:mm') - }} - /> - } - /> - )} + { + const date = new Date(value) + if (isNaN(date.getTime())) return 'N/A' + if (timerange.period === '10m') { + return format(date, 'LLL dd, HH:mm:ss') + } + return format(date, 'LLL dd, y HH:mm') + }} + /> + } + /> {/* TODO: we could use the `{timestamp, ...rest} = data[0]` to dynamically create the bars but that would mean the order can be very much random */} - - - + + + {showHighlight && ( - + secret diff --git a/apps/studio/data/logs/unified-log-inspection-query.ts b/apps/studio/data/logs/unified-log-inspection-query.ts index 1f65a1bb2536b..1bac15785309e 100644 --- a/apps/studio/data/logs/unified-log-inspection-query.ts +++ b/apps/studio/data/logs/unified-log-inspection-query.ts @@ -186,7 +186,7 @@ export async function getUnifiedLogInspection( if (!/^[0-9a-fA-F-]{1,64}$/.test(logId)) { throw new Error('Invalid logId') } - const sql = safeSql` + const sql = safeSql`-- unified logs: inspect single log by id SELECT id, timestamp, source, event_message, severity_text, log_attributes FROM logs WHERE id = ${lit(logId)} @@ -216,7 +216,7 @@ LIMIT 1 if (row.source === 'function_edge_logs') { const executionId = row.log_attributes?.['execution_id'] ?? row.log_attributes?.['request_id'] if (typeof executionId === 'string' && /^[0-9a-fA-F-]{1,64}$/.test(executionId)) { - const fnSql = safeSql` + const fnSql = safeSql`-- unified logs: edge function console logs for execution SELECT id, timestamp, source, event_message, severity_text, log_attributes FROM logs WHERE source = 'function_logs' AND log_attributes['execution_id'] = ${lit(executionId)} diff --git a/apps/studio/hooks/misc/__tests__/useShowUpgradeCta.test.ts b/apps/studio/hooks/misc/__tests__/useShowUpgradeCta.test.ts new file mode 100644 index 0000000000000..d616c3b7ecad1 --- /dev/null +++ b/apps/studio/hooks/misc/__tests__/useShowUpgradeCta.test.ts @@ -0,0 +1,91 @@ +import { renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, test, vi } from 'vitest' + +import { useShowUpgradeCta } from '../useShowUpgradeCta' + +const { mockIsPlatform, mockUseSelectedOrganizationQuery } = vi.hoisted(() => ({ + mockIsPlatform: { value: true }, + mockUseSelectedOrganizationQuery: vi.fn(), +})) + +vi.mock('@/lib/constants', async () => { + const actual = await vi.importActual>('@/lib/constants') + return { + ...actual, + get IS_PLATFORM() { + return mockIsPlatform.value + }, + } +}) + +vi.mock('@/hooks/misc/useSelectedOrganization', () => ({ + useSelectedOrganizationQuery: mockUseSelectedOrganizationQuery, +})) + +const freePlanOrg = { plan: { id: 'free' } } +const paidPlanOrg = { plan: { id: 'pro' } } + +describe('useShowUpgradeCta', () => { + beforeEach(() => { + mockIsPlatform.value = true + mockUseSelectedOrganizationQuery.mockReset() + }) + + test('hosted free plan: shows the CTA', () => { + mockUseSelectedOrganizationQuery.mockReturnValue({ data: freePlanOrg, isPending: false }) + + const { result } = renderHook(() => useShowUpgradeCta()) + + expect(result.current).toEqual({ isFreePlan: true, showUpgradeCta: true }) + }) + + test('hosted paid plan: hides the CTA', () => { + mockUseSelectedOrganizationQuery.mockReturnValue({ data: paidPlanOrg, isPending: false }) + + const { result } = renderHook(() => useShowUpgradeCta()) + + expect(result.current).toEqual({ isFreePlan: false, showUpgradeCta: false }) + }) + + test('self-hosted: never shows the CTA, even on a free plan', () => { + mockIsPlatform.value = false + mockUseSelectedOrganizationQuery.mockReturnValue({ data: freePlanOrg, isPending: false }) + + const { result } = renderHook(() => useShowUpgradeCta()) + + expect(result.current.showUpgradeCta).toBe(false) + }) + + test('plan still loading: hides the CTA until the org resolves', () => { + mockUseSelectedOrganizationQuery.mockReturnValue({ data: undefined, isPending: true }) + + const { result } = renderHook(() => useShowUpgradeCta()) + + expect(result.current).toEqual({ isFreePlan: false, showUpgradeCta: false }) + }) + + test('missing / errored organization: hides the CTA', () => { + mockUseSelectedOrganizationQuery.mockReturnValue({ data: undefined, isPending: false }) + + const { result } = renderHook(() => useShowUpgradeCta()) + + expect(result.current).toEqual({ isFreePlan: false, showUpgradeCta: false }) + }) + + test('enabled: false — hides the CTA and does not fetch organization data', () => { + mockUseSelectedOrganizationQuery.mockReturnValue({ data: undefined, isPending: true }) + + const { result } = renderHook(() => useShowUpgradeCta({ enabled: false })) + + expect(result.current.showUpgradeCta).toBe(false) + expect(mockUseSelectedOrganizationQuery).toHaveBeenCalledWith({ enabled: false }) + }) + + test('enabled + hosted: fetches organization data', () => { + mockUseSelectedOrganizationQuery.mockReturnValue({ data: freePlanOrg, isPending: false }) + + renderHook(() => useShowUpgradeCta({ enabled: true })) + + expect(mockUseSelectedOrganizationQuery).toHaveBeenCalledWith({ enabled: true }) + }) +}) diff --git a/apps/studio/hooks/misc/useShowUpgradeCta.ts b/apps/studio/hooks/misc/useShowUpgradeCta.ts new file mode 100644 index 0000000000000..c20834b3b6be1 --- /dev/null +++ b/apps/studio/hooks/misc/useShowUpgradeCta.ts @@ -0,0 +1,18 @@ +import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' +import { IS_PLATFORM } from '@/lib/constants' + +/** + * Whether to show the "Upgrade to Pro" CTA (free-plan orgs on the hosted platform only). + * Pass `enabled: false` where the CTA can't render to skip fetching organization data. + */ +export const useShowUpgradeCta = ({ enabled = true }: { enabled?: boolean } = {}) => { + const shouldEvaluate = IS_PLATFORM && enabled + const { data: organization, isPending } = useSelectedOrganizationQuery({ + enabled: shouldEvaluate, + }) + + const isFreePlan = organization?.plan?.id === 'free' + const showUpgradeCta = shouldEvaluate && !isPending && isFreePlan + + return { isFreePlan, showUpgradeCta } +} diff --git a/apps/studio/hooks/misc/useUpgradeCtaExperiment.ts b/apps/studio/hooks/misc/useUpgradeCtaExperiment.ts deleted file mode 100644 index 23ba7f53d9dab..0000000000000 --- a/apps/studio/hooks/misc/useUpgradeCtaExperiment.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { IS_PLATFORM, safeLocalStorage, useFeatureFlags, useParams } from 'common' -import { useEffect, useMemo } from 'react' - -import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' -import { useTrackExperimentExposure } from '@/hooks/misc/useTrackExperimentExposure' -import { usePHFlag } from '@/hooks/ui/useFlag' - -// PostHog flag key (camelCase, matches other flag naming in the codebase). -export const UPGRADE_CTA_FLAG_NAME = 'upgradeCtaPlacement' - -// snake_case experiment ID so the auto-fired exposure event name matches the -// `[experiment_id]_experiment_exposed` typed event registered in telemetry-constants.ts. -const UPGRADE_CTA_EXPERIMENT_ID = 'upgrade_cta_placement' - -// localStorage key prefix for the seeded variant (see hook docs). Keyed per org slug -// because eligibility folds in that org's plan. -const UPGRADE_CTA_SEED_PREFIX = 'supabase-upgrade-cta-variant-' - -export type UpgradeCtaPlacement = 'control' | 'user_dropdown' | 'org_projects_list' - -const VALID_VARIANTS: UpgradeCtaPlacement[] = ['control', 'user_dropdown', 'org_projects_list'] - -/** - * Shared experiment state for the upgrade CTA placement test. - * - * `variant` is the placement to render, and is gated on a confirmed free plan + the - * experiment flag — paid users never receive a variant, so the CTA never renders for them. - * - * First-paint correctness: PostHog flags are fetched async on every load, so the flag - * (and therefore the variant) is unknown at first paint. To avoid the CTA popping in - * (layout shift) or flashing, we persist the last resolved variant per org and seed from - * it synchronously. The seed is used only until the live flag + plan resolve, then the - * live value takes over and is re-persisted — so it self-heals if anything changed. A - * confirmed paid plan always wins over a stale seed, so the CTA can't flash for paid users. - * - * Net effect: correct at first paint with no shift/flash on every visit after the first - * (the first-ever visit to a given org still resolves async). - * - * Exposure tracking fires only once confirmed (free-plan + in experiment), so the - * experiment cohort stays accurate. - */ -export const useUpgradeCtaExperiment = () => { - const { slug } = useParams() - const { data: organization, isPending: isOrgPending } = useSelectedOrganizationQuery() - const flagStore = useFeatureFlags() - const flagValue = usePHFlag(UPGRADE_CTA_FLAG_NAME) - - const flagsLoaded = flagStore.hasLoaded === true - const planKnown = !isOrgPending - const isFreePlan = organization?.plan?.id === 'free' - const isInExperiment = - typeof flagValue === 'string' && VALID_VARIANTS.includes(flagValue as UpgradeCtaPlacement) - - // The definitive variant for a confirmed free-plan user in the experiment. - const liveVariant = isFreePlan && isInExperiment ? (flagValue as UpgradeCtaPlacement) : undefined - - // Synchronous seed from the last resolved variant for this org. Read via useMemo so it - // re-reads when the org slug changes (e.g. navigating between orgs without a remount). - const seedKey = `${UPGRADE_CTA_SEED_PREFIX}${slug ?? 'none'}` - const seededVariant = useMemo(() => { - const item = safeLocalStorage.getItem(seedKey) - if (!item) return null - try { - const parsed = JSON.parse(item) - return VALID_VARIANTS.includes(parsed) ? (parsed as UpgradeCtaPlacement) : null - } catch { - return null - } - }, [seedKey]) - - let variant: UpgradeCtaPlacement | undefined - if (!IS_PLATFORM) { - // No billing/plans on self-hosted, so there is nothing to upgrade to — never show. - variant = undefined - } else if (flagsLoaded && planKnown) { - // Fully resolved — source of truth. - variant = liveVariant - } else if (planKnown && !isFreePlan) { - // Confirmed paid — never show, even if a stale seed says otherwise. - variant = undefined - } else { - // Free, or still loading — trust the last known value to avoid a first-paint shift. - variant = seededVariant ?? undefined - } - - // Persist the last resolved variant once we have a definitive answer, so the next visit - // to this org can seed from it. Only matters before the live value resolves, so we don't - // need it in component state. - useEffect(() => { - if (!IS_PLATFORM || !flagsLoaded || !planKnown) return - safeLocalStorage.setItem(seedKey, JSON.stringify(liveVariant ?? null)) - }, [flagsLoaded, planKnown, liveVariant, seedKey]) - - useTrackExperimentExposure(UPGRADE_CTA_EXPERIMENT_ID, liveVariant) - - return { isFreePlan, variant } -} diff --git a/apps/studio/lib/constants/index.ts b/apps/studio/lib/constants/index.ts index 2dd90f2652da5..b177951c71619 100644 --- a/apps/studio/lib/constants/index.ts +++ b/apps/studio/lib/constants/index.ts @@ -59,12 +59,6 @@ export const GOTRUE_ERRORS = { export const STRIPE_PUBLIC_KEY = process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY || 'pk_test_XVwg5IZH3I9Gti98hZw6KRzd00v5858heG' -export const POSTHOG_URL = - process.env.NEXT_PUBLIC_ENVIRONMENT === 'staging' || - process.env.NEXT_PUBLIC_ENVIRONMENT === 'local' - ? 'https://ph.supabase.green' - : 'https://ph.supabase.com' - export const USAGE_APPROACHING_THRESHOLD = 0.75 export const DOCS_URL = process.env.NEXT_PUBLIC_DOCS_URL || 'https://supabase.com/docs' diff --git a/apps/studio/lib/project-supabase-client.contract.test.ts b/apps/studio/lib/project-supabase-client.contract.test.ts new file mode 100644 index 0000000000000..9f0ed4fcb0d03 --- /dev/null +++ b/apps/studio/lib/project-supabase-client.contract.test.ts @@ -0,0 +1,36 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { createProjectSupabaseClient } from './project-supabase-client' +import * as apiKeysUtils from '@/data/api-keys/temp-api-keys-utils' + +// Unlike project-supabase-client.test.ts, this file does not mock +// @supabase/supabase-js: the real client constructor must run. +vi.mock('@/data/api-keys/temp-api-keys-utils', () => ({ + getOrRefreshTemporaryApiKey: vi.fn(), +})) + +// Supabase API key formats this client must accept. +const KEY_SHAPES = [ + ['temporary key', 'sb_temp_nonce1234567890ab_payload'], + ['publishable key', 'sb_publishable_abc123'], + ['secret key', 'sb_secret_abc123'], + ['legacy JWT key', 'eyJhbGciOiJIUzI1NiJ9.payload.signature'], +] + +describe('createProjectSupabaseClient key format contract', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it.each(KEY_SHAPES)('constructs a client with a %s', async (_label, apiKey) => { + vi.mocked(apiKeysUtils.getOrRefreshTemporaryApiKey).mockResolvedValue({ + apiKey, + expiryTimeMs: Date.now() + 3600000, + }) + + const client = await createProjectSupabaseClient('test-ref', 'https://test.supabase.co') + + expect(client).toBeDefined() + expect(client.auth).toBeDefined() + }) +}) diff --git a/apps/studio/lib/project-supabase-client.test.ts b/apps/studio/lib/project-supabase-client.test.ts index 62f4a1a47f6fe..8a50f9b5f4e35 100644 --- a/apps/studio/lib/project-supabase-client.test.ts +++ b/apps/studio/lib/project-supabase-client.test.ts @@ -19,7 +19,7 @@ describe('project-supabase-client', () => { describe('createProjectSupabaseClient', () => { it('should create a Supabase client with temporary API key', async () => { - const mockApiKey = 'test-api-key-123' + const mockApiKey = 'sb_temp_nonce1234567890ab_payload1' const mockClient = { from: vi.fn() } const projectRef = 'test-project-ref' const clientEndpoint = 'https://test.supabase.co' @@ -49,7 +49,7 @@ describe('project-supabase-client', () => { }) it('should configure storage to not persist session', async () => { - const mockApiKey = 'test-api-key-456' + const mockApiKey = 'sb_temp_nonce1234567890ab_payload2' const mockClient = { from: vi.fn() } vi.mocked(apiKeysUtils.getOrRefreshTemporaryApiKey).mockResolvedValue({ @@ -82,7 +82,7 @@ describe('project-supabase-client', () => { }) it('should pass through different project refs and endpoints', async () => { - const mockApiKey = 'api-key' + const mockApiKey = 'sb_temp_nonce1234567890ab_payload3' const mockClient = { from: vi.fn() } vi.mocked(apiKeysUtils.getOrRefreshTemporaryApiKey).mockResolvedValue({ @@ -102,7 +102,7 @@ describe('project-supabase-client', () => { }) it('should disable session persistence options', async () => { - const mockApiKey = 'api-key' + const mockApiKey = 'sb_temp_nonce1234567890ab_payload4' const mockClient = { from: vi.fn() } vi.mocked(apiKeysUtils.getOrRefreshTemporaryApiKey).mockResolvedValue({ diff --git a/apps/studio/pages/org/[slug]/index.tsx b/apps/studio/pages/org/[slug]/index.tsx index 409d732765891..4673219bb6c7a 100644 --- a/apps/studio/pages/org/[slug]/index.tsx +++ b/apps/studio/pages/org/[slug]/index.tsx @@ -11,20 +11,19 @@ import OrganizationLayout from '@/components/layouts/OrganizationLayout' import { PageLayout } from '@/components/layouts/PageLayout/PageLayout' import { ScaffoldContainer, ScaffoldSection } from '@/components/layouts/Scaffold' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' -import { useUpgradeCtaExperiment } from '@/hooks/misc/useUpgradeCtaExperiment' +import { useShowUpgradeCta } from '@/hooks/misc/useShowUpgradeCta' import type { NextPageWithLayout } from '@/types' const ProjectsPage: NextPageWithLayout = () => { const isUserMFAEnabled = useIsMFAEnabled() const { data: org } = useSelectedOrganizationQuery() - const { variant: upgradeCtaVariant } = useUpgradeCtaExperiment() + const { showUpgradeCta: showOrgProjectsListUsageCard } = useShowUpgradeCta() const disableAccessMfa = org?.organization_requires_mfa && !isUserMFAEnabled - const showOrgProjectsListUsageCard = upgradeCtaVariant === 'org_projects_list' return ( - + {disableAccessMfa ? ( = { sequence: ['A', 'N'], showInSettings: false, }, + [SHORTCUT_IDS.AI_ASSISTANT_MAXIMIZE]: { + id: SHORTCUT_IDS.AI_ASSISTANT_MAXIMIZE, + label: 'Maximize assistant', + sequence: ['A', '='], + showInSettings: false, + }, [SHORTCUT_IDS.AI_ASSISTANT_COPY_CHAT_ID]: { id: SHORTCUT_IDS.AI_ASSISTANT_COPY_CHAT_ID, label: 'Copy chat ID', diff --git a/apps/studio/state/sidebar-manager-state.tsx b/apps/studio/state/sidebar-manager-state.tsx index c539eb4fc94a9..1a3f1f4ac59e7 100644 --- a/apps/studio/state/sidebar-manager-state.tsx +++ b/apps/studio/state/sidebar-manager-state.tsx @@ -17,6 +17,7 @@ type SidebarManagerData = { sidebars: Partial> activeSidebar: ManagedSidebar | undefined pendingSidebarOpen: string | undefined + isMaximised: boolean } type SidebarManagerState = SidebarManagerData & { @@ -32,12 +33,14 @@ type SidebarManagerState = SidebarManagerData & { isSidebarOpen: (id: string) => boolean closeActive: () => void clearActiveSidebar: () => void + toggleMaximise: () => void } const INITIAL_SIDEBAR_MANAGER_DATA: SidebarManagerData = { sidebars: {}, activeSidebar: undefined, pendingSidebarOpen: undefined, + isMaximised: false, } const createSidebarManagerState = () => { @@ -116,6 +119,10 @@ const createSidebarManagerState = () => { return } + if (id !== 'ai-assistant' && state.isMaximised) { + state.toggleMaximise() + } + if (state.activeSidebar && state.activeSidebar.id !== id) { const previousPanel = state.activeSidebar previousPanel?.onClose?.() @@ -149,6 +156,10 @@ const createSidebarManagerState = () => { clearActiveSidebar() { state.activeSidebar = undefined }, + + toggleMaximise() { + state.isMaximised = !state.isMaximised + }, }) return state diff --git a/apps/studio/styles/globals.css b/apps/studio/styles/globals.css index 11a94f0d7b056..23eab4d6eb1d9 100644 --- a/apps/studio/styles/globals.css +++ b/apps/studio/styles/globals.css @@ -147,7 +147,7 @@ --chart-warning: hsl(var(--warning-500)); --chart-warning-muted: hsl(var(--warning-400)); --chart-destructive: hsl(var(--destructive-default)); - --chart-success: color-mix(in oklch, var(--foreground-muted) 65%, white); + --chart-success: color-mix(in oklch, var(--foreground-muted) 50%, white); --sidebar-background: var(--background-dash-sidebar); --sidebar-foreground: var(--foreground-default); --sidebar-primary: var(--foreground-default); @@ -170,7 +170,7 @@ --chart-warning: hsl(var(--warning-default)); --chart-warning-muted: hsl(var(--warning-500)); --chart-destructive: hsl(var(--destructive-default)); - --chart-success: color-mix(in oklch, var(--foreground-muted) 65%, white); + --chart-success: color-mix(in oklch, var(--foreground-muted) 55%, var(--background)); --sidebar-background: var(--background-dash-sidebar); --sidebar-foreground: var(--foreground-default); --sidebar-primary: var(--foreground-default); diff --git a/apps/ui-library/app/(app)/page.tsx b/apps/ui-library/app/(app)/page.tsx index 567c15e01b533..9dc1778a06e57 100644 --- a/apps/ui-library/app/(app)/page.tsx +++ b/apps/ui-library/app/(app)/page.tsx @@ -50,7 +50,7 @@ export default function Home() { Get Started - Install Skills + Install Skills
    diff --git a/apps/ui-library/components/side-navigation.tsx b/apps/ui-library/components/side-navigation.tsx index 66e397f69b911..21d4a9da46847 100644 --- a/apps/ui-library/components/side-navigation.tsx +++ b/apps/ui-library/components/side-navigation.tsx @@ -3,7 +3,7 @@ import Link from 'next/link' import { CommandMenu } from './command-menu' import { ThemeSwitcherDropdown } from './theme-switcher-dropdown' import NavigationItem from '@/components/side-navigation-item' -import { aiEditorsRules, componentPages, gettingStarted, platformBlocks } from '@/config/docs' +import { componentPages, gettingStarted, platformBlocks } from '@/config/docs' function SideNavigation() { return ( @@ -88,15 +88,6 @@ function SideNavigation() { ))}
    - -
    -
    - {aiEditorsRules.title} -
    - {aiEditorsRules.items.map((item, i) => ( - - ))} -
    {platformBlocks.title} diff --git a/apps/ui-library/config/docs.ts b/apps/ui-library/config/docs.ts index acdc71c46d6e7..e0585e27e78da 100644 --- a/apps/ui-library/config/docs.ts +++ b/apps/ui-library/config/docs.ts @@ -24,19 +24,6 @@ export const gettingStarted: SidebarNavGroup = { ], } -export const aiEditorsRules: SidebarNavGroup = { - title: 'AI Skills', - items: [ - { - title: 'Skills', - href: '/docs/ai-editors-rules/skills', - items: [], - new: true, - commandItemLabel: 'AI Skills', - }, - ], -} - export const platformBlocks: SidebarNavGroup = { title: 'Platform', items: [ @@ -142,10 +129,6 @@ export const COMMAND_ITEMS = [ label: item.commandItemLabel, href: item.href, })), - ...aiEditorsRules.items.map((item) => ({ - label: item.commandItemLabel, - href: item.href, - })), ...componentPages.items.map((item) => ({ label: item.commandItemLabel, href: item.href, diff --git a/apps/ui-library/content/docs/ai-editors-rules/skills.mdx b/apps/ui-library/content/docs/ai-editors-rules/skills.mdx deleted file mode 100644 index 6c1825c44ac7e..0000000000000 --- a/apps/ui-library/content/docs/ai-editors-rules/skills.mdx +++ /dev/null @@ -1,42 +0,0 @@ ---- -title: Skills -description: Agent Skills for Supabase — procedural knowledge and context that AI agents can load on demand ---- - -Agent Skills are folders of instructions, scripts, and resources that agents can discover and use to do things more accurately and efficiently. Skills give agents access to procedural knowledge and Supabase-specific context they can load on demand, so they can do real work reliably. - -Skills work with 18+ AI agents including Claude Code, GitHub Copilot, Cursor, Cline, and many others. - -## Installation - -Run these commands in the root of your project — skills are installed per-project, similar to how you'd add a config file or dev dependency. Once installed, your AI agent will automatically pick them up the next time it runs. - -Install all Supabase skills using the skills CLI: - -```bash -npx skills add supabase/agent-skills -``` - -This installs the full set of Supabase skills — the right choice for most projects. If you're doing a lot of database work, also add the Postgres-specific skill: - -```bash -npx skills add supabase/agent-skills --skill supabase-postgres-best-practices -``` - -The `supabase-postgres-best-practices` skill covers Postgres performance optimization, query design, and schema best practices. It's used automatically when writing, reviewing, or optimizing queries and database configurations. - -### Claude Code - -You can also install the skills as Claude Code plugins: - -```bash -/plugin marketplace add supabase/agent-skills -/plugin install supabase@supabase-agent-skills -``` - -## Learn more - -For the full list of available skills, installation options, and usage guidance, see the [Agent Skills documentation](https://supabase.com/docs/guides/getting-started/ai-skills). - -- [Agent Skills Repository](https://github.com/supabase/agent-skills) -- [Agent Skills Documentation](https://agentskills.io/home) diff --git a/apps/ui-library/public/r/ai-editor-rules.json b/apps/ui-library/public/r/ai-editor-rules.json deleted file mode 100644 index 4307763b36ff7..0000000000000 --- a/apps/ui-library/public/r/ai-editor-rules.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "$schema": "https://ui.shadcn.com/schema/registry-item.json", - "name": "ai-editor-rules", - "type": "registry:file", - "title": "Prompt rules for AI Editors", - "description": "Prompts for working with Supabase using AI-powered IDE tools", - "dependencies": [], - "registryDependencies": [], - "files": [ - { - "path": "registry/default/ai-editor-rules/create-db-functions.mdc", - "content": "---\n# Specify the following for Cursor rules\ndescription: Guidelines for writing Supabase database functions\nalwaysApply: false\n---\n\n# Database: Create functions\n\nYou're a Supabase Postgres expert in writing database functions. Generate **high-quality PostgreSQL functions** that adhere to the following best practices:\n\n## General Guidelines\n\n1. **Default to `SECURITY INVOKER`:**\n\n - Functions should run with the permissions of the user invoking the function, ensuring safer access control.\n - Use `SECURITY DEFINER` only when explicitly required and explain the rationale.\n\n2. **Set the `search_path` Configuration Parameter:**\n\n - Always set `search_path` to an empty string (`set search_path = '';`).\n - This avoids unexpected behavior and security risks caused by resolving object references in untrusted or unintended schemas.\n - Use fully qualified names (e.g., `schema_name.table_name`) for all database objects referenced within the function.\n\n3. **Adhere to SQL Standards and Validation:**\n - Ensure all queries within the function are valid PostgreSQL SQL queries and compatible with the specified context (ie. Supabase).\n\n## Best Practices\n\n1. **Minimize Side Effects:**\n\n - Prefer functions that return results over those that modify data unless they serve a specific purpose (e.g., triggers).\n\n2. **Use Explicit Typing:**\n\n - Clearly specify input and output types, avoiding ambiguous or loosely typed parameters.\n\n3. **Default to Immutable or Stable Functions:**\n\n - Where possible, declare functions as `IMMUTABLE` or `STABLE` to allow better optimization by PostgreSQL. Use `VOLATILE` only if the function modifies data or has side effects.\n\n4. **Triggers (if Applicable):**\n - If the function is used as a trigger, include a valid `CREATE TRIGGER` statement that attaches the function to the desired table and event (e.g., `BEFORE INSERT`).\n\n## Example Templates\n\n### Simple Function with `SECURITY INVOKER`\n\n```sql\ncreate or replace function my_schema.hello_world()\nreturns text\nlanguage plpgsql\nsecurity invoker\nset search_path = ''\nas $$\nbegin\n return 'hello world';\nend;\n$$;\n```\n\n### Function with Parameters and Fully Qualified Object Names\n\n```sql\ncreate or replace function public.calculate_total_price(order_id bigint)\nreturns numeric\nlanguage plpgsql\nsecurity invoker\nset search_path = ''\nas $$\ndeclare\n total numeric;\nbegin\n select sum(price * quantity)\n into total\n from public.order_items\n where order_id = calculate_total_price.order_id;\n\n return total;\nend;\n$$;\n```\n\n### Function as a Trigger\n\n```sql\ncreate or replace function my_schema.update_updated_at()\nreturns trigger\nlanguage plpgsql\nsecurity invoker\nset search_path = ''\nas $$\nbegin\n -- Update the \"updated_at\" column on row modification\n new.updated_at := now();\n return new;\nend;\n$$;\n\ncreate trigger update_updated_at_trigger\nbefore update on my_schema.my_table\nfor each row\nexecute function my_schema.update_updated_at();\n```\n\n### Function with Error Handling\n\n```sql\ncreate or replace function my_schema.safe_divide(numerator numeric, denominator numeric)\nreturns numeric\nlanguage plpgsql\nsecurity invoker\nset search_path = ''\nas $$\nbegin\n if denominator = 0 then\n raise exception 'Division by zero is not allowed';\n end if;\n\n return numerator / denominator;\nend;\n$$;\n```\n\n### Immutable Function for Better Optimization\n\n```sql\ncreate or replace function my_schema.full_name(first_name text, last_name text)\nreturns text\nlanguage sql\nsecurity invoker\nset search_path = ''\nimmutable\nas $$\n select first_name || ' ' || last_name;\n$$;\n```\n", - "type": "registry:file", - "target": "~/.cursor/rules/create-db-functions.mdc" - }, - { - "path": "registry/default/ai-editor-rules/create-migration.mdc", - "content": "---\n# Specify the following for Cursor rules\ndescription: Guidelines for writing Postgres migrations\nalwaysApply: false\n---\n\n# Database: Create migration\n\nYou are a Postgres Expert who loves creating secure database schemas.\n\nThis project uses the migrations provided by the Supabase CLI.\n\n## Creating a migration file\n\nGiven the context of the user's message, create a database migration file inside the folder `supabase/migrations/`.\n\nThe file MUST following this naming convention:\n\nThe file MUST be named in the format `YYYYMMDDHHmmss_short_description.sql` with proper casing for months, minutes, and seconds in UTC time:\n\n1. `YYYY` - Four digits for the year (e.g., `2024`).\n2. `MM` - Two digits for the month (01 to 12).\n3. `DD` - Two digits for the day of the month (01 to 31).\n4. `HH` - Two digits for the hour in 24-hour format (00 to 23).\n5. `mm` - Two digits for the minute (00 to 59).\n6. `ss` - Two digits for the second (00 to 59).\n7. Add an appropriate description for the migration.\n\nFor example:\n\n```\n20240906123045_create_profiles.sql\n```\n\n## SQL Guidelines\n\nWrite Postgres-compatible SQL code for Supabase migration files that:\n\n- Includes a header comment with metadata about the migration, such as the purpose, affected tables/columns, and any special considerations.\n- Includes thorough comments explaining the purpose and expected behavior of each migration step.\n- Write all SQL in lowercase.\n- Add copious comments for any destructive SQL commands, including truncating, dropping, or column alterations.\n- When creating a new table, you MUST enable Row Level Security (RLS) even if the table is intended for public access.\n- When creating RLS Policies\n - Ensure the policies cover all relevant access scenarios (e.g. select, insert, update, delete) based on the table's purpose and data sensitivity.\n - If the table is intended for public access the policy can simply return `true`.\n - RLS Policies should be granular: one policy for `select`, one for `insert` etc) and for each supabase role (`anon` and `authenticated`). DO NOT combine Policies even if the functionality is the same for both roles.\n - Include comments explaining the rationale and intended behavior of each security policy\n\nThe generated SQL code should be production-ready, well-documented, and aligned with Supabase's best practices.\n", - "type": "registry:file", - "target": "~/.cursor/rules/create-migration.mdc" - }, - { - "path": "registry/default/ai-editor-rules/create-rls-policies.mdc", - "content": "---\ndescription: Guidelines for writing Postgres Row Level Security policies\nalwaysApply: false\n---\n\n# Database: Create RLS policies\n\nYou're a Supabase Postgres expert in writing row level security policies. Your purpose is to generate a policy with the constraints given by the user. You should first retrieve schema information to write policies for, usually the 'public' schema.\n\nThe output should use the following instructions:\n\n- The generated SQL must be valid SQL.\n- You can use only CREATE POLICY or ALTER POLICY queries, no other queries are allowed.\n- Always use double apostrophe in SQL strings (eg. 'Night''s watch')\n- You can add short explanations to your messages.\n- The result should be a valid markdown. The SQL code should be wrapped in ``` (including sql language tag).\n- Always use \"auth.uid()\" instead of \"current_user\".\n- SELECT policies should always have USING but not WITH CHECK\n- INSERT policies should always have WITH CHECK but not USING\n- UPDATE policies should always have WITH CHECK and most often have USING\n- DELETE policies should always have USING but not WITH CHECK\n- Don't use `FOR ALL`. Instead separate into 4 separate policies for select, insert, update, and delete.\n- The policy name should be short but detailed text explaining the policy, enclosed in double quotes.\n- Always put explanations as separate text. Never use inline SQL comments.\n- If the user asks for something that's not related to SQL policies, explain to the user\n that you can only help with policies.\n- Discourage `RESTRICTIVE` policies and encourage `PERMISSIVE` policies, and explain why.\n\nThe output should look like this:\n\n```sql\nCREATE POLICY \"My descriptive policy.\" ON books FOR INSERT to authenticated USING ( (select auth.uid()) = author_id ) WITH ( true );\n```\n\nSince you are running in a Supabase environment, take note of these Supabase-specific additions below.\n\n## Authenticated and unauthenticated roles\n\nSupabase maps every request to one of the roles:\n\n- `anon`: an unauthenticated request (the user is not logged in)\n- `authenticated`: an authenticated request (the user is logged in)\n\nThese are actually [Postgres Roles](mdc:docs/guides/database/postgres/roles). You can use these roles within your Policies using the `TO` clause:\n\n```sql\ncreate policy \"Profiles are viewable by everyone\"\non profiles\nfor select\nto authenticated, anon\nusing ( true );\n\n-- OR\n\ncreate policy \"Public profiles are viewable only by authenticated users\"\non profiles\nfor select\nto authenticated\nusing ( true );\n```\n\nNote that `for ...` must be added after the table but before the roles. `to ...` must be added after `for ...`:\n\n### Incorrect\n\n```sql\ncreate policy \"Public profiles are viewable only by authenticated users\"\non profiles\nto authenticated\nfor select\nusing ( true );\n```\n\n### Correct\n\n```sql\ncreate policy \"Public profiles are viewable only by authenticated users\"\non profiles\nfor select\nto authenticated\nusing ( true );\n```\n\n## Multiple operations\n\nPostgreSQL policies do not support specifying multiple operations in a single FOR clause. You need to create separate policies for each operation.\n\n### Incorrect\n\n```sql\ncreate policy \"Profiles can be created and deleted by any user\"\non profiles\nfor insert, delete -- cannot create a policy on multiple operators\nto authenticated\nwith check ( true )\nusing ( true );\n```\n\n### Correct\n\n```sql\ncreate policy \"Profiles can be created by any user\"\non profiles\nfor insert\nto authenticated\nwith check ( true );\n\ncreate policy \"Profiles can be deleted by any user\"\non profiles\nfor delete\nto authenticated\nusing ( true );\n```\n\n## Helper functions\n\nSupabase provides some helper functions that make it easier to write Policies.\n\n### `auth.uid()`\n\nReturns the ID of the user making the request.\n\n### `auth.jwt()`\n\nReturns the JWT of the user making the request. Anything that you store in the user's `raw_app_meta_data` column or the `raw_user_meta_data` column will be accessible using this function. It's important to know the distinction between these two:\n\n- `raw_user_meta_data` - can be updated by the authenticated user using the `supabase.auth.update()` function. It is not a good place to store authorization data.\n- `raw_app_meta_data` - cannot be updated by the user, so it's a good place to store authorization data.\n\nThe `auth.jwt()` function is extremely versatile. For example, if you store some team data inside `app_metadata`, you can use it to determine whether a particular user belongs to a team. For example, if this was an array of IDs:\n\n```sql\ncreate policy \"User is in team\"\non my_table\nto authenticated\nusing ( team_id in (select auth.jwt() -> 'app_metadata' -> 'teams'));\n```\n\n### MFA\n\nThe `auth.jwt()` function can be used to check for [Multi-Factor Authentication](mdc:docs/guides/auth/auth-mfa#enforce-rules-for-mfa-logins). For example, you could restrict a user from updating their profile unless they have at least 2 levels of authentication (Assurance Level 2):\n\n```sql\ncreate policy \"Restrict updates.\"\non profiles\nas restrictive\nfor update\nto authenticated using (\n (select auth.jwt()->>'aal') = 'aal2'\n);\n```\n\n## RLS performance recommendations\n\nEvery authorization system has an impact on performance. While row level security is powerful, the performance impact is important to keep in mind. This is especially true for queries that scan every row in a table - like many `select` operations, including those using limit, offset, and ordering.\n\nBased on a series of [tests](mdc:https:/github.com/GaryAustin1/RLS-Performance), we have a few recommendations for RLS:\n\n### Add indexes\n\nMake sure you've added [indexes](mdc:docs/guides/database/postgres/indexes) on any columns used within the Policies which are not already indexed (or primary keys). For a Policy like this:\n\n```sql\ncreate policy \"Users can access their own records\" on test_table\nto authenticated\nusing ( (select auth.uid()) = user_id );\n```\n\nYou can add an index like:\n\n```sql\ncreate index userid\non test_table\nusing btree (user_id);\n```\n\n### Call functions with `select`\n\nYou can use `select` statement to improve policies that use functions. For example, instead of this:\n\n```sql\ncreate policy \"Users can access their own records\" on test_table\nto authenticated\nusing ( auth.uid() = user_id );\n```\n\nYou can do:\n\n```sql\ncreate policy \"Users can access their own records\" on test_table\nto authenticated\nusing ( (select auth.uid()) = user_id );\n```\n\nThis method works well for JWT functions like `auth.uid()` and `auth.jwt()` as well as `security definer` Functions. Wrapping the function causes an `initPlan` to be run by the Postgres optimizer, which allows it to \"cache\" the results per-statement, rather than calling the function on each row.\n\nCaution: You can only use this technique if the results of the query or function do not change based on the row data.\n\n### Minimize joins\n\nYou can often rewrite your Policies to avoid joins between the source and the target table. Instead, try to organize your policy to fetch all the relevant data from the target table into an array or set, then you can use an `IN` or `ANY` operation in your filter.\n\nFor example, this is an example of a slow policy which joins the source `test_table` to the target `team_user`:\n\n```sql\ncreate policy \"Users can access records belonging to their teams\" on test_table\nto authenticated\nusing (\n (select auth.uid()) in (\n select user_id\n from team_user\n where team_user.team_id = team_id -- joins to the source \"test_table.team_id\"\n )\n);\n```\n\nWe can rewrite this to avoid this join, and instead select the filter criteria into a set:\n\n```sql\ncreate policy \"Users can access records belonging to their teams\" on test_table\nto authenticated\nusing (\n team_id in (\n select team_id\n from team_user\n where user_id = (select auth.uid()) -- no join\n )\n);\n```\n\n### Specify roles in your policies\n\nAlways use the Role of inside your policies, specified by the `TO` operator. For example, instead of this query:\n\n```sql\ncreate policy \"Users can access their own records\" on rls_test\nusing ( auth.uid() = user_id );\n```\n\nUse:\n\n```sql\ncreate policy \"Users can access their own records\" on rls_test\nto authenticated\nusing ( (select auth.uid()) = user_id );\n```\n\nThis prevents the policy `( (select auth.uid()) = user_id )` from running for any `anon` users, since the execution stops at the `to authenticated` step.\n", - "type": "registry:file", - "target": "~/.cursor/rules/create-rls-policies.mdc" - }, - { - "path": "registry/default/ai-editor-rules/postgres-sql-style-guide.mdc", - "content": "---\n# Specify the following for Cursor rules\ndescription: Guidelines for writing Postgres SQL\nalwaysApply: false\n---\n\n# Postgres SQL Style Guide\n\n## General\n\n- Use lowercase for SQL reserved words to maintain consistency and readability.\n- Employ consistent, descriptive identifiers for tables, columns, and other database objects.\n- Use white space and indentation to enhance the readability of your code.\n- Store dates in ISO 8601 format (`yyyy-mm-ddThh:mm:ss.sssss`).\n- Include comments for complex logic, using '/_ ... _/' for block comments and '--' for line comments.\n\n## Naming Conventions\n\n- Avoid SQL reserved words and ensure names are unique and under 63 characters.\n- Use snake_case for tables and columns.\n- Prefer plurals for table names\n- Prefer singular names for columns.\n\n## Tables\n\n- Avoid prefixes like 'tbl\\_' and ensure no table name matches any of its column names.\n- Always add an `id` column of type `identity generated always` unless otherwise specified.\n- Create all tables in the `public` schema unless otherwise specified.\n- Always add the schema to SQL queries for clarity.\n- Always add a comment to describe what the table does. The comment can be up to 1024 characters.\n\n## Columns\n\n- Use singular names and avoid generic names like 'id'.\n- For references to foreign tables, use the singular of the table name with the `_id` suffix. For example `user_id` to reference the `users` table\n- Always use lowercase except in cases involving acronyms or when readability would be enhanced by an exception.\n\n#### Examples:\n\n```sql\ncreate table books (\n id bigint generated always as identity primary key,\n title text not null,\n author_id bigint references authors (id)\n);\ncomment on table books is 'A list of all the books in the library.';\n```\n\n## Queries\n\n- When the query is shorter keep it on just a few lines. As it gets larger start adding newlines for readability\n- Add spaces for readability.\n\nSmaller queries:\n\n```sql\nselect *\nfrom employees\nwhere end_date is null;\n\nupdate employees\nset end_date = '2023-12-31'\nwhere employee_id = 1001;\n```\n\nLarger queries:\n\n```sql\nselect\n first_name,\n last_name\nfrom employees\nwhere start_date between '2021-01-01' and '2021-12-31' and status = 'employed';\n```\n\n### Joins and Subqueries\n\n- Format joins and subqueries for clarity, aligning them with related SQL clauses.\n- Prefer full table names when referencing tables. This helps for readability.\n\n```sql\nselect\n employees.employee_name,\n departments.department_name\nfrom\n employees\n join departments on employees.department_id = departments.department_id\nwhere employees.start_date > '2022-01-01';\n```\n\n## Aliases\n\n- Use meaningful aliases that reflect the data or transformation applied, and always include the 'as' keyword for clarity.\n\n```sql\nselect count(*) as total_employees\nfrom employees\nwhere end_date is null;\n```\n\n## Complex queries and CTEs\n\n- If a query is extremely complex, prefer a CTE.\n- Make sure the CTE is clear and linear. Prefer readability over performance.\n- Add comments to each block.\n\n```sql\nwith\n department_employees as (\n -- Get all employees and their departments\n select\n employees.department_id,\n employees.first_name,\n employees.last_name,\n departments.department_name\n from\n employees\n join departments on employees.department_id = departments.department_id\n ),\n employee_counts as (\n -- Count how many employees in each department\n select\n department_name,\n count(*) as num_employees\n from department_employees\n group by department_name\n )\nselect\n department_name,\n num_employees\nfrom employee_counts\norder by department_name;\n```\n", - "type": "registry:file", - "target": "~/.cursor/rules/postgres-sql-style-guide.mdc" - }, - { - "path": "registry/default/ai-editor-rules/writing-supabase-edge-functions.mdc", - "content": "---\ndescription: Coding rules for Supabase Edge Functions\nalwaysApply: false\n---\n\n# Writing Supabase Edge Functions\n\nYou're an expert in writing TypeScript and Deno JavaScript runtime. Generate **high-quality Supabase Edge Functions** that adhere to the following best practices:\n\n## Guidelines\n\n1. Try to use Web APIs and Deno’s core APIs instead of external dependencies (eg: use fetch instead of Axios, use WebSockets API instead of node-ws)\n2. If you are reusing utility methods between Edge Functions, add them to `supabase/functions/_shared` and import using a relative path. Do NOT have cross dependencies between Edge Functions.\n3. Do NOT use bare specifiers when importing dependencies. If you need to use an external dependency, make sure it's prefixed with either `npm:` or `jsr:`. For example, `@supabase/supabase-js` should be written as `npm:@supabase/supabase-js`.\n4. For external imports, always define a version. For example, `npm:@express` should be written as `npm:express@4.18.2`.\n5. For external dependencies, importing via `npm:` and `jsr:` is preferred. Minimize the use of imports from @`deno.land/x` , `esm.sh` and @`unpkg.com` . If you have a package from one of those CDNs, you can replace the CDN hostname with `npm:` specifier.\n6. You can also use Node built-in APIs. You will need to import them using `node:` specifier. For example, to import Node process: `import process from \"node:process\". Use Node APIs when you find gaps in Deno APIs.\n7. Do NOT use `import { serve } from \"https://deno.land/std@0.168.0/http/server.ts\"`. Instead use the built-in `Deno.serve`.\n8. Following environment variables (ie. secrets) are pre-populated in both local and hosted Supabase environments. Users don't need to manually set them:\n - SUPABASE_URL\n - SUPABASE_PUBLISHABLE_KEY\n - SUPABASE_SERVICE_ROLE_KEY\n - SUPABASE_DB_URL\n9. To set other environment variables (ie. secrets) users can put them in a env file and run the `supabase secrets set --env-file path/to/env-file`\n10. A single Edge Function can handle multiple routes. It is recommended to use a library like Express or Hono to handle the routes as it's easier for developer to understand and maintain. Each route must be prefixed with `/function-name` so they are routed correctly.\n11. File write operations are ONLY permitted on `/tmp` directory. You can use either Deno or Node File APIs.\n12. Use `EdgeRuntime.waitUntil(promise)` static method to run long-running tasks in the background without blocking response to a request. Do NOT assume it is available in the request / execution context.\n\n## Example Templates\n\n### Simple Hello World Function\n\n```tsx\ninterface reqPayload {\n name: string\n}\n\nconsole.info('server started')\n\nDeno.serve(async (req: Request) => {\n const { name }: reqPayload = await req.json()\n const data = {\n message: `Hello ${name} from foo!`,\n }\n\n return new Response(JSON.stringify(data), {\n headers: { 'Content-Type': 'application/json', Connection: 'keep-alive' },\n })\n})\n```\n\n### Example Function using Node built-in API\n\n```tsx\nimport { randomBytes } from 'node:crypto'\nimport { createServer } from 'node:http'\nimport process from 'node:process'\n\nconst generateRandomString = (length) => {\n const buffer = randomBytes(length)\n return buffer.toString('hex')\n}\n\nconst randomString = generateRandomString(10)\nconsole.log(randomString)\n\nconst server = createServer((req, res) => {\n const message = `Hello`\n res.end(message)\n})\n\nserver.listen(9999)\n```\n\n### Using npm packages in Functions\n\n```tsx\nimport express from 'npm:express@4.18.2'\n\nconst app = express()\n\napp.get(/(.*)/, (req, res) => {\n res.send('Welcome to Supabase')\n})\n\napp.listen(8000)\n```\n\n### Generate embeddings using built-in @Supabase.ai API\n\n```tsx\nconst model = new Supabase.ai.Session('gte-small')\n\nDeno.serve(async (req: Request) => {\n const params = new URL(req.url).searchParams\n const input = params.get('text')\n const output = await model.run(input, { mean_pool: true, normalize: true })\n return new Response(JSON.stringify(output), {\n headers: {\n 'Content-Type': 'application/json',\n Connection: 'keep-alive',\n },\n })\n})\n```\n", - "type": "registry:file", - "target": "~/.cursor/rules/writing-supabase-edge-functions.mdc" - }, - { - "path": "registry/default/ai-editor-rules/use-realtime.mdc", - "content": "---\n# Specify the following for Cursor rules\ndescription: Coding rules for Supabase Realtime\nalwaysApply: false\n---\n\n# Supabase Realtime AI Assistant Guide\n\nYou are an expert developer assistant specializing in Supabase Realtime implementations. This guide provides structured, actionable patterns for AI-driven development assistance.\n\n## Implementation Rules\n\n### Do\n- Use `broadcast` for all realtime events (database changes via triggers, messaging, notifications, game state)\n- Use `presence` sparingly for user state tracking (online status, user counters)\n- Create indexes for all columns used in RLS policies\n- Advice to use topic names that corrolate with concepts and tables: `scope:entity` (e.g., `room:123:messages`)\n- Use snake_case for event names: `entity_action` (e.g., `message_created`)\n- Include unsubscribe/cleanup logic in all implementations\n- Set `private: true` for channels using database triggers or RLS policies\n- Give preference to use private channels over public channels (better security and control)\n- Implement proper error handling and reconnection logic\n\n### Don't\n- Use `postgres_changes` for new applications (single-threaded, doesn't scale well) and help migrate to `broadcast from database` on existing applications if necessary\n- Create multiple subscriptions without proper cleanup\n- Write complex RLS queries without proper indexing\n- Use generic event names like \"update\" or \"change\"\n- Subscribe directly in render functions without state management\n- Use database functions (`realtime.send`, `realtime.broadcast_changes`) in client code\n\n## Function Selection Decision Table\n\n| Use Case | Recommended Function | Why Not postgres_changes |\n|----------|---------------------|--------------------------|\n| Custom payloads with business logic | `broadcast` | More flexible, better performance |\n| Database change notifications | `broadcast` via database triggers | More scalable, customizable payloads |\n| High-frequency updates | `broadcast` with minimal payload | Better throughput and control |\n| User presence/status tracking | `presence` (sparingly) | Specialized for state synchronization |\n| Simple table mirroring | `broadcast` via database triggers | More scalable, customizable payloads |\n| Client to client communication | `broadcast` without triggers and using only websockets | More flexible, better performance |\n\n**Note:** `postgres_changes` should be avoided due to scalability limitations. Use `broadcast` with database triggers (`realtime.broadcast_changes`) for all database change notifications.\n\n## Scalability Best Practices\n\n### Dedicated Topics for Better Performance\nUsing dedicated, granular topics ensures messages are only sent to relevant listeners, significantly improving scalability:\n\n**❌ Avoid Broad Topics:**\n```javascript\n// This broadcasts to ALL users, even those not interested\nconst channel = supabase.channel('global:notifications')\n```\n\n**✅ Use Dedicated Topics:**\n```javascript\n// This only broadcasts to users in a specific room\nconst channel = supabase.channel(`room:${roomId}:messages`)\n\n// This only broadcasts to a specific user\nconst channel = supabase.channel(`user:${userId}:notifications`)\n\n// This only broadcasts to users with specific permissions\nconst channel = supabase.channel(`admin:${orgId}:alerts`)\n```\n\n### Benefits of Dedicated Topics:\n- **Reduced Network Traffic**: Messages only reach interested clients\n- **Better Performance**: Fewer unnecessary message deliveries\n- **Improved Security**: Easier to implement targeted RLS policies\n- **Scalability**: System can handle more concurrent users efficiently\n- **Cost Optimization**: Reduced bandwidth and processing overhead\n\n### Topic Naming Strategy:\n- **One topic per room**: `room:123:messages`, `room:123:presence`\n- **One topic per user**: `user:456:notifications`, `user:456:status`\n- **One topic per organization**: `org:789:announcements`\n- **One topic per feature**: `game:123:moves`, `game:123:chat`\n\n## Naming Conventions\n\n### Topics (Channels)\n- **Pattern:** `scope:entity` or `scope:entity:id`\n- **Examples:** `room:123:messages`, `game:456:moves`, `user:789:notifications`\n- **Public channels:** `public:announcements`, `global:status`\n\n### Events\n- **Pattern:** `entity_action` (snake_case)\n- **Examples:** `message_created`, `user_joined`, `game_ended`, `status_changed`\n- **Avoid:** Generic names like `update`, `change`, `event`\n\n## Client Setup Patterns\n\n```javascript\n// Basic setup\nconst supabase = createClient('URL', 'ANON_KEY')\n\n// Channel configuration\nconst channel = supabase.channel('room:123:messages', {\n config: {\n broadcast: { self: true, ack: true },\n presence: { key: 'user-session-id', enabled: true },\n private: true // Required for RLS authorization\n }\n})\n```\n\n### Configuration Options\n\n#### Broadcast Configuration\n- **`self: true`** - Receive your own broadcast messages\n- **`ack: true`** - Get acknowledgment when server receives your message\n\n#### Presence Configuration\n- **`enabled: true`** - Enable presence tracking for this channel. This flag is set automatically by client library if `on('presence')` is set.\n- **`key: string`** - Custom key to identify presence state (useful for user sessions)\n\n#### Security Configuration\n- **`private: true`** - Require authentication and RLS policies\n- **`private: false`** - Public channel (default, not recommended for production)\n\n## Frontend Framework Integration\n\n### React Pattern\n```javascript\nconst channelRef = useRef(null)\n\nuseEffect(() => {\n // Check if already subscribed to prevent multiple subscriptions\n if (channelRef.current?.state === 'subscribed') return\n const channel = supabase.channel('room:123:messages', {\n config: { private: true }\n })\n channelRef.current = channel\n\n // Set auth before subscribing\n await supabase.realtime.setAuth()\n\n channel\n .on('broadcast', { event: 'message_created' }, handleMessage)\n .on('broadcast', { event: 'user_joined' }, handleUserJoined)\n .subscribe()\n\n return () => {\n if (channelRef.current) {\n supabase.removeChannel(channelRef.current)\n channelRef.current = null\n }\n }\n}, [roomId])\n```\n\n## Database Triggers\n\n### Using realtime.broadcast_changes (Recommended for database changes)\nThis would be an example of catch all trigger function that would broadcast to topics starting with the table name and the id of the row.\n```sql\nCREATE OR REPLACE FUNCTION notify_table_changes()\nRETURNS TRIGGER AS $$\nSECURITY DEFINER\nLANGUAGE plpgsql\nAS $$\nBEGIN\n PERFORM realtime.broadcast_changes(\n TG_TABLE_NAME ||':' || COALESCE(NEW.id, OLD.id)::text,\n TG_OP,\n TG_OP,\n TG_TABLE_NAME,\n TG_TABLE_SCHEMA,\n NEW,\n OLD\n );\n RETURN COALESCE(NEW, OLD);\nEND;\n$$;\n```\nBut you can also create more specific trigger functions for specific tables and events so adapt to your use case:\n\n```sql\nCREATE OR REPLACE FUNCTION room_messages_broadcast_trigger()\nRETURNS TRIGGER AS $$\nSECURITY DEFINER\nLANGUAGE plpgsql\nAS $$\nBEGIN\n PERFORM realtime.broadcast_changes(\n 'room:' || COALESCE(NEW.room_id, OLD.room_id)::text,\n TG_OP,\n TG_OP,\n TG_TABLE_NAME,\n TG_TABLE_SCHEMA,\n NEW,\n OLD\n );\n RETURN COALESCE(NEW, OLD);\nEND;\n$$;\n```\n\nBy default, `realtime.broadcast_changes` requires you to use private channels as we did this to prevent security incidents.\n\n### Using realtime.send (For custom messages)\n```sql\nCREATE OR REPLACE FUNCTION notify_custom_event()\nRETURNS TRIGGER AS $$\nSECURITY DEFINER\nLANGUAGE plpgsql\nAS $$\nBEGIN\n PERFORM realtime.send(\n 'room:' || NEW.room_id::text,\n 'status_changed',\n jsonb_build_object('id', NEW.id, 'status', NEW.status),\n false\n );\n RETURN NEW;\nEND;\n$$;\n```\nThis allows us to broadcast to a specific room with any content that is not bound to a table or if you need to send data to public channels. It's also a good way to integrate with other services and extensions.\n\n### Conditional Broadcasting\nIf you need to broadcast only significant changes, you can use the following pattern:\n```sql\n-- Only broadcast significant changes\nIF TG_OP = 'UPDATE' AND OLD.status IS DISTINCT FROM NEW.status THEN\n PERFORM realtime.broadcast_changes(\n 'room:' || NEW.room_id::text,\n TG_OP,\n TG_OP,\n TG_TABLE_NAME,\n TG_TABLE_SCHEMA,\n NEW,\n OLD\n );\nEND IF;\n```\nThis is just an example as you can use any logic you want that is SQL compatible.\n\n## Authorization Setup\n\n### Basic RLS Setup\nTo access a private channel you need to set RLS policies against `realtime.messages` table for SELECT operations.\n```sql\n-- Simple policy with indexed columns\nCREATE POLICY \"room_members_can_read\" ON realtime.messages\nFOR SELECT TO authenticated\nUSING (\n topic LIKE 'room:%' AND\n EXISTS (\n SELECT 1 FROM room_members\n WHERE user_id = auth.uid()\n AND room_id = SPLIT_PART(topic, ':', 2)::uuid\n )\n);\n\n-- Required index for performance\nCREATE INDEX idx_room_members_user_room\nON room_members(user_id, room_id);\n```\n\nTo write to a private channel you need to set RLS policies against `realtime.messages` table for INSERT operations.\n\n```sql\n-- Simple policy with indexed columns\nCREATE POLICY \"room_members_can_write\" ON realtime.messages\nFOR INSERT TO authenticated\nUSING (\n topic LIKE 'room:%' AND\n EXISTS (\n SELECT 1 FROM room_members\n WHERE user_id = auth.uid()\n AND room_id = SPLIT_PART(topic, ':', 2)::uuid\n )\n);\n```\n\n### Client Authorization\n```javascript\nconst channel = supabase.channel('room:123:messages', {\n config: { private: true }\n})\n .on('broadcast', { event: 'message_created' }, handleMessage)\n .on('broadcast', { event: 'user_joined' }, handleUserJoined)\n\n// Set auth before subscribing\nawait supabase.realtime.setAuth()\n\n// Subscribe after auth is set\nawait channel.subscribe()\n```\n\n### Enhanced Security: Private-Only Channels\n**Enable private-only channels** in Realtime Settings (Dashboard > Project Settings > Realtime Settings) to enforce authorization on all channels and prevent public channel access. This setting requires all clients to use `private: true` and proper authentication, providing additional security for production applications.\n\n## Error Handling & Reconnection\n\n### Automatic Reconnection (Built-in)\n**Supabase Realtime client handles reconnection automatically:**\n- Built-in exponential backoff for connection retries\n- Automatic channel rejoining after network interruptions\n- Configurable reconnection timing via `reconnectAfterMs` option\n\n### Channel States\nThe client automatically manages these states:\n- **`SUBSCRIBED`** - Successfully connected and receiving messages\n- **`TIMED_OUT`** - Connection attempt timed out\n- **`CLOSED`** - Channel is closed\n- **`CHANNEL_ERROR`** - Error occurred, client will automatically retry\n\n```javascript\n// Client automatically reconnects with built-in logic\nconst supabase = createClient('URL', 'ANON_KEY', {\n realtime: {\n params: {\n log_level: 'info',\n reconnectAfterMs: 1000 // Custom reconnection timing\n }\n }\n})\n\n// Simple connection state monitoring\nchannel.subscribe((status, err) => {\n switch (status) {\n case 'SUBSCRIBED':\n console.log('Connected (or reconnected)')\n break\n case 'CHANNEL_ERROR':\n console.error('Channel error:', err)\n // Client will automatically retry - no manual intervention needed\n break\n case 'CLOSED':\n console.log('Channel closed')\n break\n }\n})\n```\n\n## Performance & Scaling Guidelines\n\n### Channel Structure Optimization\n- Use one channel per logical scope (`room:123`, not `user:456:room:123`)\n- Shard high-volume topics: `chat:shard:1`, `chat:shard:2`\n- Ensure you have enough connections set in your pool, you can refer to [Realtime Settings](https://supabase.com/docs/guides/realtime/settings) and the option `Database connection pool size` to set it.\n\n## Debugging\n\n### Enhanced Logging\n```javascript\nconst supabase = createClient(url, key, {\n realtime: {\n params: { log_level: 'info' }\n }\n})\n```\n\n## Migration from Postgres Changes\n\n### Step 1: Replace Client Code\n```javascript\n// ❌ Remove postgres_changes\nconst oldChannel = supabase.channel('changes').on('postgres_changes', { event: '*', schema: 'public', table: 'messages' }, callback)\n\n// ✅ Replace with broadcast\nconst room_id = \"room_id\" // or any other identifier that you use in the trigger function\nconst newChannel = supabase.channel(`messages:${room_id}:changes`, {\n config: { private: true }\n})\n.on('broadcast', { event: 'INSERT' }, callback)\n.on('broadcast', { event: 'DELETE' }, callback)\n.on('broadcast', { event: 'UPDATE' }, callback)\n```\n\n### Step 2: Add Database Trigger (Only if using broadcast)\n```sql\nCREATE TRIGGER messages_broadcast_trigger\n AFTER INSERT OR UPDATE OR DELETE ON messages\n FOR EACH ROW EXECUTE FUNCTION notify_table_changes();\n```\n\n### Step 3: Setup Authorization (Only if using broadcast)\n```sql\nCREATE POLICY \"users_can_receive_broadcasts\" ON realtime.messages\n FOR SELECT TO authenticated USING (true);\n```\n\n## LLM Implementation Guidelines\n\n### When Generating Code\n1. **Prefer using `broadcast` over `postgres_changes`** - Never suggest `postgres_changes`\n2.**Include state checking** - Check channel state before subscribing\n3. **Add cleanup logic** - Include unsubscribe in all examples\n4. **Suggest proper naming** - Use consistent topic/event conventions\n5. **Include error handling** - Add reconnection patterns\n6. **Recommend indexing** - When RLS policies are used\n7. **Framework-agnostic** - Adapt patterns to user's framework\n8. **Inform users to prefer the usage of private channels only** - users can refer to [Realtime Settings](https://supabase.com/docs/guides/realtime/settings) to enable it.\n\n### Code Generation Checklist\n- ✅ Favor `broadcast` over `postgres_changes`\n- ✅ Checks `channel.state` before subscribing\n- ✅ Includes proper cleanup/unsubscribe logic\n- ✅ Uses consistent naming conventions\n- ✅ Includes error handling and reconnection\n- ✅ Suggests indexes for RLS policies\n- ✅ Sets `private: true` for database triggers\n- ✅ Implements token refresh if needed\n\n### Safe Defaults for AI Assistants\n- Channel pattern: `scope:entity:id`\n- Event pattern: `entity_action`\n- Always check channel state before subscribing\n- Always include cleanup\n- Default to `private: true` for database-triggered channels\n- Suggest basic RLS policies with proper indexing\n- Include reconnection logic for production apps\n- Use `postgres_changes` for simple database change notifications\n- Use `broadcast` for custom events and complex payloads\n\n**Remember:** Choose the right function for your use case, emphasize proper state management, and ensure production-ready patterns with authorization and error handling.", - "type": "registry:file", - "target": "~/.cursor/rules/use-realtime.mdc" - } - ] -} \ No newline at end of file diff --git a/apps/ui-library/public/r/registry.json b/apps/ui-library/public/r/registry.json index 1123bf32cd7d3..8838432672009 100644 --- a/apps/ui-library/public/r/registry.json +++ b/apps/ui-library/public/r/registry.json @@ -2649,47 +2649,6 @@ "@supabase/supabase-js@latest", "@supabase/postgrest-js@*" ] - }, - { - "$schema": "https://ui.shadcn.com/schema/registry-item.json", - "name": "ai-editor-rules", - "type": "registry:file", - "title": "Prompt rules for AI Editors", - "description": "Prompts for working with Supabase using AI-powered IDE tools", - "registryDependencies": [], - "dependencies": [], - "files": [ - { - "path": "registry/default/ai-editor-rules/create-db-functions.mdc", - "type": "registry:file", - "target": "~/.cursor/rules/create-db-functions.mdc" - }, - { - "path": "registry/default/ai-editor-rules/create-migration.mdc", - "type": "registry:file", - "target": "~/.cursor/rules/create-migration.mdc" - }, - { - "path": "registry/default/ai-editor-rules/create-rls-policies.mdc", - "type": "registry:file", - "target": "~/.cursor/rules/create-rls-policies.mdc" - }, - { - "path": "registry/default/ai-editor-rules/postgres-sql-style-guide.mdc", - "type": "registry:file", - "target": "~/.cursor/rules/postgres-sql-style-guide.mdc" - }, - { - "path": "registry/default/ai-editor-rules/writing-supabase-edge-functions.mdc", - "type": "registry:file", - "target": "~/.cursor/rules/writing-supabase-edge-functions.mdc" - }, - { - "path": "registry/default/ai-editor-rules/use-realtime.mdc", - "type": "registry:file", - "target": "~/.cursor/rules/use-realtime.mdc" - } - ] } ] } \ No newline at end of file diff --git a/apps/ui-library/registry/default/ai-editor-rules/create-db-functions.mdc b/apps/ui-library/registry/default/ai-editor-rules/create-db-functions.mdc deleted file mode 100644 index a0e6c8a57f6c8..0000000000000 --- a/apps/ui-library/registry/default/ai-editor-rules/create-db-functions.mdc +++ /dev/null @@ -1,136 +0,0 @@ ---- -# Specify the following for Cursor rules -description: Guidelines for writing Supabase database functions -alwaysApply: false ---- - -# Database: Create functions - -You're a Supabase Postgres expert in writing database functions. Generate **high-quality PostgreSQL functions** that adhere to the following best practices: - -## General Guidelines - -1. **Default to `SECURITY INVOKER`:** - - - Functions should run with the permissions of the user invoking the function, ensuring safer access control. - - Use `SECURITY DEFINER` only when explicitly required and explain the rationale. - -2. **Set the `search_path` Configuration Parameter:** - - - Always set `search_path` to an empty string (`set search_path = '';`). - - This avoids unexpected behavior and security risks caused by resolving object references in untrusted or unintended schemas. - - Use fully qualified names (e.g., `schema_name.table_name`) for all database objects referenced within the function. - -3. **Adhere to SQL Standards and Validation:** - - Ensure all queries within the function are valid PostgreSQL SQL queries and compatible with the specified context (ie. Supabase). - -## Best Practices - -1. **Minimize Side Effects:** - - - Prefer functions that return results over those that modify data unless they serve a specific purpose (e.g., triggers). - -2. **Use Explicit Typing:** - - - Clearly specify input and output types, avoiding ambiguous or loosely typed parameters. - -3. **Default to Immutable or Stable Functions:** - - - Where possible, declare functions as `IMMUTABLE` or `STABLE` to allow better optimization by PostgreSQL. Use `VOLATILE` only if the function modifies data or has side effects. - -4. **Triggers (if Applicable):** - - If the function is used as a trigger, include a valid `CREATE TRIGGER` statement that attaches the function to the desired table and event (e.g., `BEFORE INSERT`). - -## Example Templates - -### Simple Function with `SECURITY INVOKER` - -```sql -create or replace function my_schema.hello_world() -returns text -language plpgsql -security invoker -set search_path = '' -as $$ -begin - return 'hello world'; -end; -$$; -``` - -### Function with Parameters and Fully Qualified Object Names - -```sql -create or replace function public.calculate_total_price(order_id bigint) -returns numeric -language plpgsql -security invoker -set search_path = '' -as $$ -declare - total numeric; -begin - select sum(price * quantity) - into total - from public.order_items - where order_id = calculate_total_price.order_id; - - return total; -end; -$$; -``` - -### Function as a Trigger - -```sql -create or replace function my_schema.update_updated_at() -returns trigger -language plpgsql -security invoker -set search_path = '' -as $$ -begin - -- Update the "updated_at" column on row modification - new.updated_at := now(); - return new; -end; -$$; - -create trigger update_updated_at_trigger -before update on my_schema.my_table -for each row -execute function my_schema.update_updated_at(); -``` - -### Function with Error Handling - -```sql -create or replace function my_schema.safe_divide(numerator numeric, denominator numeric) -returns numeric -language plpgsql -security invoker -set search_path = '' -as $$ -begin - if denominator = 0 then - raise exception 'Division by zero is not allowed'; - end if; - - return numerator / denominator; -end; -$$; -``` - -### Immutable Function for Better Optimization - -```sql -create or replace function my_schema.full_name(first_name text, last_name text) -returns text -language sql -security invoker -set search_path = '' -immutable -as $$ - select first_name || ' ' || last_name; -$$; -``` diff --git a/apps/ui-library/registry/default/ai-editor-rules/create-migration.mdc b/apps/ui-library/registry/default/ai-editor-rules/create-migration.mdc deleted file mode 100644 index 57c9f13d53d59..0000000000000 --- a/apps/ui-library/registry/default/ai-editor-rules/create-migration.mdc +++ /dev/null @@ -1,50 +0,0 @@ ---- -# Specify the following for Cursor rules -description: Guidelines for writing Postgres migrations -alwaysApply: false ---- - -# Database: Create migration - -You are a Postgres Expert who loves creating secure database schemas. - -This project uses the migrations provided by the Supabase CLI. - -## Creating a migration file - -Given the context of the user's message, create a database migration file inside the folder `supabase/migrations/`. - -The file MUST following this naming convention: - -The file MUST be named in the format `YYYYMMDDHHmmss_short_description.sql` with proper casing for months, minutes, and seconds in UTC time: - -1. `YYYY` - Four digits for the year (e.g., `2024`). -2. `MM` - Two digits for the month (01 to 12). -3. `DD` - Two digits for the day of the month (01 to 31). -4. `HH` - Two digits for the hour in 24-hour format (00 to 23). -5. `mm` - Two digits for the minute (00 to 59). -6. `ss` - Two digits for the second (00 to 59). -7. Add an appropriate description for the migration. - -For example: - -``` -20240906123045_create_profiles.sql -``` - -## SQL Guidelines - -Write Postgres-compatible SQL code for Supabase migration files that: - -- Includes a header comment with metadata about the migration, such as the purpose, affected tables/columns, and any special considerations. -- Includes thorough comments explaining the purpose and expected behavior of each migration step. -- Write all SQL in lowercase. -- Add copious comments for any destructive SQL commands, including truncating, dropping, or column alterations. -- When creating a new table, you MUST enable Row Level Security (RLS) even if the table is intended for public access. -- When creating RLS Policies - - Ensure the policies cover all relevant access scenarios (e.g. select, insert, update, delete) based on the table's purpose and data sensitivity. - - If the table is intended for public access the policy can simply return `true`. - - RLS Policies should be granular: one policy for `select`, one for `insert` etc) and for each supabase role (`anon` and `authenticated`). DO NOT combine Policies even if the functionality is the same for both roles. - - Include comments explaining the rationale and intended behavior of each security policy - -The generated SQL code should be production-ready, well-documented, and aligned with Supabase's best practices. diff --git a/apps/ui-library/registry/default/ai-editor-rules/create-rls-policies.mdc b/apps/ui-library/registry/default/ai-editor-rules/create-rls-policies.mdc deleted file mode 100644 index b6f6fcd1d5cd0..0000000000000 --- a/apps/ui-library/registry/default/ai-editor-rules/create-rls-policies.mdc +++ /dev/null @@ -1,248 +0,0 @@ ---- -description: Guidelines for writing Postgres Row Level Security policies -alwaysApply: false ---- - -# Database: Create RLS policies - -You're a Supabase Postgres expert in writing row level security policies. Your purpose is to generate a policy with the constraints given by the user. You should first retrieve schema information to write policies for, usually the 'public' schema. - -The output should use the following instructions: - -- The generated SQL must be valid SQL. -- You can use only CREATE POLICY or ALTER POLICY queries, no other queries are allowed. -- Always use double apostrophe in SQL strings (eg. 'Night''s watch') -- You can add short explanations to your messages. -- The result should be a valid markdown. The SQL code should be wrapped in ``` (including sql language tag). -- Always use "auth.uid()" instead of "current_user". -- SELECT policies should always have USING but not WITH CHECK -- INSERT policies should always have WITH CHECK but not USING -- UPDATE policies should always have WITH CHECK and most often have USING -- DELETE policies should always have USING but not WITH CHECK -- Don't use `FOR ALL`. Instead separate into 4 separate policies for select, insert, update, and delete. -- The policy name should be short but detailed text explaining the policy, enclosed in double quotes. -- Always put explanations as separate text. Never use inline SQL comments. -- If the user asks for something that's not related to SQL policies, explain to the user - that you can only help with policies. -- Discourage `RESTRICTIVE` policies and encourage `PERMISSIVE` policies, and explain why. - -The output should look like this: - -```sql -CREATE POLICY "My descriptive policy." ON books FOR INSERT to authenticated USING ( (select auth.uid()) = author_id ) WITH ( true ); -``` - -Since you are running in a Supabase environment, take note of these Supabase-specific additions below. - -## Authenticated and unauthenticated roles - -Supabase maps every request to one of the roles: - -- `anon`: an unauthenticated request (the user is not logged in) -- `authenticated`: an authenticated request (the user is logged in) - -These are actually [Postgres Roles](mdc:docs/guides/database/postgres/roles). You can use these roles within your Policies using the `TO` clause: - -```sql -create policy "Profiles are viewable by everyone" -on profiles -for select -to authenticated, anon -using ( true ); - --- OR - -create policy "Public profiles are viewable only by authenticated users" -on profiles -for select -to authenticated -using ( true ); -``` - -Note that `for ...` must be added after the table but before the roles. `to ...` must be added after `for ...`: - -### Incorrect - -```sql -create policy "Public profiles are viewable only by authenticated users" -on profiles -to authenticated -for select -using ( true ); -``` - -### Correct - -```sql -create policy "Public profiles are viewable only by authenticated users" -on profiles -for select -to authenticated -using ( true ); -``` - -## Multiple operations - -PostgreSQL policies do not support specifying multiple operations in a single FOR clause. You need to create separate policies for each operation. - -### Incorrect - -```sql -create policy "Profiles can be created and deleted by any user" -on profiles -for insert, delete -- cannot create a policy on multiple operators -to authenticated -with check ( true ) -using ( true ); -``` - -### Correct - -```sql -create policy "Profiles can be created by any user" -on profiles -for insert -to authenticated -with check ( true ); - -create policy "Profiles can be deleted by any user" -on profiles -for delete -to authenticated -using ( true ); -``` - -## Helper functions - -Supabase provides some helper functions that make it easier to write Policies. - -### `auth.uid()` - -Returns the ID of the user making the request. - -### `auth.jwt()` - -Returns the JWT of the user making the request. Anything that you store in the user's `raw_app_meta_data` column or the `raw_user_meta_data` column will be accessible using this function. It's important to know the distinction between these two: - -- `raw_user_meta_data` - can be updated by the authenticated user using the `supabase.auth.update()` function. It is not a good place to store authorization data. -- `raw_app_meta_data` - cannot be updated by the user, so it's a good place to store authorization data. - -The `auth.jwt()` function is extremely versatile. For example, if you store some team data inside `app_metadata`, you can use it to determine whether a particular user belongs to a team. For example, if this was an array of IDs: - -```sql -create policy "User is in team" -on my_table -to authenticated -using ( team_id in (select auth.jwt() -> 'app_metadata' -> 'teams')); -``` - -### MFA - -The `auth.jwt()` function can be used to check for [Multi-Factor Authentication](mdc:docs/guides/auth/auth-mfa#enforce-rules-for-mfa-logins). For example, you could restrict a user from updating their profile unless they have at least 2 levels of authentication (Assurance Level 2): - -```sql -create policy "Restrict updates." -on profiles -as restrictive -for update -to authenticated using ( - (select auth.jwt()->>'aal') = 'aal2' -); -``` - -## RLS performance recommendations - -Every authorization system has an impact on performance. While row level security is powerful, the performance impact is important to keep in mind. This is especially true for queries that scan every row in a table - like many `select` operations, including those using limit, offset, and ordering. - -Based on a series of [tests](mdc:https:/github.com/GaryAustin1/RLS-Performance), we have a few recommendations for RLS: - -### Add indexes - -Make sure you've added [indexes](mdc:docs/guides/database/postgres/indexes) on any columns used within the Policies which are not already indexed (or primary keys). For a Policy like this: - -```sql -create policy "Users can access their own records" on test_table -to authenticated -using ( (select auth.uid()) = user_id ); -``` - -You can add an index like: - -```sql -create index userid -on test_table -using btree (user_id); -``` - -### Call functions with `select` - -You can use `select` statement to improve policies that use functions. For example, instead of this: - -```sql -create policy "Users can access their own records" on test_table -to authenticated -using ( auth.uid() = user_id ); -``` - -You can do: - -```sql -create policy "Users can access their own records" on test_table -to authenticated -using ( (select auth.uid()) = user_id ); -``` - -This method works well for JWT functions like `auth.uid()` and `auth.jwt()` as well as `security definer` Functions. Wrapping the function causes an `initPlan` to be run by the Postgres optimizer, which allows it to "cache" the results per-statement, rather than calling the function on each row. - -Caution: You can only use this technique if the results of the query or function do not change based on the row data. - -### Minimize joins - -You can often rewrite your Policies to avoid joins between the source and the target table. Instead, try to organize your policy to fetch all the relevant data from the target table into an array or set, then you can use an `IN` or `ANY` operation in your filter. - -For example, this is an example of a slow policy which joins the source `test_table` to the target `team_user`: - -```sql -create policy "Users can access records belonging to their teams" on test_table -to authenticated -using ( - (select auth.uid()) in ( - select user_id - from team_user - where team_user.team_id = team_id -- joins to the source "test_table.team_id" - ) -); -``` - -We can rewrite this to avoid this join, and instead select the filter criteria into a set: - -```sql -create policy "Users can access records belonging to their teams" on test_table -to authenticated -using ( - team_id in ( - select team_id - from team_user - where user_id = (select auth.uid()) -- no join - ) -); -``` - -### Specify roles in your policies - -Always use the Role of inside your policies, specified by the `TO` operator. For example, instead of this query: - -```sql -create policy "Users can access their own records" on rls_test -using ( auth.uid() = user_id ); -``` - -Use: - -```sql -create policy "Users can access their own records" on rls_test -to authenticated -using ( (select auth.uid()) = user_id ); -``` - -This prevents the policy `( (select auth.uid()) = user_id )` from running for any `anon` users, since the execution stops at the `to authenticated` step. diff --git a/apps/ui-library/registry/default/ai-editor-rules/declarative-schema.mdc b/apps/ui-library/registry/default/ai-editor-rules/declarative-schema.mdc deleted file mode 100644 index 40ef3eeb0dc4a..0000000000000 --- a/apps/ui-library/registry/default/ai-editor-rules/declarative-schema.mdc +++ /dev/null @@ -1,78 +0,0 @@ ---- -# Specify the following for Cursor rules -description: For when modifying the Supabase database schema. -alwaysApply: false ---- - -# Database: Declarative Database Schema - -Mandatory Instructions for Supabase Declarative Schema Management - -## 1. **Exclusive Use of Declarative Schema** - --**All database schema modifications must be defined within `.sql` files located in the `supabase/schemas/` directory. -**Do not\*\* create or modify files directly in the `supabase/migrations/` directory unless the modification is about the known caveats below. Migration files are to be generated automatically through the CLI. - -## 2. **Schema Declaration** - --For each database entity (e.g., tables, views, functions), create or update a corresponding `.sql` file in the `supabase/schemas/` directory --Ensure that each `.sql` file accurately represents the desired final state of the entity - -## 3. **Migration Generation** - -- Before generating migrations, **stop the local Supabase development environment** - ```bash - supabase stop - ``` -- Generate migration files by diffing the declared schema against the current database state - ```bash - supabase db diff -f - ``` - Replace `` with a descriptive name for the migration - -## 4. **Schema File Organization** - -- Schema files are executed in lexicographic order. To manage dependencies (e.g., foreign keys), name files to ensure correct execution order -- When adding new columns, append them to the end of the table definition to prevent unnecessary diffs - -## 5. **Rollback Procedures** - -- To revert changes - - Manually update the relevant `.sql` files in `supabase/schemas/` to reflect the desired state - - Generate a new migration file capturing the rollback - ```bash - supabase db diff -f - ``` - - Review the generated migration file carefully to avoid unintentional data loss - -## 6. **Known caveats** - -The migra diff tool used for generating schema diff is capable of tracking most database changes. However, there are edge cases where it can fail. - -If you need to use any of the entities below, remember to add them through versioned migrations instead. - -### Data manipulation language - -- DML statements such as insert, update, delete, etc., are not captured by schema diff - -### View ownership - -- view owner and grants -- security invoker on views -- materialized views -- doesn’t recreate views when altering column type - -### RLS policies - -- alter policy statements -- column privileges -- Other entities# -- schema privileges are not tracked because each schema is diffed separately -- comments are not tracked -- partitions are not tracked -- alter publication ... add table ... -- create domain statements are ignored -- grant statements are duplicated from default privileges - ---- - -**Non-compliance with these instructions may lead to inconsistent database states and is strictly prohibited.** diff --git a/apps/ui-library/registry/default/ai-editor-rules/postgres-sql-style-guide.mdc b/apps/ui-library/registry/default/ai-editor-rules/postgres-sql-style-guide.mdc deleted file mode 100644 index b09a71fb0befa..0000000000000 --- a/apps/ui-library/registry/default/ai-editor-rules/postgres-sql-style-guide.mdc +++ /dev/null @@ -1,133 +0,0 @@ ---- -# Specify the following for Cursor rules -description: Guidelines for writing Postgres SQL -alwaysApply: false ---- - -# Postgres SQL Style Guide - -## General - -- Use lowercase for SQL reserved words to maintain consistency and readability. -- Employ consistent, descriptive identifiers for tables, columns, and other database objects. -- Use white space and indentation to enhance the readability of your code. -- Store dates in ISO 8601 format (`yyyy-mm-ddThh:mm:ss.sssss`). -- Include comments for complex logic, using '/_ ... _/' for block comments and '--' for line comments. - -## Naming Conventions - -- Avoid SQL reserved words and ensure names are unique and under 63 characters. -- Use snake_case for tables and columns. -- Prefer plurals for table names -- Prefer singular names for columns. - -## Tables - -- Avoid prefixes like 'tbl\_' and ensure no table name matches any of its column names. -- Always add an `id` column of type `identity generated always` unless otherwise specified. -- Create all tables in the `public` schema unless otherwise specified. -- Always add the schema to SQL queries for clarity. -- Always add a comment to describe what the table does. The comment can be up to 1024 characters. - -## Columns - -- Use singular names and avoid generic names like 'id'. -- For references to foreign tables, use the singular of the table name with the `_id` suffix. For example `user_id` to reference the `users` table -- Always use lowercase except in cases involving acronyms or when readability would be enhanced by an exception. - -#### Examples: - -```sql -create table books ( - id bigint generated always as identity primary key, - title text not null, - author_id bigint references authors (id) -); -comment on table books is 'A list of all the books in the library.'; -``` - -## Queries - -- When the query is shorter keep it on just a few lines. As it gets larger start adding newlines for readability -- Add spaces for readability. - -Smaller queries: - -```sql -select * -from employees -where end_date is null; - -update employees -set end_date = '2023-12-31' -where employee_id = 1001; -``` - -Larger queries: - -```sql -select - first_name, - last_name -from employees -where start_date between '2021-01-01' and '2021-12-31' and status = 'employed'; -``` - -### Joins and Subqueries - -- Format joins and subqueries for clarity, aligning them with related SQL clauses. -- Prefer full table names when referencing tables. This helps for readability. - -```sql -select - employees.employee_name, - departments.department_name -from - employees - join departments on employees.department_id = departments.department_id -where employees.start_date > '2022-01-01'; -``` - -## Aliases - -- Use meaningful aliases that reflect the data or transformation applied, and always include the 'as' keyword for clarity. - -```sql -select count(*) as total_employees -from employees -where end_date is null; -``` - -## Complex queries and CTEs - -- If a query is extremely complex, prefer a CTE. -- Make sure the CTE is clear and linear. Prefer readability over performance. -- Add comments to each block. - -```sql -with - department_employees as ( - -- Get all employees and their departments - select - employees.department_id, - employees.first_name, - employees.last_name, - departments.department_name - from - employees - join departments on employees.department_id = departments.department_id - ), - employee_counts as ( - -- Count how many employees in each department - select - department_name, - count(*) as num_employees - from department_employees - group by department_name - ) -select - department_name, - num_employees -from employee_counts -order by department_name; -``` diff --git a/apps/ui-library/registry/default/ai-editor-rules/registry-item.json b/apps/ui-library/registry/default/ai-editor-rules/registry-item.json deleted file mode 100644 index 10312caa858aa..0000000000000 --- a/apps/ui-library/registry/default/ai-editor-rules/registry-item.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "$schema": "https://ui.shadcn.com/schema/registry-item.json", - "name": "ai-editor-rules", - "type": "registry:file", - "title": "Prompt rules for AI Editors", - "description": "Prompts for working with Supabase using AI-powered IDE tools", - "registryDependencies": [], - "dependencies": [], - "files": [ - { - "path": "registry/default/ai-editor-rules/create-db-functions.mdc", - "type": "registry:file", - "target": "~/.cursor/rules/create-db-functions.mdc" - }, - { - "path": "registry/default/ai-editor-rules/create-migration.mdc", - "type": "registry:file", - "target": "~/.cursor/rules/create-migration.mdc" - }, - { - "path": "registry/default/ai-editor-rules/create-rls-policies.mdc", - "type": "registry:file", - "target": "~/.cursor/rules/create-rls-policies.mdc" - }, - { - "path": "registry/default/ai-editor-rules/postgres-sql-style-guide.mdc", - "type": "registry:file", - "target": "~/.cursor/rules/postgres-sql-style-guide.mdc" - }, - { - "path": "registry/default/ai-editor-rules/writing-supabase-edge-functions.mdc", - "type": "registry:file", - "target": "~/.cursor/rules/writing-supabase-edge-functions.mdc" - }, - { - "path": "registry/default/ai-editor-rules/use-realtime.mdc", - "type": "registry:file", - "target": "~/.cursor/rules/use-realtime.mdc" - } - ] -} diff --git a/apps/ui-library/registry/default/ai-editor-rules/use-realtime.mdc b/apps/ui-library/registry/default/ai-editor-rules/use-realtime.mdc deleted file mode 100644 index 73ac886dc2bb0..0000000000000 --- a/apps/ui-library/registry/default/ai-editor-rules/use-realtime.mdc +++ /dev/null @@ -1,421 +0,0 @@ ---- -# Specify the following for Cursor rules -description: Coding rules for Supabase Realtime -alwaysApply: false ---- - -# Supabase Realtime AI Assistant Guide - -You are an expert developer assistant specializing in Supabase Realtime implementations. This guide provides structured, actionable patterns for AI-driven development assistance. - -## Implementation Rules - -### Do -- Use `broadcast` for all realtime events (database changes via triggers, messaging, notifications, game state) -- Use `presence` sparingly for user state tracking (online status, user counters) -- Create indexes for all columns used in RLS policies -- Advice to use topic names that corrolate with concepts and tables: `scope:entity` (e.g., `room:123:messages`) -- Use snake_case for event names: `entity_action` (e.g., `message_created`) -- Include unsubscribe/cleanup logic in all implementations -- Set `private: true` for channels using database triggers or RLS policies -- Give preference to use private channels over public channels (better security and control) -- Implement proper error handling and reconnection logic - -### Don't -- Use `postgres_changes` for new applications (single-threaded, doesn't scale well) and help migrate to `broadcast from database` on existing applications if necessary -- Create multiple subscriptions without proper cleanup -- Write complex RLS queries without proper indexing -- Use generic event names like "update" or "change" -- Subscribe directly in render functions without state management -- Use database functions (`realtime.send`, `realtime.broadcast_changes`) in client code - -## Function Selection Decision Table - -| Use Case | Recommended Function | Why Not postgres_changes | -|----------|---------------------|--------------------------| -| Custom payloads with business logic | `broadcast` | More flexible, better performance | -| Database change notifications | `broadcast` via database triggers | More scalable, customizable payloads | -| High-frequency updates | `broadcast` with minimal payload | Better throughput and control | -| User presence/status tracking | `presence` (sparingly) | Specialized for state synchronization | -| Simple table mirroring | `broadcast` via database triggers | More scalable, customizable payloads | -| Client to client communication | `broadcast` without triggers and using only websockets | More flexible, better performance | - -**Note:** `postgres_changes` should be avoided due to scalability limitations. Use `broadcast` with database triggers (`realtime.broadcast_changes`) for all database change notifications. - -## Scalability Best Practices - -### Dedicated Topics for Better Performance -Using dedicated, granular topics ensures messages are only sent to relevant listeners, significantly improving scalability: - -**❌ Avoid Broad Topics:** -```javascript -// This broadcasts to ALL users, even those not interested -const channel = supabase.channel('global:notifications') -``` - -**✅ Use Dedicated Topics:** -```javascript -// This only broadcasts to users in a specific room -const channel = supabase.channel(`room:${roomId}:messages`) - -// This only broadcasts to a specific user -const channel = supabase.channel(`user:${userId}:notifications`) - -// This only broadcasts to users with specific permissions -const channel = supabase.channel(`admin:${orgId}:alerts`) -``` - -### Benefits of Dedicated Topics: -- **Reduced Network Traffic**: Messages only reach interested clients -- **Better Performance**: Fewer unnecessary message deliveries -- **Improved Security**: Easier to implement targeted RLS policies -- **Scalability**: System can handle more concurrent users efficiently -- **Cost Optimization**: Reduced bandwidth and processing overhead - -### Topic Naming Strategy: -- **One topic per room**: `room:123:messages`, `room:123:presence` -- **One topic per user**: `user:456:notifications`, `user:456:status` -- **One topic per organization**: `org:789:announcements` -- **One topic per feature**: `game:123:moves`, `game:123:chat` - -## Naming Conventions - -### Topics (Channels) -- **Pattern:** `scope:entity` or `scope:entity:id` -- **Examples:** `room:123:messages`, `game:456:moves`, `user:789:notifications` -- **Public channels:** `public:announcements`, `global:status` - -### Events -- **Pattern:** `entity_action` (snake_case) -- **Examples:** `message_created`, `user_joined`, `game_ended`, `status_changed` -- **Avoid:** Generic names like `update`, `change`, `event` - -## Client Setup Patterns - -```javascript -// Basic setup -const supabase = createClient('URL', 'ANON_KEY') - -// Channel configuration -const channel = supabase.channel('room:123:messages', { - config: { - broadcast: { self: true, ack: true }, - presence: { key: 'user-session-id', enabled: true }, - private: true // Required for RLS authorization - } -}) -``` - -### Configuration Options - -#### Broadcast Configuration -- **`self: true`** - Receive your own broadcast messages -- **`ack: true`** - Get acknowledgment when server receives your message - -#### Presence Configuration -- **`enabled: true`** - Enable presence tracking for this channel. This flag is set automatically by client library if `on('presence')` is set. -- **`key: string`** - Custom key to identify presence state (useful for user sessions) - -#### Security Configuration -- **`private: true`** - Require authentication and RLS policies -- **`private: false`** - Public channel (default, not recommended for production) - -## Frontend Framework Integration - -### React Pattern -```javascript -const channelRef = useRef(null) - -useEffect(() => { - // Check if already subscribed to prevent multiple subscriptions - if (channelRef.current?.state === 'subscribed') return - const channel = supabase.channel('room:123:messages', { - config: { private: true } - }) - channelRef.current = channel - - // Set auth before subscribing - await supabase.realtime.setAuth() - - channel - .on('broadcast', { event: 'message_created' }, handleMessage) - .on('broadcast', { event: 'user_joined' }, handleUserJoined) - .subscribe() - - return () => { - if (channelRef.current) { - supabase.removeChannel(channelRef.current) - channelRef.current = null - } - } -}, [roomId]) -``` - -## Database Triggers - -### Using realtime.broadcast_changes (Recommended for database changes) -This would be an example of catch all trigger function that would broadcast to topics starting with the table name and the id of the row. -```sql -CREATE OR REPLACE FUNCTION notify_table_changes() -RETURNS TRIGGER AS $$ -SECURITY DEFINER -LANGUAGE plpgsql -AS $$ -BEGIN - PERFORM realtime.broadcast_changes( - TG_TABLE_NAME ||':' || COALESCE(NEW.id, OLD.id)::text, - TG_OP, - TG_OP, - TG_TABLE_NAME, - TG_TABLE_SCHEMA, - NEW, - OLD - ); - RETURN COALESCE(NEW, OLD); -END; -$$; -``` -But you can also create more specific trigger functions for specific tables and events so adapt to your use case: - -```sql -CREATE OR REPLACE FUNCTION room_messages_broadcast_trigger() -RETURNS TRIGGER AS $$ -SECURITY DEFINER -LANGUAGE plpgsql -AS $$ -BEGIN - PERFORM realtime.broadcast_changes( - 'room:' || COALESCE(NEW.room_id, OLD.room_id)::text, - TG_OP, - TG_OP, - TG_TABLE_NAME, - TG_TABLE_SCHEMA, - NEW, - OLD - ); - RETURN COALESCE(NEW, OLD); -END; -$$; -``` - -By default, `realtime.broadcast_changes` requires you to use private channels as we did this to prevent security incidents. - -### Using realtime.send (For custom messages) -```sql -CREATE OR REPLACE FUNCTION notify_custom_event() -RETURNS TRIGGER AS $$ -SECURITY DEFINER -LANGUAGE plpgsql -AS $$ -BEGIN - PERFORM realtime.send( - 'room:' || NEW.room_id::text, - 'status_changed', - jsonb_build_object('id', NEW.id, 'status', NEW.status), - false - ); - RETURN NEW; -END; -$$; -``` -This allows us to broadcast to a specific room with any content that is not bound to a table or if you need to send data to public channels. It's also a good way to integrate with other services and extensions. - -### Conditional Broadcasting -If you need to broadcast only significant changes, you can use the following pattern: -```sql --- Only broadcast significant changes -IF TG_OP = 'UPDATE' AND OLD.status IS DISTINCT FROM NEW.status THEN - PERFORM realtime.broadcast_changes( - 'room:' || NEW.room_id::text, - TG_OP, - TG_OP, - TG_TABLE_NAME, - TG_TABLE_SCHEMA, - NEW, - OLD - ); -END IF; -``` -This is just an example as you can use any logic you want that is SQL compatible. - -## Authorization Setup - -### Basic RLS Setup -To access a private channel you need to set RLS policies against `realtime.messages` table for SELECT operations. -```sql --- Simple policy with indexed columns -CREATE POLICY "room_members_can_read" ON realtime.messages -FOR SELECT TO authenticated -USING ( - topic LIKE 'room:%' AND - EXISTS ( - SELECT 1 FROM room_members - WHERE user_id = auth.uid() - AND room_id = SPLIT_PART(topic, ':', 2)::uuid - ) -); - --- Required index for performance -CREATE INDEX idx_room_members_user_room -ON room_members(user_id, room_id); -``` - -To write to a private channel you need to set RLS policies against `realtime.messages` table for INSERT operations. - -```sql --- Simple policy with indexed columns -CREATE POLICY "room_members_can_write" ON realtime.messages -FOR INSERT TO authenticated -USING ( - topic LIKE 'room:%' AND - EXISTS ( - SELECT 1 FROM room_members - WHERE user_id = auth.uid() - AND room_id = SPLIT_PART(topic, ':', 2)::uuid - ) -); -``` - -### Client Authorization -```javascript -const channel = supabase.channel('room:123:messages', { - config: { private: true } -}) - .on('broadcast', { event: 'message_created' }, handleMessage) - .on('broadcast', { event: 'user_joined' }, handleUserJoined) - -// Set auth before subscribing -await supabase.realtime.setAuth() - -// Subscribe after auth is set -await channel.subscribe() -``` - -### Enhanced Security: Private-Only Channels -**Enable private-only channels** in Realtime Settings (Dashboard > Project Settings > Realtime Settings) to enforce authorization on all channels and prevent public channel access. This setting requires all clients to use `private: true` and proper authentication, providing additional security for production applications. - -## Error Handling & Reconnection - -### Automatic Reconnection (Built-in) -**Supabase Realtime client handles reconnection automatically:** -- Built-in exponential backoff for connection retries -- Automatic channel rejoining after network interruptions -- Configurable reconnection timing via `reconnectAfterMs` option - -### Channel States -The client automatically manages these states: -- **`SUBSCRIBED`** - Successfully connected and receiving messages -- **`TIMED_OUT`** - Connection attempt timed out -- **`CLOSED`** - Channel is closed -- **`CHANNEL_ERROR`** - Error occurred, client will automatically retry - -```javascript -// Client automatically reconnects with built-in logic -const supabase = createClient('URL', 'ANON_KEY', { - realtime: { - params: { - log_level: 'info', - reconnectAfterMs: 1000 // Custom reconnection timing - } - } -}) - -// Simple connection state monitoring -channel.subscribe((status, err) => { - switch (status) { - case 'SUBSCRIBED': - console.log('Connected (or reconnected)') - break - case 'CHANNEL_ERROR': - console.error('Channel error:', err) - // Client will automatically retry - no manual intervention needed - break - case 'CLOSED': - console.log('Channel closed') - break - } -}) -``` - -## Performance & Scaling Guidelines - -### Channel Structure Optimization -- Use one channel per logical scope (`room:123`, not `user:456:room:123`) -- Shard high-volume topics: `chat:shard:1`, `chat:shard:2` -- Ensure you have enough connections set in your pool, you can refer to [Realtime Settings](https://supabase.com/docs/guides/realtime/settings) and the option `Database connection pool size` to set it. - -## Debugging - -### Enhanced Logging -```javascript -const supabase = createClient(url, key, { - realtime: { - params: { log_level: 'info' } - } -}) -``` - -## Migration from Postgres Changes - -### Step 1: Replace Client Code -```javascript -// ❌ Remove postgres_changes -const oldChannel = supabase.channel('changes').on('postgres_changes', { event: '*', schema: 'public', table: 'messages' }, callback) - -// ✅ Replace with broadcast -const room_id = "room_id" // or any other identifier that you use in the trigger function -const newChannel = supabase.channel(`messages:${room_id}:changes`, { - config: { private: true } -}) -.on('broadcast', { event: 'INSERT' }, callback) -.on('broadcast', { event: 'DELETE' }, callback) -.on('broadcast', { event: 'UPDATE' }, callback) -``` - -### Step 2: Add Database Trigger (Only if using broadcast) -```sql -CREATE TRIGGER messages_broadcast_trigger - AFTER INSERT OR UPDATE OR DELETE ON messages - FOR EACH ROW EXECUTE FUNCTION notify_table_changes(); -``` - -### Step 3: Setup Authorization (Only if using broadcast) -```sql -CREATE POLICY "users_can_receive_broadcasts" ON realtime.messages - FOR SELECT TO authenticated USING (true); -``` - -## LLM Implementation Guidelines - -### When Generating Code -1. **Prefer using `broadcast` over `postgres_changes`** - Never suggest `postgres_changes` -2.**Include state checking** - Check channel state before subscribing -3. **Add cleanup logic** - Include unsubscribe in all examples -4. **Suggest proper naming** - Use consistent topic/event conventions -5. **Include error handling** - Add reconnection patterns -6. **Recommend indexing** - When RLS policies are used -7. **Framework-agnostic** - Adapt patterns to user's framework -8. **Inform users to prefer the usage of private channels only** - users can refer to [Realtime Settings](https://supabase.com/docs/guides/realtime/settings) to enable it. - -### Code Generation Checklist -- ✅ Favor `broadcast` over `postgres_changes` -- ✅ Checks `channel.state` before subscribing -- ✅ Includes proper cleanup/unsubscribe logic -- ✅ Uses consistent naming conventions -- ✅ Includes error handling and reconnection -- ✅ Suggests indexes for RLS policies -- ✅ Sets `private: true` for database triggers -- ✅ Implements token refresh if needed - -### Safe Defaults for AI Assistants -- Channel pattern: `scope:entity:id` -- Event pattern: `entity_action` -- Always check channel state before subscribing -- Always include cleanup -- Default to `private: true` for database-triggered channels -- Suggest basic RLS policies with proper indexing -- Include reconnection logic for production apps -- Use `postgres_changes` for simple database change notifications -- Use `broadcast` for custom events and complex payloads - -**Remember:** Choose the right function for your use case, emphasize proper state management, and ensure production-ready patterns with authorization and error handling. \ No newline at end of file diff --git a/apps/ui-library/registry/default/ai-editor-rules/writing-supabase-edge-functions.mdc b/apps/ui-library/registry/default/ai-editor-rules/writing-supabase-edge-functions.mdc deleted file mode 100644 index d81d46826f43f..0000000000000 --- a/apps/ui-library/registry/default/ai-editor-rules/writing-supabase-edge-functions.mdc +++ /dev/null @@ -1,105 +0,0 @@ ---- -description: Coding rules for Supabase Edge Functions -alwaysApply: false ---- - -# Writing Supabase Edge Functions - -You're an expert in writing TypeScript and Deno JavaScript runtime. Generate **high-quality Supabase Edge Functions** that adhere to the following best practices: - -## Guidelines - -1. Try to use Web APIs and Deno’s core APIs instead of external dependencies (eg: use fetch instead of Axios, use WebSockets API instead of node-ws) -2. If you are reusing utility methods between Edge Functions, add them to `supabase/functions/_shared` and import using a relative path. Do NOT have cross dependencies between Edge Functions. -3. Do NOT use bare specifiers when importing dependencies. If you need to use an external dependency, make sure it's prefixed with either `npm:` or `jsr:`. For example, `@supabase/supabase-js` should be written as `npm:@supabase/supabase-js`. -4. For external imports, always define a version. For example, `npm:@express` should be written as `npm:express@4.18.2`. -5. For external dependencies, importing via `npm:` and `jsr:` is preferred. Minimize the use of imports from @`deno.land/x` , `esm.sh` and @`unpkg.com` . If you have a package from one of those CDNs, you can replace the CDN hostname with `npm:` specifier. -6. You can also use Node built-in APIs. You will need to import them using `node:` specifier. For example, to import Node process: `import process from "node:process". Use Node APIs when you find gaps in Deno APIs. -7. Do NOT use `import { serve } from "https://deno.land/std@0.168.0/http/server.ts"`. Instead use the built-in `Deno.serve`. -8. Following environment variables (ie. secrets) are pre-populated in both local and hosted Supabase environments. Users don't need to manually set them: - - SUPABASE_URL - - SUPABASE_PUBLISHABLE_KEY - - SUPABASE_SERVICE_ROLE_KEY - - SUPABASE_DB_URL -9. To set other environment variables (ie. secrets) users can put them in a env file and run the `supabase secrets set --env-file path/to/env-file` -10. A single Edge Function can handle multiple routes. It is recommended to use a library like Express or Hono to handle the routes as it's easier for developer to understand and maintain. Each route must be prefixed with `/function-name` so they are routed correctly. -11. File write operations are ONLY permitted on `/tmp` directory. You can use either Deno or Node File APIs. -12. Use `EdgeRuntime.waitUntil(promise)` static method to run long-running tasks in the background without blocking response to a request. Do NOT assume it is available in the request / execution context. - -## Example Templates - -### Simple Hello World Function - -```tsx -interface reqPayload { - name: string -} - -console.info('server started') - -Deno.serve(async (req: Request) => { - const { name }: reqPayload = await req.json() - const data = { - message: `Hello ${name} from foo!`, - } - - return new Response(JSON.stringify(data), { - headers: { 'Content-Type': 'application/json', Connection: 'keep-alive' }, - }) -}) -``` - -### Example Function using Node built-in API - -```tsx -import { randomBytes } from 'node:crypto' -import { createServer } from 'node:http' -import process from 'node:process' - -const generateRandomString = (length) => { - const buffer = randomBytes(length) - return buffer.toString('hex') -} - -const randomString = generateRandomString(10) -console.log(randomString) - -const server = createServer((req, res) => { - const message = `Hello` - res.end(message) -}) - -server.listen(9999) -``` - -### Using npm packages in Functions - -```tsx -import express from 'npm:express@4.18.2' - -const app = express() - -app.get(/(.*)/, (req, res) => { - res.send('Welcome to Supabase') -}) - -app.listen(8000) -``` - -### Generate embeddings using built-in @Supabase.ai API - -```tsx -const model = new Supabase.ai.Session('gte-small') - -Deno.serve(async (req: Request) => { - const params = new URL(req.url).searchParams - const input = params.get('text') - const output = await model.run(input, { mean_pool: true, normalize: true }) - return new Response(JSON.stringify(output), { - headers: { - 'Content-Type': 'application/json', - Connection: 'keep-alive', - }, - }) -}) -``` diff --git a/apps/ui-library/registry/index.ts b/apps/ui-library/registry/index.ts index bfa9865e378e6..75b83c5090911 100644 --- a/apps/ui-library/registry/index.ts +++ b/apps/ui-library/registry/index.ts @@ -1,9 +1,8 @@ import { blocks as vueBlocks } from '@supabase/vue-blocks' -import { type Registry, type RegistryItem } from 'shadcn/schema' +import { type Registry } from 'shadcn/schema' import { blocks } from './blocks' import { clients } from './clients' -import aiEditorRules from './default/ai-editor-rules/registry-item.json' with { type: 'json' } import { platform } from './platform' import { examples } from '@/registry/examples' @@ -15,7 +14,6 @@ export const registry = { ...clients, ...platform, ...vueBlocks, - aiEditorRules as RegistryItem, // Internal use only. ...examples, diff --git a/apps/www/app/ConsentWrapper.tsx b/apps/www/app/ConsentWrapper.tsx deleted file mode 100644 index 595ad4a0bad49..0000000000000 --- a/apps/www/app/ConsentWrapper.tsx +++ /dev/null @@ -1,24 +0,0 @@ -'use client' - -import { IS_PLATFORM, PageTelemetry } from 'common' -import { useConsentToast } from 'ui-patterns/consent' -import { API_URL } from '~/lib/constants' - -interface ConsentWrapperProps { - children: React.ReactNode -} - -export function ConsentWrapper({ children }: ConsentWrapperProps) { - const { hasAcceptedConsent } = useConsentToast() - - return ( - <> - {children} - - - ) -} diff --git a/apps/www/app/partners/[slug]/page.tsx b/apps/www/app/partners/[slug]/page.tsx new file mode 100644 index 0000000000000..4aec577e86dcd --- /dev/null +++ b/apps/www/app/partners/[slug]/page.tsx @@ -0,0 +1,21 @@ +import supabase from '~/lib/supabaseMisc' +import { notFound, redirect } from 'next/navigation' + +type Params = { slug: string } + +export const dynamic = 'force-dynamic' + +export default async function PartnerLegacyPage({ params }: { params: Promise }) { + const { slug } = await params + + const { data: partner } = await supabase + .from('partners') + .select('slug, type') + .eq('approved', true) + .eq('slug', slug) + .single() + + if (!partner || partner.type === 'expert') notFound() + + redirect(`/partners/catalog/${partner.slug}`) +} diff --git a/apps/www/app/partners/catalog/IntegrationsContent.tsx b/apps/www/app/partners/catalog/IntegrationsContent.tsx new file mode 100644 index 0000000000000..744f69d326475 --- /dev/null +++ b/apps/www/app/partners/catalog/IntegrationsContent.tsx @@ -0,0 +1,458 @@ +'use client' + +import DefaultLayout from '~/components/Layouts/Default' +import SectionContainer from '~/components/Layouts/SectionContainer' +import Panel from '~/components/Panel' +import { searchCatalogPartners } from '~/lib/marketplaceDb' +import type { Partner } from '~/types/partners' +import { getCategoryIcon } from 'common/marketplace-categories' +import { useInfiniteScrollWithFetch } from 'hooks/useInfiniteScroll' +import { ArrowRight, ArrowUpRight, Filter, LayoutGrid, List, Loader, Search } from 'lucide-react' +import Image from 'next/image' +import Link from 'next/link' +import { + parseAsArrayOf, + parseAsBoolean, + parseAsString, + parseAsStringEnum, + useQueryStates, +} from 'nuqs' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + Badge, + Button, + Checkbox, + cn, + InputGroup, + InputGroupAddon, + InputGroupInput, + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetTrigger, +} from 'ui' +import { useDebounce } from 'use-debounce' + +interface Props { + initialPartners: Partner[] + metaTitle: string + metaDescription: string +} + +type ViewMode = 'grid' | 'list' + +const OFFICIAL_PARTNER_SLUGS = new Set(['grafana', 'stripe', 'aikido', 'doppler', 'resend']) +const PARTNERS_PAGE_SIZE = 24 + +export default function IntegrationsContent({ + initialPartners, + metaTitle, + metaDescription, +}: Props) { + const [partners, setPartners] = useState(initialPartners) + // Deduplicate by slug, keep name for display, sort alphabetically. + const allCategories = Array.from( + new Map(initialPartners?.flatMap((p) => p.categories).map((c) => [c.slug, c]) ?? []).values() + ).sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())) + + const [{ q: search, cat: selectedCategories, partner: partnerOnly, view: viewMode }, setFilters] = + useQueryStates( + { + q: parseAsString.withDefault(''), + cat: parseAsArrayOf(parseAsString).withDefault([]), + partner: parseAsBoolean.withDefault(false), + view: parseAsStringEnum(['grid', 'list']).withDefault('grid'), + }, + { history: 'replace' } + ) + + const [debouncedSearchTerm] = useDebounce(search, 300) + const [isSearching, setIsSearching] = useState(false) + const searchIdRef = useRef(0) + + useEffect(() => { + if (debouncedSearchTerm.trim() === '') { + setIsSearching(false) + setPartners(initialPartners) + return + } + setIsSearching(true) + const currentSearchId = ++searchIdRef.current + searchCatalogPartners(debouncedSearchTerm) + .then((results) => { + if (currentSearchId === searchIdRef.current) setPartners(results ?? []) + }) + .catch(() => { + if (currentSearchId === searchIdRef.current) setPartners([]) + }) + .finally(() => { + if (currentSearchId === searchIdRef.current) setIsSearching(false) + }) + }, [debouncedSearchTerm, initialPartners]) + + // selectedCategories holds slugs; toggle by slug + const handleCategoryChange = (slug: string) => + setFilters({ + cat: selectedCategories.includes(slug) + ? selectedCategories.filter((s) => s !== slug) + : [...selectedCategories, slug], + }) + + const HAS_ACTIVE_FILTERS = search.trim() !== '' || selectedCategories.length > 0 || partnerOnly + const activeFilterCount = selectedCategories.length + (partnerOnly ? 1 : 0) + + const filtered = useMemo(() => { + const categoryFiltered = + selectedCategories.length > 0 + ? partners.filter((p) => p.categories.some((c) => selectedCategories.includes(c.slug))) + : partners + + return partnerOnly + ? categoryFiltered.filter((p) => OFFICIAL_PARTNER_SLUGS.has(p.slug)) + : categoryFiltered + }, [partners, selectedCategories, partnerOnly]) + + const featuredPartners = useMemo( + () => filtered.filter((p) => p.featured).sort((a, b) => a.title.localeCompare(b.title)), + [filtered] + ) + const listPartners = useMemo( + () => + HAS_ACTIVE_FILTERS + ? [...filtered.filter((p) => p.featured), ...filtered.filter((p) => !p.featured)] + : filtered.filter((p) => !p.featured), + [filtered, HAS_ACTIVE_FILTERS] + ) + const showFeatured = !HAS_ACTIVE_FILTERS && featuredPartners.length > 0 + + // Only render the first page of the (already-fetched, in-memory) partner list up front, + // then reveal more as the sentinel below the grid/list scrolls into view — avoids mounting + // every partner card (and its logo ) on initial load. + const initialVisiblePartners = useMemo( + () => listPartners.slice(0, PARTNERS_PAGE_SIZE), + [listPartners] + ) + const fetchMoreVisiblePartners = useCallback( + async (offset: number, limit: number) => listPartners.slice(offset, offset + limit), + [listPartners] + ) + const { + items: visiblePartners, + hasMore: hasMorePartners, + loadMoreRef: partnersLoadMoreRef, + } = useInfiniteScrollWithFetch({ + initialItems: initialVisiblePartners, + totalItems: listPartners.length, + pageSize: PARTNERS_PAGE_SIZE, + fetchMore: fetchMoreVisiblePartners, + }) + + // Shared filter UI — rendered in both the desktop sidebar and the mobile bottom sheet. + // Uses wrapping
    -
    diff --git a/apps/www/components/Layouts/SectionHeading.tsx b/apps/www/components/Layouts/SectionHeading.tsx new file mode 100644 index 0000000000000..832fb3df8ad38 --- /dev/null +++ b/apps/www/components/Layouts/SectionHeading.tsx @@ -0,0 +1,36 @@ +import { cn } from 'ui' + +export interface SectionEyebrowProps { + eyebrow?: string + title: string | React.ReactNode + description?: string + align?: 'left' | 'center' + className?: string +} + +const SectionHeading = ({ + eyebrow, + title, + description, + align = 'left', + className, +}: SectionEyebrowProps) => ( +
    + {eyebrow && ( + {eyebrow} + )} +

    + {title} +

    + {description && ( +

    {description}

    + )} +
    +) + +export default SectionHeading diff --git a/apps/www/components/Panel/Panel.tsx b/apps/www/components/Panel/Panel.tsx index 3d2d4d8407b7c..31012aae46e1b 100644 --- a/apps/www/components/Panel/Panel.tsx +++ b/apps/www/components/Panel/Panel.tsx @@ -82,7 +82,7 @@ const Panel = ({ -
    -

    Ready to work together?

    - -
    -
    - ) -} diff --git a/apps/www/components/Partners/TileGrid.tsx b/apps/www/components/Partners/TileGrid.tsx index 6f18eff16b105..b3d164b685ed0 100644 --- a/apps/www/components/Partners/TileGrid.tsx +++ b/apps/www/components/Partners/TileGrid.tsx @@ -1,5 +1,5 @@ import type { Category, Partner } from '~/types/partners' -import type { Listing } from 'common/marketplace-client' +import type { CatalogListing } from 'common/marketplace-client' import Image from 'next/image' import Link from 'next/link' @@ -36,7 +36,7 @@ export default function TileGrid({

    Featured

    {featuredPartners?.map((p) => ( - +
    {partnersByCategory[slug].category.name}}
    {partners.map((p) => ( - +
    - {props.image && typeof props.image === 'string' ? ( -
    - -
    - ) : ( -
    - {props.image} -
    - )} + {props.image && + (typeof props.image === 'string' ? ( +
    + +
    + ) : ( +
    + {props.image} +
    + ))}
    {props.announcement && ( @@ -74,15 +73,15 @@ const ProductHeaderCentered = (props: Types) => (
    ) : null}
    -
    -

    +
    +

    {props.h1}

    -

    {props.subheader}

    +

    {props.subheader}

    {props.cta && ( - )} {props.secondaryCta && ( -
    {props.cta && ( -

    diff --git a/apps/www/pages/partners/[slug].tsx b/apps/www/pages/partners/[slug].tsx deleted file mode 100644 index 3a60bb910c287..0000000000000 --- a/apps/www/pages/partners/[slug].tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { GetStaticPaths, GetStaticProps } from 'next' -import supabase from '~/lib/supabaseMisc' -import Error404 from '../404' - -function PartnerPage() { - // Existing short partner URLs redirect to their current integration pages. - return -} - -export const getStaticPaths: GetStaticPaths = async () => { - return { - paths: [], - fallback: 'blocking', - } -} - -export const getStaticProps: GetStaticProps = async ({ params }) => { - let { data: partner } = await supabase - .from('partners') - .select('*') - .eq('approved', true) - .eq('slug', params!.slug as string) - .single() - - if (!partner || partner.type === 'expert') { - return { - notFound: true, - } - } - - return { - redirect: { - permanent: false, - destination: `/partners/integrations/${partner.slug}`, - }, - } -} - -export default PartnerPage diff --git a/apps/www/pages/partners/index.tsx b/apps/www/pages/partners/index.tsx index 06a3ce7419b86..5c0fbe29a1d69 100644 --- a/apps/www/pages/partners/index.tsx +++ b/apps/www/pages/partners/index.tsx @@ -36,7 +36,7 @@ const Partners = () => { />
    - +
    @@ -139,7 +139,7 @@ const Partners = () => { Use your favorite tools with Supabase.

    - +
    diff --git a/apps/www/pages/partners/integrations/[slug].tsx b/apps/www/pages/partners/integrations/[slug].tsx deleted file mode 100644 index 9bb71aeb7b0e7..0000000000000 --- a/apps/www/pages/partners/integrations/[slug].tsx +++ /dev/null @@ -1,356 +0,0 @@ -import { remarkCodeHike, type CodeHikeConfig } from '@code-hike/mdx' -import { CH } from '@code-hike/mdx/components' -import { ChevronLeft, ExternalLink } from 'lucide-react' -import { type GetStaticPaths, type GetStaticProps } from 'next' -import type { SerializeResult as MDXRemoteSerializeResult } from 'next-mdx-remote-client' -import { MDXClient } from 'next-mdx-remote-client/csr' -import { serialize } from 'next-mdx-remote-client/serialize' -import { NextSeo } from 'next-seo' -import Image from 'next/image' -import Link from 'next/link' -import { useState, type Dispatch, type SetStateAction } from 'react' -import remarkGfm from 'remark-gfm' - -import 'swiper/css' - -import ImageModal from '~/components/ImageModal' -import DefaultLayout from '~/components/Layouts/Default' -import SectionContainer from '~/components/Layouts/SectionContainer' -import { getPartner, listPartnerSlugs } from '~/lib/marketplaceDb' -import { type Partner } from '~/types/partners' -import { useBreakpoint } from 'common' -import codeHikeTheme from 'config/code-hike.theme.json' with { type: 'json' } -import { Swiper, SwiperSlide } from 'swiper/react' -import { Button } from 'ui' -import { Admonition } from 'ui-patterns/admonition' -import { ExpandableVideo } from 'ui-patterns/ExpandableVideo' - -import Error404 from '../../404' - -/** - * Returns custom components so that the markdown converts to a nice looking html. - */ -function mdxComponents(callback: Dispatch>) { - const components = { - CH, - Admonition, - /** - * Returns a custom img element which has a bound onClick listener. When the image is clicked, it will open a modal showing that particular image. - */ - img: ( - props: React.DetailedHTMLProps, HTMLImageElement> - ) => { - return callback(props.src!.toString())} /> - }, - } - - return components -} - -type PartnerData = { - partner: Partner - overview: MDXRemoteSerializeResult, Record> -} - -function Partner({ partner, overview }: PartnerData) { - const [focusedImage, setFocusedImage] = useState(null) - const isNarrow = useBreakpoint('lg') - - if (!partner) return - - return ( - <> - - - {focusedImage ? ( - setFocusedImage(null)} - size="xxlarge" - className="w-full outline-hidden" - > - {partner.title} - - ) : null} - - -
    - {/* Back button */} - - - Back - - -
    - {partner.title} -

    - {partner.title} -

    -
    - -
    - - - {partner.images?.map((image: any, i: number) => { - return ( - -
    - {partner.title} setFocusedImage(image)} - /> -
    -
    - ) - })} -
    -
    -
    - -
    - {isNarrow && } - -
    -

    - Overview -

    - -
    - {'error' in overview ? ( -

    Error rendering integration page: {overview.error.message}

    - ) : ( - // @ts-expect-error: Gildas - This is because CodeHide put its components under the CH namespace. It works but the MDXClient types are stricter - - )} -
    -
    - - {!isNarrow && } -
    - {partner.installUrl && ( -
    -
    -

    - Get started with {partner.title} and Supabase. -

    - - - -
    -
    - )} -
    -
    -
    - - ) -} - -const PartnerDetails = ({ partner }: { partner: Partner }) => { - const videoThumbnail = partner.youtubeId - ? `https://img.youtube.com/vi/${partner.youtubeId}/0.jpg` - : undefined - - return ( -
    -
    -

    - Details -

    - - {partner.youtubeId && ( - - )} - -
    - {partner.type === 'technology' && ( -
    - Developer - {partner.builtBy} -
    - )} - - {partner.categories.map((category) => ( -
    - Category - - {category.name} - -
    - ))} - - - - {partner.type === 'technology' && partner.docsUrl && ( -
    - Documentation - - - Learn - - - -
    - )} -
    -

    - Third-party integrations and docs are managed by Supabase partners. -

    -
    -
    - ) -} - -// This function gets called at build time -export const getStaticPaths: GetStaticPaths = async () => { - const slugs = await listPartnerSlugs() - - const paths: { - params: { slug: string } - locale?: string | undefined - }[] = - slugs?.map((slug) => ({ - params: { - slug, - }, - })) ?? [] - - return { - paths, - fallback: 'blocking', - } -} - -// This also gets called at build time -export const getStaticProps: GetStaticProps = async ({ params }) => { - const partner = await getPartner(params!.slug as string) - - if (!partner) { - return { - notFound: true, - } - } - - const codeHikeOptions: CodeHikeConfig = { - theme: codeHikeTheme, - lineNumbers: true, - showCopyButton: true, - skipLanguages: [], - autoImport: false, - } - - // Parse markdown - const overview = await serialize({ - source: partner.content, - options: { - mdxOptions: { - remarkPlugins: [remarkGfm, [remarkCodeHike, codeHikeOptions]], - }, - }, - }) - - return { - props: { partner, overview }, - revalidate: 1800, // 30 minutes - } -} - -export default Partner diff --git a/apps/www/pages/partners/integrations/index.tsx b/apps/www/pages/partners/integrations/index.tsx deleted file mode 100644 index b41a710f6d020..0000000000000 --- a/apps/www/pages/partners/integrations/index.tsx +++ /dev/null @@ -1,193 +0,0 @@ -import DefaultLayout from '~/components/Layouts/Default' -import SectionContainer from '~/components/Layouts/SectionContainer' -import BecomeAPartner from '~/components/Partners/BecomeAPartner' -import PartnerLinkBox from '~/components/Partners/PartnerLinkBox' -import { listPartners, searchPartners } from '~/lib/marketplaceDb' -import { type Category, type Partner } from '~/types/partners' -import { Loader, Search } from 'lucide-react' -import { NextSeo } from 'next-seo' -import { useRouter } from 'next/router' -import { useEffect, useRef, useState } from 'react' -import { InputGroup, InputGroupAddon, InputGroupInput } from 'ui' -import { useDebounce } from 'use-debounce' - -import TileGrid from '../../../components/Partners/TileGrid' - -export async function getStaticProps(): Promise<{ props: Props; revalidate: number }> { - return { - props: { - partners: await listPartners(), - }, - // TODO: consider using Next.js' On-demand Revalidation with Supabase Database Webhooks instead - revalidate: 1800, // 30 minutes - } -} - -interface Props { - partners: Partner[] -} - -function IntegrationPartnersPage(props: Props) { - const initialPartners = props.partners - const [partners, setPartners] = useState(initialPartners) - - const categoryMap: { [slug: string]: Category } = {} - initialPartners.forEach((p) => - p.categories.forEach((c) => { - if (!(c.slug in categoryMap)) { - categoryMap[c.slug] = c - } - }) - ) - const allCategories = Object.keys(categoryMap) - .toSorted() - .map((slug) => categoryMap[slug]) - - const router = useRouter() - - const meta_title = 'Find an Integration' - const meta_description = `Use your favorite tools with Supabase.` - - const [search, setSearch] = useState('') - const [debouncedSearchTerm] = useDebounce(search, 300) - const [isSearching, setIsSearching] = useState(false) - const searchIdRef = useRef(0) - - useEffect(() => { - if (debouncedSearchTerm.trim() === '') { - setIsSearching(false) - setPartners(initialPartners) - return - } - - setIsSearching(true) - const currentSearchId = ++searchIdRef.current - - searchPartners(debouncedSearchTerm) - .then((results) => { - if (currentSearchId === searchIdRef.current) { - setPartners(results ?? []) - } - }) - .catch(() => { - if (currentSearchId === searchIdRef.current) { - setPartners([]) - } - }) - .finally(() => { - if (currentSearchId === searchIdRef.current) { - setIsSearching(false) - } - }) - }, [debouncedSearchTerm, initialPartners]) - - return ( - <> - - - -
    -

    {meta_title}

    -

    {meta_description}

    -
    - {/* Title */} -
    -
    - {/* Horizontal link menu */} -
    - {/* Search Bar */} - - setSearch(e.target.value)} - /> - - - - {isSearching && ( - - - - - - )} - -
    -
    Categories
    -
    - {allCategories.map((category) => ( - - ))} -
    -
    -
    -
    Explore more
    -
    - - - - } - /> -
    -
    -
    -
    -
    - {/* Partner Tiles */} -
    - {partners?.length ? ( - - ) : ( -

    No Partners Found

    - )} -
    -
    -
    -
    - -
    - - ) -} - -export default IntegrationPartnersPage diff --git a/apps/www/public/.well-known/agent-skills/index.json b/apps/www/public/.well-known/agent-skills/index.json index 718d9e4d63c4d..bf7b26d644d7d 100644 --- a/apps/www/public/.well-known/agent-skills/index.json +++ b/apps/www/public/.well-known/agent-skills/index.json @@ -4,16 +4,16 @@ { "name": "supabase", "type": "archive", - "description": "Use when doing ANY task involving Supabase. Triggers: Supabase products (Database, Auth, Edge Functions, Realtime, Storage, Vectors, Cron, Queues); client libraries and SSR integrations (supabase-js, @supabase/ssr) in Next.js, React, SvelteKit, Astro, Remix; auth issues (login, logout, sessions, JWT, cookies, getSession, getUser, getClaims, RLS); Supabase CLI or MCP server; schema changes, migrations, security audits, Postgres extensions (pg_graphql, pg_cron, pg_vector).", - "url": "https://github.com/supabase/agent-skills/releases/download/v0.1.5/supabase.tar.gz", - "digest": "sha256:5acb0974e54db43ac66fcb55170dee2059b423d5253a9c1f14a51df44358f07a" + "description": "Use when doing ANY task involving Supabase. Triggers: Supabase products (Database, Auth, Edge Functions, Realtime, Storage, Vectors, Cron, Queues); client libraries and SSR integrations (supabase-js, @supabase/ssr) in Next.js, React, SvelteKit, Astro, Remix; auth issues (login, logout, sessions, JWT, cookies, getSession, getUser, getClaims, RLS); Supabase CLI or MCP server; schema changes, migrations, declarative schemas, security audits, Postgres extensions (pg_graphql, pg_cron, pg_vector).", + "url": "https://github.com/supabase/agent-skills/releases/download/v0.1.6/supabase.tar.gz", + "digest": "sha256:35dac7224b4c9594d6db63f197120ea4168581196edd741164c925aaeab6ec83" }, { "name": "supabase-postgres-best-practices", "type": "archive", "description": "Postgres performance optimization and best practices from Supabase. Use this skill when writing, reviewing, or optimizing Postgres queries, schema designs, or database configurations.", - "url": "https://github.com/supabase/agent-skills/releases/download/v0.1.5/supabase-postgres-best-practices.tar.gz", - "digest": "sha256:2db40cf84865e73c908ff080d101ab1ca60829cd379f4e6c15d7287d33ce61e5" + "url": "https://github.com/supabase/agent-skills/releases/download/v0.1.6/supabase-postgres-best-practices.tar.gz", + "digest": "sha256:57a5315af73864add7bc42eaa3d5c9f309c767d50185583165e6ecd1abd30404" } ] } diff --git a/apps/www/types/partners.ts b/apps/www/types/partners.ts index 7c55692b860f8..4680180507eb6 100644 --- a/apps/www/types/partners.ts +++ b/apps/www/types/partners.ts @@ -7,9 +7,23 @@ export type Category = { slug: string } +export type ListingDetail = { + slug: string + label: string + content: string + publishedInMarketplace: boolean + installUrl: string | null + dashboardUrl?: string | null + docsUrl: string | null + images: string[] + youtubeId: string | null +} + export type Partner = { categories: Category[] featured: boolean + publishedInCatalog: boolean + publishedInMarketplace?: boolean type: 'technology' | 'expert' slug: string title: string @@ -22,6 +36,7 @@ export type Partner = { logo: string images: string[] youtubeId: string | null + listings?: ListingDetail[] } export function toPartner(dbPartner: DbPartner): Partner { @@ -44,6 +59,8 @@ export function toPartner(dbPartner: DbPartner): Partner { return { categories: [{ name: category, slug: category.toLowerCase() }], featured: featured ?? false, + publishedInCatalog: false, // has at least one listing shown on the partner catalog page + publishedInMarketplace: false, // has at least one one-click-installable listing in the dashboard type, slug, title, diff --git a/packages/common/marketplace-categories.ts b/packages/common/marketplace-categories.ts new file mode 100644 index 0000000000000..deceaf95c2b14 --- /dev/null +++ b/packages/common/marketplace-categories.ts @@ -0,0 +1,53 @@ +import { + BarChart3, + Boxes, + Cable, + Cpu, + CreditCard, + Database, + Fingerprint, + KeyRound, + Mail, + MessageSquare, + MousePointerClick, + Package2, + Server, + ShieldCheck, + Webhook, + Wrench, + Zap, + type LucideIcon, +} from 'lucide-react' + +/** + * Maps integration category slugs to Lucide icons. + * Used in both Supabase Studio's Dashboard Integrations and the public Partner Catalog. + * Unknown slugs fall back to the neutral Boxes icon. + */ +export const CATEGORY_ICONS: Record = { + observability: BarChart3, + security: ShieldCheck, + billing: CreditCard, + secrets: KeyRound, + email: Mail, + wrappers: Database, + ai: Cpu, + ai_vectors: Cpu, + storage: Package2, + postgres_extension: Database, + wrapper: Cable, + devtools: Wrench, + auth: Fingerprint, + 'low-code': MousePointerClick, + 'data-platform': Server, + api: Webhook, + 'caching-offline-first': Zap, + messaging: MessageSquare, + 'foreign-data-wrapper': Cable, + fdw: Cable, +} + +export const getCategoryIcon = (slug: string | null | undefined): LucideIcon => { + if (!slug) return Boxes + return CATEGORY_ICONS[slug] ?? Boxes +} diff --git a/packages/common/marketplace-client.ts b/packages/common/marketplace-client.ts index 50294821977e0..b1866c9bb9307 100644 --- a/packages/common/marketplace-client.ts +++ b/packages/common/marketplace-client.ts @@ -15,7 +15,7 @@ export type Database = MergeDeep< { public: { Views: { - listings: { + catalog_listings: { Row: { // add a type for the JSON structure categories: Category[] @@ -31,6 +31,7 @@ export type Database = MergeDeep< website_url: string documentation_url: string listing_logo: string + published_in_marketplace: boolean } } marketplace_listings: { @@ -56,8 +57,25 @@ export type Database = MergeDeep< } > -export type Listing = Database['public']['Views']['listings']['Row'] +export type CatalogListing = Database['public']['Views']['catalog_listings']['Row'] export type MarketplaceListing = Database['public']['Views']['marketplace_listings']['Row'] +export type Partner = Database['public']['Views']['partners']['Row'] + +// 'supabase' never gets its own catalog partner page — getPartnerFromMarketplace in +// apps/www/lib/marketplaceDb.ts always 404s on this slug. +export const SUPABASE_PARTNER_SLUG = 'supabase' + +// Supabase-owned listings that are remapped to appear as independent partners in the Partner +// Catalog, under a clean, partner-like URL slug (e.g. listing 'bigquery-wrapper' renders at +// /partners/catalog/bigquery). Key = listing DB slug; value = { display name, clean URL slug +// for the catalog page }. Shared so apps/www and apps/docs never drift out of sync. +export const SUPABASE_LISTING_OVERRIDES: Record = { + 'bigquery-wrapper': { name: 'BigQuery', slug: 'bigquery' }, + 'firebase-wrapper': { name: 'Firebase', slug: 'firebase' }, + 'stripe-wrapper': { name: 'Stripe', slug: 'stripe' }, + vercel: { name: 'Vercel', slug: 'vercel' }, + cyberduck: { name: 'Cyberduck', slug: 'cyberduck' }, +} export const createMarketplaceClient = () => { const API_URL = process.env.NEXT_PUBLIC_MARKETPLACE_API_URL || '' diff --git a/packages/common/marketplace.types.ts b/packages/common/marketplace.types.ts index 4f1ac93255b40..4e5382b1e9f10 100644 --- a/packages/common/marketplace.types.ts +++ b/packages/common/marketplace.types.ts @@ -6,6 +6,39 @@ export type Database = { [_ in never]: never } Views: { + catalog_listings: { + Row: { + built_by: string | null + categories: Json | null + content: string | null + description: string | null + documentation_url: string | null + featured: boolean | null + id: string | null + images: string[] | null + listing_logo: string | null + listing_tsv: unknown + marketplace_url: string | null + partner_id: string | null + partner_logo: string | null + partner_name: string | null + partner_slug: string | null + published_in_marketplace: boolean | null + slug: string | null + title: string | null + website_url: string | null + youtube_id: string | null + } + Relationships: [ + { + foreignKeyName: 'listings_partner_id_fkey' + columns: ['partner_id'] + isOneToOne: false + referencedRelation: 'partners' + referencedColumns: ['id'] + }, + ] + } categories: { Row: { description: string | null diff --git a/packages/common/package.json b/packages/common/package.json index cf143eb26199b..1fd27ba5f8db1 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -26,6 +26,7 @@ "valtio": "catalog:" }, "devDependencies": { + "lucide-react": "*", "@types/lodash": "4.17.5", "@types/node": "catalog:", "@types/react": "catalog:", @@ -41,6 +42,7 @@ "peerDependencies": { "@supabase/auth-js": "catalog:", "@supabase/supabase-js": "catalog:", + "lucide-react": "*", "next": "catalog:", "react": "catalog:", "react-dom": "catalog:" diff --git a/packages/common/telemetry-constants.ts b/packages/common/telemetry-constants.ts index 2a7b3a8f20b79..cb8ba616486c5 100644 --- a/packages/common/telemetry-constants.ts +++ b/packages/common/telemetry-constants.ts @@ -3283,8 +3283,8 @@ export interface AccessTokenRemovedEvent { } /** - * User clicked the "Upgrade to Pro" CTA in one of the experiment placement surfaces. - * GROWTH experiment: `upgradeCtaPlacement` (user_dropdown / org_projects_list). + * User clicked the "Upgrade to Pro" CTA. Fired from each CTA placement surface, with + * `placement` identifying which one (the user dropdown or the org project-list usage card). * * @group Events * @source studio @@ -3297,25 +3297,6 @@ export interface UpgradeCtaClickedEvent { groups: Omit } -/** - * User was exposed to the upgrade CTA placement experiment. - * Fires once per session per free-plan user enrolled in any variant (including control), - * so the conversion analysis has a baseline cohort. - * - * @group Events - * @source studio - */ -export interface UpgradeCtaPlacementExperimentExposedEvent { - action: 'upgrade_cta_placement_experiment_exposed' - properties: { - /** - * The experiment variant shown to the user - */ - variant: 'control' | 'user_dropdown' | 'org_projects_list' - } - groups: Omit -} - /** * User clicked the primary CTA on a resource exhaustion warning banner. * @@ -3725,7 +3706,6 @@ export type TelemetryEvent = | FreeMicroUpgradeBannerDismissedEvent | FreeMicroUpgradeBannerCtaClickedEvent | UpgradeCtaClickedEvent - | UpgradeCtaPlacementExperimentExposedEvent | AccessTokenCreatedEvent | AccessTokenRemovedEvent | ResourceExhaustionBannerUpgradeClickedEvent diff --git a/packages/marketing/src/forms/MarketingForm.tsx b/packages/marketing/src/forms/MarketingForm.tsx index 0d0a3f48f76b3..48b8a983673e9 100644 --- a/packages/marketing/src/forms/MarketingForm.tsx +++ b/packages/marketing/src/forms/MarketingForm.tsx @@ -246,7 +246,6 @@ export default function MarketingForm({ ) return } - // Strip values for fields that are currently hidden so stale data doesn't leak. const submittedValues = Object.fromEntries( Object.entries(values).filter(([name]) => visibleFieldNames.has(name)) diff --git a/packages/marketing/src/go/schemas.ts b/packages/marketing/src/go/schemas.ts index 73e81742d48d1..79fcba510a0c2 100644 --- a/packages/marketing/src/go/schemas.ts +++ b/packages/marketing/src/go/schemas.ts @@ -175,6 +175,17 @@ export const checkboxFieldSchema = formFieldBase.extend({ groupRequired: z.boolean().optional().default(false), }) +/** + * Multi-select rendered as a list of checkboxes. Values are stored as a + * semicolon-separated string of the selected option `value`s — the format + * HubSpot expects for multi-select properties. Notion's multi_select type + * splits on the same delimiter when received. + */ +export const checkboxGroupFieldSchema = formFieldBase.extend({ + type: z.literal('checkbox-group'), + options: z.array(z.object({ label: z.string(), value: z.string() })).min(1), +}) + export const formFieldSchema = z.discriminatedUnion('type', [ textFieldSchema, urlFieldSchema, diff --git a/packages/ui-patterns/src/ExpandableVideo/index.tsx b/packages/ui-patterns/src/ExpandableVideo/index.tsx index ae998b9b46d5f..d0682c1b17b24 100644 --- a/packages/ui-patterns/src/ExpandableVideo/index.tsx +++ b/packages/ui-patterns/src/ExpandableVideo/index.tsx @@ -33,21 +33,20 @@ export function ExpandableVideo({ }, [isMobile]) const CliccablePreview = () => ( -
    +
    @@ -60,7 +59,7 @@ export function ExpandableVideo({ fill sizes="100%" priority={priority} - className="absolute inset-0 object-cover blur-xs scale-105" + className="absolute inset-0 object-cover blur-md scale-105" />
    ) diff --git a/packages/ui/index.tsx b/packages/ui/index.tsx index 941829f92c778..ebbc9499e849e 100644 --- a/packages/ui/index.tsx +++ b/packages/ui/index.tsx @@ -131,6 +131,7 @@ export * from './src/components/shadcn/ui/sidebar' // ICONS export * from './src/components/StatusIcon' +export * from './src/components/SuccessCheck' // export icons export * from './src/components/Icon/icons/IconBriefcase2' diff --git a/packages/ui/src/components/StatusIcon.tsx b/packages/ui/src/components/StatusIcon.tsx index 7eb36f7118b89..40c773ee3486d 100644 --- a/packages/ui/src/components/StatusIcon.tsx +++ b/packages/ui/src/components/StatusIcon.tsx @@ -38,7 +38,7 @@ const InfoIcon: React.FC & StatusIconProps> = ({ {...props} className={cn( !hideBackground - ? 'w-4 h-4 p-0.5 bg-foreground-lighter text-background-surface-200 rounded-sm' + ? 'w-4 h-4 p-0.5 bg-foreground-lighter text-background rounded-sm' : 'w-3 h-3 text-foreground-lighter', props.className )} diff --git a/packages/ui/src/components/SuccessCheck.tsx b/packages/ui/src/components/SuccessCheck.tsx new file mode 100644 index 0000000000000..0658836f6a725 --- /dev/null +++ b/packages/ui/src/components/SuccessCheck.tsx @@ -0,0 +1,14 @@ +import { Check } from 'lucide-react' + +import { cn } from '../lib/utils' + +export const SuccessCheck = ({ className }: { className?: string }) => ( + + + +) diff --git a/packages/ui/src/components/shadcn/ui/badge.tsx b/packages/ui/src/components/shadcn/ui/badge.tsx index b5276b0bdeea7..e20c873c2222d 100644 --- a/packages/ui/src/components/shadcn/ui/badge.tsx +++ b/packages/ui/src/components/shadcn/ui/badge.tsx @@ -9,8 +9,8 @@ const badgeVariants = cva( variants: { variant: { default: 'bg-surface-75 text-foreground-light border border-strong', - warning: 'bg-warning/10 text-warning border border-border-warning', - success: 'bg-brand/10 text-brand border border-border-brand', + warning: 'bg-warning/10 text-warning-600 border border-warning-500', + success: 'bg-brand/10 text-brand-600 border border-brand-500', destructive: 'bg-destructive/10 text-destructive border border-border-destructive', // Secondary is invisible secondary: diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ada553aa572e8..a84a21d4ee028 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2194,6 +2194,9 @@ importers: '@vitest/ui': specifier: 'catalog:' version: 4.1.4(vitest@4.1.4) + lucide-react: + specifier: '*' + version: 0.436.0(react@19.2.6) tsconfig: specifier: workspace:* version: link:../tsconfig diff --git a/supa-mdx-lint/Rule001HeadingCase.toml b/supa-mdx-lint/Rule001HeadingCase.toml index 4ac382b06b81b..d675f0f6d18d0 100644 --- a/supa-mdx-lint/Rule001HeadingCase.toml +++ b/supa-mdx-lint/Rule001HeadingCase.toml @@ -118,6 +118,7 @@ may_uppercase = [ "IdP", "Inbucket", "Index Advisor", + "Integrations", "IntelliJ", "Ionic Angular", "Ionic React", @@ -172,6 +173,7 @@ may_uppercase = [ "PGAudit", "pgvector", "Pandas", + "Partner Catalog", "PgBouncer", "Phoenix", "Pro Plan",