diff --git a/apps/docs/content/guides/database/joins-and-nesting.mdx b/apps/docs/content/guides/database/joins-and-nesting.mdx index 92f2a4a960f13..361180a3a6ceb 100644 --- a/apps/docs/content/guides/database/joins-and-nesting.mdx +++ b/apps/docs/content/guides/database/joins-and-nesting.mdx @@ -211,6 +211,460 @@ GET https://[REF].supabase.co/rest/v1/orchestral_sections?select=id,name,instrum +## Join types and join modifiers + +By default, embedded relations use **left join** semantics from the parent table: + +- Parent rows are returned even if no related rows match. +- The embedded relation is `[]` for one-to-many joins and `null` for many-to-one joins when nothing matches. + +To filter out parent rows that do not match the related table, use `!inner` on the embedded relation. + +### What `:` and `!` mean in join syntax + +| Syntax | Meaning | Example | +| ------------------------------- | --------------------------------------------------------------------------------------- | --------------------------------------- | +| `alias:relation(columns)` | Rename the embedded relation in the response. | `start_scan:scans(id, badge_scan_time)` | +| `relation!inner(columns)` | Use `inner join` behavior for that embedded relation. | `instruments!inner(id, name)` | +| `relation!foreign_key(columns)` | Choose which foreign key relationship to use when multiple foreign keys match the join. | `scans!scan_id_start(id)` | + +### Example data for join types + + + + +#### Orchestral sections + +| `id` | `name` | +| ---- | ---------- | +| 1 | strings | +| 2 | woodwinds | +| 3 | percussion | + +#### Instruments + +| `id` | `name` | `section_id` | +| ---- | ------ | ------------ | +| 1 | violin | 1 | +| 2 | viola | 1 | +| 3 | flute | 2 | +| 4 | oboe | 2 | + + + + +```sql +create table orchestral_sections ( + "id" serial primary key, + "name" text +); + +insert into orchestral_sections + (id, name) +values + (1, 'strings'), + (2, 'woodwinds'), + (3, 'percussion'); + +create table instruments ( + "id" serial primary key, + "name" text, + "section_id" int references orchestral_sections +); + +insert into instruments + (id, name, section_id) +values + (1, 'violin', 1), + (2, 'viola', 1), + (3, 'flute', 2), + (4, 'oboe', 2); +``` + + + + +### Left join (default) + +This query filters on a joined field (`instruments.name`) but still returns all parent rows: + + + + +```js +const { data, error } = await supabase + .from('orchestral_sections') + .select( + ` + id, + name, + instruments ( id, name ) + ` + ) + .eq('instruments.name', 'flute') +``` + + +<$Show if="sdk:dart"> + + +```dart +final data = await supabase + .from('orchestral_sections') + .select(''' + id, + name, + instruments ( id, name ) + ''') + .eq('instruments.name', 'flute'); +``` + + + +<$Show if="sdk:swift"> + + +```swift +try await supabase + .from("orchestral_sections") + .select( + """ + id, + name, + instruments ( id, name ) + """ + ) + .eq("instruments.name", value: "flute") + .execute() +``` + + + +<$Show if="sdk:kotlin"> + + +```kotlin +val columns = Columns.raw(""" + id, + name, + instruments ( id, name ) +""".trimIndent()) + +val data = supabase.from("orchestral_sections").select( + columns = columns +) { + filter { + eq("instruments.name", "flute") + } +} +``` + + + +<$Show if="sdk:python"> + + +```python +data = ( + supabase.from_('orchestral_sections') + .select('id, name, instruments(id, name)') + .eq('instruments.name', 'flute') + .execute() +) +``` + + + + + +```bash +GET https://[REF].supabase.co/rest/v1/orchestral_sections?select=id,name,instruments(id,name)&instruments.name=eq.flute +``` + + + + +#### Result + +```json +[ + { + "id": 1, + "name": "strings", + "instruments": [] + }, + { + "id": 2, + "name": "woodwinds", + "instruments": [{ "id": 3, "name": "flute" }] + }, + { + "id": 3, + "name": "percussion", + "instruments": [] + } +] +``` + +### Inner join (`!inner`) + +Adding `!inner` filters out parent rows that don't match the joined filter: + + + + +```js +const { data, error } = await supabase + .from('orchestral_sections') + .select( + ` + id, + name, + instruments!inner ( id, name ) + ` + ) + .eq('instruments.name', 'flute') +``` + + +<$Show if="sdk:dart"> + + +```dart +final data = await supabase + .from('orchestral_sections') + .select(''' + id, + name, + instruments!inner ( id, name ) + ''') + .eq('instruments.name', 'flute'); +``` + + + +<$Show if="sdk:swift"> + + +```swift +try await supabase + .from("orchestral_sections") + .select( + """ + id, + name, + instruments!inner ( id, name ) + """ + ) + .eq("instruments.name", value: "flute") + .execute() +``` + + + +<$Show if="sdk:kotlin"> + + +```kotlin +val columns = Columns.raw(""" + id, + name, + instruments!inner ( id, name ) +""".trimIndent()) + +val data = supabase.from("orchestral_sections").select( + columns = columns +) { + filter { + eq("instruments.name", "flute") + } +} +``` + + + +<$Show if="sdk:python"> + + +```python +data = ( + supabase.from_('orchestral_sections') + .select('id, name, instruments!inner(id, name)') + .eq('instruments.name', 'flute') + .execute() +) +``` + + + + + +```bash +GET https://[REF].supabase.co/rest/v1/orchestral_sections?select=id,name,instruments!inner(id,name)&instruments.name=eq.flute +``` + + + + +#### Result + +```json +[ + { + "id": 2, + "name": "woodwinds", + "instruments": [{ "id": 3, "name": "flute" }] + } +] +``` + +### Filtering using joined fields + +Use `joined_table.column` in filters (for example `eq`, `neq`, and `in`): + + + + +```js +const { data, error } = await supabase + .from('instruments') + .select( + ` + id, + name, + orchestral_sections!inner ( id, name ) + ` + ) + .eq('orchestral_sections.name', 'woodwinds') +``` + + +<$Show if="sdk:dart"> + + +```dart +final data = await supabase + .from('instruments') + .select(''' + id, + name, + orchestral_sections!inner ( id, name ) + ''') + .eq('orchestral_sections.name', 'woodwinds'); +``` + + + +<$Show if="sdk:swift"> + + +```swift +try await supabase + .from("instruments") + .select( + """ + id, + name, + orchestral_sections!inner ( id, name ) + """ + ) + .eq("orchestral_sections.name", value: "woodwinds") + .execute() +``` + + + +<$Show if="sdk:kotlin"> + + +```kotlin +val columns = Columns.raw(""" + id, + name, + orchestral_sections!inner ( id, name ) +""".trimIndent()) + +val data = supabase.from("instruments").select( + columns = columns +) { + filter { + eq("orchestral_sections.name", "woodwinds") + } +} +``` + + + +<$Show if="sdk:python"> + + +```python +data = ( + supabase.from_('instruments') + .select('id, name, orchestral_sections!inner(id, name)') + .eq('orchestral_sections.name', 'woodwinds') + .execute() +) +``` + + + + + +```bash +GET https://[REF].supabase.co/rest/v1/instruments?select=id,name,orchestral_sections!inner(id,name)&orchestral_sections.name=eq.woodwinds +``` + + + + +#### Result + +```json +[ + { + "id": 3, + "name": "flute", + "orchestral_sections": { + "id": 2, + "name": "woodwinds" + } + }, + { + "id": 4, + "name": "oboe", + "orchestral_sections": { + "id": 2, + "name": "woodwinds" + } + } +] +``` + ## Many-to-many joins The data APIs will detect many-to-many joins. For example, if you have a database which stored teams of users (where each user could belong to many teams): diff --git a/apps/docs/content/guides/platform/read-replicas/getting-started.mdx b/apps/docs/content/guides/platform/read-replicas/getting-started.mdx index 42dac54514855..33e0193ff8443 100644 --- a/apps/docs/content/guides/platform/read-replicas/getting-started.mdx +++ b/apps/docs/content/guides/platform/read-replicas/getting-started.mdx @@ -17,15 +17,15 @@ Projects must meet these requirements to use Read Replicas: 1. Running on AWS. 2. Running on at least a [Small compute add-on](/docs/guides/platform/compute-add-ons). - - Read Replicas are started on the same compute instance as the Primary to keep up with changes. + - Read Replicas are started on the same compute instance as the Primary to keep up with changes. 3. Running on Postgres 15+. - - For projects running on older versions of Postgres, you need to [upgrade to the latest platform version](/docs/guides/platform/migrating-and-upgrading-projects#pgupgrade). + - For projects running on older versions of Postgres, you need to [upgrade to the latest platform version](/docs/guides/platform/migrating-and-upgrading-projects#pgupgrade). 4. Not using [legacy logical backups](/docs/guides/platform/backups#point-in-time-recovery) - - Physical backups are automatically enabled if using [Point in time recovery (PITR)](/docs/guides/platform/backups#point-in-time-recovery) + - Physical backups are automatically enabled if using [Point in time recovery (PITR)](/docs/guides/platform/backups#point-in-time-recovery) ## Creating a Read Replica diff --git a/apps/docs/content/guides/self-hosting/copy-from-platform-s3.mdx b/apps/docs/content/guides/self-hosting/copy-from-platform-s3.mdx index 2c98be8144f87..1665b9aadc626 100644 --- a/apps/docs/content/guides/self-hosting/copy-from-platform-s3.mdx +++ b/apps/docs/content/guides/self-hosting/copy-from-platform-s3.mdx @@ -20,7 +20,7 @@ You need: - A working self-hosted Supabase instance with the S3 protocol endpoint enabled - see [Configure S3 Storage](/docs/guides/self-hosting/self-hosted-s3#enable-the-s3-protocol-endpoint) - Your platform project's S3 credentials - generated from the [S3 Configuration](/dashboard/project/_/storage/s3) page - Matching buckets created on your self-hosted instance -{/* supa-mdx-lint-disable-next-line Rule003Spelling */} + {/* supa-mdx-lint-disable-next-line Rule003Spelling */} - [rclone](https://rclone.org/install/) installed on the machine running the copy ## Step 1: Get platform S3 credentials @@ -50,22 +50,24 @@ If you already restored your platform database to self-hosted using the [restore To list your platform buckets, connect to your platform database and run: ```sql -SELECT id, name, public FROM storage.buckets ORDER BY name; +select id, name, public from storage.buckets order by name; ``` Then create matching buckets on your self-hosted instance. Connect to your self-hosted database and run: ```sql -INSERT INTO storage.buckets (id, name, public) -VALUES +insert into storage.buckets (id, name, public) +values ('your-storage-bucket', 'your-storage-bucket', false) -ON CONFLICT (id) DO NOTHING; +on conflict (id) do nothing; ``` Repeat for each bucket, setting `public` to `true` or `false` as appropriate. {/* supa-mdx-lint-disable-next-line Rule003Spelling */} + ## Step 3: Configure rclone + {/* supa-mdx-lint-disable-next-line Rule003Spelling */} Create or edit your rclone configuration file (`~/.config/rclone/rclone.conf`): @@ -139,14 +141,16 @@ Open Studio on your self-hosted instance and browse the storage buckets to confi If you see `SignatureDoesNotMatch` when connecting to either remote: - **Platform**: Regenerate S3 access keys from your project's Storage Settings. Ensure the endpoint URL includes `/storage/v1/s3`. -{/* supa-mdx-lint-disable-next-line Rule003Spelling */} + {/* supa-mdx-lint-disable-next-line Rule003Spelling */} - **Self-hosted**: Verify that `REGION`, `S3_PROTOCOL_ACCESS_KEY_ID` and `S3_PROTOCOL_ACCESS_KEY_SECRET` in `.env` file match your rclone config. ### Bucket not found + {/* supa-mdx-lint-disable-next-line Rule003Spelling */} If rclone reports that a bucket doesn't exist on the self-hosted side, create it first - see [Step 2](#step-2-create-buckets-on-self-hosted). The S3 protocol does not auto-create buckets on copy. ### Timeouts on large files + {/* supa-mdx-lint-disable-next-line Rule003Spelling */} For very large files, increase rclone's timeout: diff --git a/apps/docs/content/guides/self-hosting/self-hosted-s3.mdx b/apps/docs/content/guides/self-hosting/self-hosted-s3.mdx index 37ada206d7480..248746f8670ca 100644 --- a/apps/docs/content/guides/self-hosting/self-hosted-s3.mdx +++ b/apps/docs/content/guides/self-hosting/self-hosted-s3.mdx @@ -6,8 +6,9 @@ subtitle: 'Enable S3-compatible client endpoint and set up an S3 backend for sel Self-hosted Supabase Storage has two independent S3-related features: {/* supa-mdx-lint-disable-next-line Rule003Spelling */} + - **S3 protocol endpoint** - an S3-compatible API that Storage exposes at `/storage/v1/s3`. This allows standard S3 tools like `rclone` and the AWS CLI to interact with your Storage instance. -{/* supa-mdx-lint-disable-next-line Rule003Spelling */} + {/* supa-mdx-lint-disable-next-line Rule003Spelling */} - **S3 backend** - where Storage keeps data. By default, files are stored on the local filesystem. You can switch to an S3-compatible service (AWS S3, MinIO, etc.) for durability, scalability, or to use existing infrastructure. You can configure either feature independently. For example, you can enable the S3 protocol endpoint to use `rclone` while keeping the default file-based storage, or switch to an S3 backend without enabling the S3 protocol endpoint. @@ -42,7 +43,9 @@ s3://your-storage-bucket ) ``` {/* supa-mdx-lint-disable-next-line Rule003Spelling */} + ### Test with rclone + ```bash ( set -a && \ source .env > /dev/null 2>&1 && \ @@ -70,7 +73,7 @@ storage: GLOBAL_S3_BUCKET: your-s3-bucket-or-dirname GLOBAL_S3_ENDPOINT: https://your-s3-endpoint GLOBAL_S3_PROTOCOL: https - GLOBAL_S3_FORCE_PATH_STYLE: "true" + GLOBAL_S3_FORCE_PATH_STYLE: 'true' AWS_ACCESS_KEY_ID: your-access-key-id AWS_SECRET_ACCESS_KEY: your-secret-access-key REGION: your-region @@ -81,7 +84,9 @@ Depending on your setup, you may need to adjust these values - for example, to u {/* supa-mdx-lint-disable-next-line Rule001HeadingCase */} {/* supa-mdx-lint-disable-next-line Rule003Spelling */} + ### Using MinIO + {/* supa-mdx-lint-disable-next-line Rule003Spelling */} An overlay `docker-compose.s3.yml` configuration can be added to enable MinIO container and provide an S3-compatible API for Storage backend: @@ -109,6 +114,7 @@ storage: For AWS S3, you do not need `GLOBAL_S3_ENDPOINT` or `GLOBAL_S3_FORCE_PATH_STYLE` - the Storage S3 client automatically resolves the endpoint from the region and uses virtual-hosted-style URLs, which is what AWS S3 expects. These variables are only needed for non-AWS S3-compatible providers. ### S3-compatible providers + {/* supa-mdx-lint-disable-next-line Rule003Spelling */} Use the same configuration as MinIO, but point to your provider's endpoint, e.g.: @@ -124,6 +130,7 @@ storage: ## Verify {/* supa-mdx-lint-disable-next-line Rule003Spelling */} + - Open Studio and upload a file to a bucket. List the file using the AWS CLI or `rclone` to confirm the S3 endpoint works. - If using an S3 backend: confirm the file appears in your S3 provider's console. diff --git a/apps/docs/content/guides/telemetry/log-drains.mdx b/apps/docs/content/guides/telemetry/log-drains.mdx index 94a025230efa3..f05a4e6f73f5d 100644 --- a/apps/docs/content/guides/telemetry/log-drains.mdx +++ b/apps/docs/content/guides/telemetry/log-drains.mdx @@ -245,7 +245,6 @@ Ensure the AWS account tied to the Access Key ID has permissions to write to the - ## OpenTelemetry protocol (OTLP) Logs are sent to any OTLP-compatible endpoint using the OpenTelemetry Protocol over HTTP with Protocol Buffers encoding. diff --git a/apps/studio/.github/eslint-rule-baselines.json b/apps/studio/.github/eslint-rule-baselines.json index 2fa5dbaf55a6c..c18c10191a810 100644 --- a/apps/studio/.github/eslint-rule-baselines.json +++ b/apps/studio/.github/eslint-rule-baselines.json @@ -4,8 +4,9 @@ "import/no-anonymous-default-export": 57, "@tanstack/query/exhaustive-deps": 13, "@typescript-eslint/no-explicit-any": 1215, - "no-restricted-exports": 301, - "no-restricted-imports": 59 + "no-restricted-imports": 59, + "no-restricted-exports": 300, + "react/no-unstable-nested-components": 69 }, "ruleFiles": { "react-hooks/exhaustive-deps": { @@ -737,6 +738,65 @@ "types/next.ts": 2, "types/ui.ts": 2 }, + "no-restricted-imports": { + "components/grid/SupabaseGrid.tsx": 1, + "components/grid/SupabaseGrid.utils.ts": 1, + "components/grid/components/common/Shortcuts.tsx": 1, + "components/grid/components/editor/BooleanEditor.tsx": 1, + "components/grid/components/editor/DateTimeEditor.tsx": 1, + "components/grid/components/editor/JsonEditor.tsx": 1, + "components/grid/components/editor/NumberEditor.tsx": 1, + "components/grid/components/editor/SelectEditor.tsx": 1, + "components/grid/components/editor/TextEditor.tsx": 1, + "components/grid/components/editor/TimeEditor.tsx": 1, + "components/grid/components/formatter/BinaryFormatter.tsx": 1, + "components/grid/components/formatter/BooleanFormatter.tsx": 1, + "components/grid/components/formatter/DefaultFormatter.tsx": 1, + "components/grid/components/formatter/ForeignKeyFormatter.tsx": 1, + "components/grid/components/formatter/JsonFormatter.tsx": 1, + "components/grid/components/formatter/ReferenceRecordPeek.tsx": 1, + "components/grid/components/grid/AddColumn.tsx": 1, + "components/grid/components/grid/Grid.tsx": 1, + "components/grid/components/grid/Grid.utils.tsx": 1, + "components/grid/components/grid/RowRenderer.tsx": 2, + "components/grid/components/grid/SelectColumn.tsx": 1, + "components/grid/components/menu/ColumnMenu.tsx": 1, + "components/grid/components/menu/RowContextMenu.tsx": 1, + "components/grid/types/base.ts": 1, + "components/grid/utils/column.ts": 1, + "components/grid/utils/gridColumns.tsx": 1, + "components/interfaces/Auth/Users/Users.utils.tsx": 1, + "components/interfaces/Auth/Users/UsersGridComponents.tsx": 1, + "components/interfaces/Auth/Users/UsersV2.tsx": 1, + "components/interfaces/Integrations/CronJobs/CronJobs.utils.tsx": 1, + "components/interfaces/Integrations/CronJobs/CronJobsTab.DataGrid.tsx": 1, + "components/interfaces/Integrations/CronJobs/PreviousRunsTab.tsx": 1, + "components/interfaces/Integrations/Queues/Queues.utils.tsx": 1, + "components/interfaces/Integrations/Queues/QueuesTab.tsx": 1, + "components/interfaces/Integrations/Queues/SingleQueue/QueueDataGrid.tsx": 1, + "components/interfaces/Integrations/Vault/Secrets/Secrets.utils.tsx": 1, + "components/interfaces/Integrations/Vault/Secrets/SecretsManagement.tsx": 1, + "components/interfaces/Linter/LinterDataGrid.tsx": 1, + "components/interfaces/QueryPerformance/QueryPerformanceGrid.tsx": 1, + "components/interfaces/Realtime/Inspector/MessagesTable.tsx": 1, + "components/interfaces/Realtime/Inspector/RealtimeMessageColumnRenderer.tsx": 1, + "components/interfaces/SQLEditor/UtilityPanel/Results.tsx": 1, + "components/interfaces/Settings/Logs/LogColumnRenderers/AuthColumnRenderer.tsx": 1, + "components/interfaces/Settings/Logs/LogColumnRenderers/DatabaseApiColumnRender.tsx": 1, + "components/interfaces/Settings/Logs/LogColumnRenderers/DatabasePostgresColumnRender.tsx": 1, + "components/interfaces/Settings/Logs/LogColumnRenderers/DefaultPreviewColumnRenderer.tsx": 1, + "components/interfaces/Settings/Logs/LogColumnRenderers/FunctionsEdgeColumnRender.tsx": 1, + "components/interfaces/Settings/Logs/LogColumnRenderers/FunctionsLogsColumnRender.tsx": 1, + "components/interfaces/Settings/Logs/LogTable.tsx": 2, + "components/interfaces/Storage/StorageExplorer/ColumnContextMenu.tsx": 1, + "components/interfaces/Storage/StorageExplorer/FileExplorerColumn.tsx": 1, + "components/interfaces/Storage/StorageExplorer/FileExplorerRow.tsx": 1, + "components/interfaces/Storage/StorageExplorer/FolderContextMenu.tsx": 1, + "components/interfaces/Storage/StorageExplorer/ItemContextMenu.tsx": 1, + "components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/ForeignRowSelector/SelectorGrid.tsx": 1, + "components/interfaces/TableGridEditor/SidePanelEditor/SpreadsheetImport/SpreadsheetPreviewGrid.tsx": 1, + "state/table-editor-table.tsx": 1 + }, "no-restricted-exports": { "__mocks__/hooks/analytics/useFillTimeseriesSorted.ts": 1, "__mocks__/hooks/analytics/useLogsQuery.ts": 1, @@ -1037,67 +1097,55 @@ "hooks/misc/useLatest.ts": 1, "lib/api/apiWrapper.ts": 1, "lib/pingPostgrest.ts": 1, - "scripts/codegen.ts": 1, - "vitest.config.ts": 1 + "scripts/codegen.ts": 1 }, - "no-restricted-imports": { - "components/grid/SupabaseGrid.tsx": 1, - "components/grid/SupabaseGrid.utils.ts": 1, - "components/grid/components/common/Shortcuts.tsx": 1, - "components/grid/components/editor/BooleanEditor.tsx": 1, - "components/grid/components/editor/DateTimeEditor.tsx": 1, - "components/grid/components/editor/JsonEditor.tsx": 1, - "components/grid/components/editor/NumberEditor.tsx": 1, - "components/grid/components/editor/SelectEditor.tsx": 1, - "components/grid/components/editor/TextEditor.tsx": 1, - "components/grid/components/editor/TimeEditor.tsx": 1, - "components/grid/components/formatter/BinaryFormatter.tsx": 1, - "components/grid/components/formatter/BooleanFormatter.tsx": 1, - "components/grid/components/formatter/DefaultFormatter.tsx": 1, - "components/grid/components/formatter/ForeignKeyFormatter.tsx": 1, - "components/grid/components/formatter/JsonFormatter.tsx": 1, - "components/grid/components/formatter/ReferenceRecordPeek.tsx": 1, - "components/grid/components/grid/AddColumn.tsx": 1, - "components/grid/components/grid/Grid.tsx": 1, - "components/grid/components/grid/Grid.utils.tsx": 1, - "components/grid/components/grid/RowRenderer.tsx": 2, - "components/grid/components/grid/SelectColumn.tsx": 1, - "components/grid/components/menu/ColumnMenu.tsx": 1, - "components/grid/components/menu/RowContextMenu.tsx": 1, - "components/grid/types/base.ts": 1, - "components/grid/utils/column.ts": 1, - "components/grid/utils/gridColumns.tsx": 1, - "components/interfaces/Auth/Users/Users.utils.tsx": 1, - "components/interfaces/Auth/Users/UsersGridComponents.tsx": 1, - "components/interfaces/Auth/Users/UsersV2.tsx": 1, - "components/interfaces/Integrations/CronJobs/CronJobs.utils.tsx": 1, - "components/interfaces/Integrations/CronJobs/CronJobsTab.DataGrid.tsx": 1, - "components/interfaces/Integrations/CronJobs/PreviousRunsTab.tsx": 1, - "components/interfaces/Integrations/Queues/Queues.utils.tsx": 1, - "components/interfaces/Integrations/Queues/QueuesTab.tsx": 1, - "components/interfaces/Integrations/Queues/SingleQueue/QueueDataGrid.tsx": 1, - "components/interfaces/Integrations/Vault/Secrets/Secrets.utils.tsx": 1, - "components/interfaces/Integrations/Vault/Secrets/SecretsManagement.tsx": 1, - "components/interfaces/Linter/LinterDataGrid.tsx": 1, - "components/interfaces/QueryPerformance/QueryPerformanceGrid.tsx": 1, - "components/interfaces/Realtime/Inspector/MessagesTable.tsx": 1, - "components/interfaces/Realtime/Inspector/RealtimeMessageColumnRenderer.tsx": 1, - "components/interfaces/SQLEditor/UtilityPanel/Results.tsx": 1, - "components/interfaces/Settings/Logs/LogColumnRenderers/AuthColumnRenderer.tsx": 1, - "components/interfaces/Settings/Logs/LogColumnRenderers/DatabaseApiColumnRender.tsx": 1, - "components/interfaces/Settings/Logs/LogColumnRenderers/DatabasePostgresColumnRender.tsx": 1, - "components/interfaces/Settings/Logs/LogColumnRenderers/DefaultPreviewColumnRenderer.tsx": 1, - "components/interfaces/Settings/Logs/LogColumnRenderers/FunctionsEdgeColumnRender.tsx": 1, - "components/interfaces/Settings/Logs/LogColumnRenderers/FunctionsLogsColumnRender.tsx": 1, - "components/interfaces/Settings/Logs/LogTable.tsx": 2, - "components/interfaces/Storage/StorageExplorer/ColumnContextMenu.tsx": 1, + "react/no-unstable-nested-components": { + "components/grid/components/header/filter/FilterPopoverNew.tsx": 1, + "components/interfaces/Account/Preferences/ThemeSettings.tsx": 1, + "components/interfaces/App/CommandMenu/ContextSearchResults.shared.tsx": 1, + "components/interfaces/Auth/AuthProvidersForm/FormField.tsx": 1, + "components/interfaces/Connect/ConnectTabContent.tsx": 1, + "components/interfaces/ConnectSheet/ConnectStepsSection.tsx": 1, + "components/interfaces/Database/Migrations/MigrationsEmptyState.tsx": 3, + "components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx": 1, + "components/interfaces/Functions/TerminalInstructions.tsx": 3, + "components/interfaces/Integrations/VercelGithub/IntegrationPanels.tsx": 1, + "components/interfaces/Integrations/VercelGithub/ProjectLinker.tsx": 1, + "components/interfaces/Integrations/Wrappers/WrapperTableEditor.tsx": 2, + "components/interfaces/Linter/LintPageTabs.tsx": 1, + "components/interfaces/Linter/LinterDataGrid.tsx": 3, + "components/interfaces/Markdown.tsx": 3, + "components/interfaces/Organization/BillingSettings/Subscription/PaymentMethodSelection.tsx": 1, + "components/interfaces/Organization/IntegrationSettings/IntegrationSettings.tsx": 1, + "components/interfaces/Organization/Usage/UsageBarChart.tsx": 1, + "components/interfaces/ProjectAPIDocs/FirstLevelNav.tsx": 1, + "components/interfaces/Settings/General/TransferProjectPanel/TransferProjectButton.tsx": 1, + "components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/DeployNewReplicaPanel.tsx": 1, + "components/interfaces/Settings/Logs/LogSelection.tsx": 1, + "components/interfaces/Settings/Logs/LogTable.tsx": 3, + "components/interfaces/Settings/Logs/LogsFormatters.tsx": 2, + "components/interfaces/Settings/Logs/PreviewFilterPanelWithUniversal.tsx": 1, "components/interfaces/Storage/StorageExplorer/FileExplorerColumn.tsx": 1, - "components/interfaces/Storage/StorageExplorer/FileExplorerRow.tsx": 1, - "components/interfaces/Storage/StorageExplorer/FolderContextMenu.tsx": 1, - "components/interfaces/Storage/StorageExplorer/ItemContextMenu.tsx": 1, - "components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/ForeignRowSelector/SelectorGrid.tsx": 1, - "components/interfaces/TableGridEditor/SidePanelEditor/SpreadsheetImport/SpreadsheetPreviewGrid.tsx": 1, - "state/table-editor-table.tsx": 1 + "components/interfaces/Storage/StoragePolicies/StoragePoliciesEditPolicyModal.tsx": 1, + "components/interfaces/TableGridEditor/SidePanelEditor/ForeignKeySelector/ForeignKeySelector.tsx": 2, + "components/interfaces/UnifiedLogs/components/LogTypeIcon.tsx": 5, + "components/layouts/ProjectLayout/LayoutHeader/FeedbackDropdown/FeedbackWidget.tsx": 1, + "components/layouts/ProjectLayout/NavigationBar/NavigationIconLink.tsx": 1, + "components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx": 3, + "components/layouts/SQLEditorLayout/SQLEditorNavV2/SearchList.tsx": 1, + "components/ui/AIAssistantPanel/MessageMarkdown.tsx": 1, + "components/ui/CardButton.tsx": 1, + "components/ui/Charts/AreaChart.tsx": 1, + "components/ui/Charts/BarChart.tsx": 1, + "components/ui/Charts/ComposedChart.tsx": 2, + "components/ui/Charts/ComposedChart.utils.tsx": 3, + "components/ui/ErrorBoundary/ErrorBoundary.tsx": 1, + "components/ui/FileExplorerAndEditor/index.tsx": 1, + "components/ui/NoPermission.tsx": 1, + "components/ui/SqlEditor.tsx": 1, + "pages/integrations/vercel/[slug]/deploy-button/new-project.tsx": 1, + "pages/project/[ref]/integrations/index.tsx": 2, + "pages/project/[ref]/observability/database.tsx": 1 } } } diff --git a/apps/studio/components/interfaces/Auth/AuthProvidersForm/FormField.tsx b/apps/studio/components/interfaces/Auth/AuthProvidersForm/FormField.tsx index 2058ae821f71a..3baabe74351d9 100644 --- a/apps/studio/components/interfaces/Auth/AuthProvidersForm/FormField.tsx +++ b/apps/studio/components/interfaces/Auth/AuthProvidersForm/FormField.tsx @@ -1,13 +1,13 @@ +import { Markdown } from 'components/interfaces/Markdown' +import { DatePicker } from 'components/ui/DatePicker' import dayjs from 'dayjs' +import { BASE_PATH } from 'lib/constants' import { Eye, EyeOff } from 'lucide-react' import { useEffect, useState } from 'react' import ReactMarkdown from 'react-markdown' - -import { Markdown } from 'components/interfaces/Markdown' -import { DatePicker } from 'components/ui/DatePicker' -import { BASE_PATH } from 'lib/constants' import { Button, Input, InputNumber, Listbox, Toggle } from 'ui' import { InfoTooltip } from 'ui-patterns/info-tooltip' + import type { Enum } from './AuthProvidersForm.types' interface FormFieldProps { @@ -269,7 +269,11 @@ const FormField = ({ value={option.value} addOnBefore={() => { return option.icon ? ( - + {`${option.label} ) : null }} > diff --git a/apps/studio/components/interfaces/Auth/ThirdPartyAuthForm/AwsRegionSelector.tsx b/apps/studio/components/interfaces/Auth/ThirdPartyAuthForm/AwsRegionSelector.tsx index ec0a1d18ee744..1af9df53dde5b 100644 --- a/apps/studio/components/interfaces/Auth/ThirdPartyAuthForm/AwsRegionSelector.tsx +++ b/apps/studio/components/interfaces/Auth/ThirdPartyAuthForm/AwsRegionSelector.tsx @@ -1,19 +1,19 @@ import { Check, ChevronsUpDown } from 'lucide-react' -import { useState } from 'react' +import { useId, useState } from 'react' import { Button, + cn, + Command_Shadcn_, CommandEmpty_Shadcn_, CommandGroup_Shadcn_, CommandInput_Shadcn_, CommandItem_Shadcn_, CommandList_Shadcn_, - Command_Shadcn_, FormControl_Shadcn_, + Popover_Shadcn_, PopoverContent_Shadcn_, PopoverTrigger_Shadcn_, - Popover_Shadcn_, ScrollArea, - cn, } from 'ui' // copied from https://docs.aws.amazon.com/general/latest/gr/cognito_identity.html @@ -56,6 +56,7 @@ export const AwsRegionSelector = ({ onChange: (value: string) => void }) => { const [open, setOpen] = useState(false) + const listboxId = useId() return ( @@ -64,6 +65,8 @@ export const AwsRegionSelector = ({ - + diff --git a/apps/studio/components/interfaces/Database/Backups/PITR/TimezoneSelection.tsx b/apps/studio/components/interfaces/Database/Backups/PITR/TimezoneSelection.tsx index d527da4b6d495..8f7a3c94a3ba2 100644 --- a/apps/studio/components/interfaces/Database/Backups/PITR/TimezoneSelection.tsx +++ b/apps/studio/components/interfaces/Database/Backups/PITR/TimezoneSelection.tsx @@ -1,18 +1,18 @@ import { CheckIcon, ChevronsUpDown, Globe } from 'lucide-react' -import { useState } from 'react' +import { useId, useState } from 'react' import { Button, + cn, + Command_Shadcn_, CommandEmpty_Shadcn_, CommandGroup_Shadcn_, CommandInput_Shadcn_, CommandItem_Shadcn_, CommandList_Shadcn_, - Command_Shadcn_, + Popover_Shadcn_, PopoverContent_Shadcn_, PopoverTrigger_Shadcn_, - Popover_Shadcn_, ScrollArea, - cn, } from 'ui' import { ALL_TIMEZONES } from './PITR.constants' @@ -28,6 +28,7 @@ export const TimezoneSelection = ({ onSelectTimezone, }: TimezoneSelectionProps) => { const [open, setOpen] = useState(false) + const listboxId = useId() const timezoneOptions = ALL_TIMEZONES.map((option) => option.text) @@ -38,6 +39,7 @@ export const TimezoneSelection = ({ - + diff --git a/apps/studio/components/interfaces/Home/Home.tsx b/apps/studio/components/interfaces/Home/Home.tsx index 8560ca79a4c8f..45d54e0fdaeb3 100644 --- a/apps/studio/components/interfaces/Home/Home.tsx +++ b/apps/studio/components/interfaces/Home/Home.tsx @@ -167,21 +167,19 @@ export const Home = () => { )} - {IS_PLATFORM && ( -
- - Functions - - {isLoadingFunctions ? ( - - ) : ( -

{functionsCount}

- )} -
- )} +
+ + Functions + + {isLoadingFunctions ? ( + + ) : ( +

{functionsCount}

+ )} +
{IS_PLATFORM && (
diff --git a/apps/studio/components/interfaces/Integrations/Wrappers/WrapperTableEditor.tsx b/apps/studio/components/interfaces/Integrations/Wrappers/WrapperTableEditor.tsx index 56849026784a4..95ac87a2d1898 100644 --- a/apps/studio/components/interfaces/Integrations/Wrappers/WrapperTableEditor.tsx +++ b/apps/studio/components/interfaces/Integrations/Wrappers/WrapperTableEditor.tsx @@ -1,9 +1,8 @@ -import { Check, ChevronsUpDown, Database, Plus } from 'lucide-react' -import { useEffect, useState } from 'react' - import { ActionBar } from 'components/interfaces/TableGridEditor/SidePanelEditor/ActionBar' import { useSchemasQuery } from 'data/database/schemas-query' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { Check, ChevronsUpDown, Database, Plus } from 'lucide-react' +import { useEffect, useId, useState } from 'react' import { Button, cn, @@ -25,6 +24,7 @@ import { SidePanel, } from 'ui' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' + import WrapperDynamicColumns from './WrapperDynamicColumns' import type { Table, TableOption } from './Wrappers.types' import { makeValidateRequired } from './Wrappers.utils' @@ -48,6 +48,7 @@ const WrapperTableEditor = ({ initialData, }: WrapperTableEditorProps) => { const [open, setOpen] = useState(false) + const listboxId = useId() const [selectedTableIndex, setSelectedTableIndex] = useState('') useEffect(() => { @@ -101,6 +102,8 @@ const WrapperTableEditor = ({ - + @@ -246,6 +255,8 @@ export const BillingCustomerDataForm = ({ role="combobox" size="medium" disabled={disabled} + aria-expanded={showTaxIDsPopover} + aria-controls={taxIdListboxId} className={cn( 'w-full justify-between h-[34px] pr-2', !selectedTaxId && 'text-muted' @@ -260,7 +271,12 @@ export const BillingCustomerDataForm = ({ - + diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/MemberRow.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/MemberRow.tsx index 6592bf3696875..5dea2402437de 100644 --- a/apps/studio/components/interfaces/Organization/TeamSettings/MemberRow.tsx +++ b/apps/studio/components/interfaces/Organization/TeamSettings/MemberRow.tsx @@ -9,7 +9,6 @@ import { useOrganizationRolesV2Query } from 'data/organization-members/organizat import { OrganizationMember } from 'data/organizations/organization-members-query' import { useOrgProjectsInfiniteQuery } from 'data/projects/org-projects-infinite-query' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' -import { getGitHubProfileImgUrl } from 'lib/github' import { useProfile } from 'lib/profile' import { Badge, @@ -51,8 +50,8 @@ export const MemberRow = ({ member }: MemberRowProps) => { const isEmailUser = member.username === member.primary_email const isFlyUser = Boolean(member.primary_email?.endsWith('customer.fly.io')) - const profileImageUrl = - isInvitedUser || isEmailUser || isFlyUser ? undefined : getGitHubProfileImgUrl(member.username) + // Use generic avatar for all team members instead of attempting to fetch from GitHub + const profileImageUrl = undefined return ( diff --git a/apps/studio/components/interfaces/Organization/Usage/Usage.tsx b/apps/studio/components/interfaces/Organization/Usage/Usage.tsx index c6e1c0867dd35..6e8baf4aa658f 100644 --- a/apps/studio/components/interfaces/Organization/Usage/Usage.tsx +++ b/apps/studio/components/interfaces/Organization/Usage/Usage.tsx @@ -1,8 +1,4 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import dayjs from 'dayjs' -import Link from 'next/link' -import { useMemo, useState } from 'react' - import { useParams } from 'common' import { ScaffoldContainer, @@ -17,13 +13,17 @@ import { OrganizationProjectSelector } from 'components/ui/OrganizationProjectSe import { useOrgDailyStatsQuery } from 'data/analytics/org-daily-stats-query' import { useProjectDetailQuery } from 'data/projects/project-detail-query' import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' +import dayjs from 'dayjs' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { TIME_PERIODS_BILLING, TIME_PERIODS_REPORTS } from 'lib/constants/metrics' import { Check, ChevronDown } from 'lucide-react' +import Link from 'next/link' import { useQueryState } from 'nuqs' +import { useMemo, useState } from 'react' import { Button, cn, CommandGroup_Shadcn_, CommandItem_Shadcn_ } from 'ui' import { Admonition } from 'ui-patterns' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' + import { Restriction } from '../BillingSettings/Restriction' import ActiveCompute from './ActiveCompute' import Activity from './Activity' @@ -163,13 +163,15 @@ export const Usage = () => { onSelect={(project) => { setSelectedProjectRef(project.ref) }} - renderTrigger={() => { + renderTrigger={({ listboxId, open }) => { return ( - + } tooltip={{ content: { side: 'bottom', text: 'Toggle column visibility' } }} /> - + diff --git a/apps/studio/components/ui/EditorPanel/EditorPanel.tsx b/apps/studio/components/ui/EditorPanel/EditorPanel.tsx index a31d9fe0359b5..7f660ea02c1fa 100644 --- a/apps/studio/components/ui/EditorPanel/EditorPanel.tsx +++ b/apps/studio/components/ui/EditorPanel/EditorPanel.tsx @@ -16,7 +16,7 @@ import { BASE_PATH } from 'lib/constants' import { useProfile } from 'lib/profile' import { Book, Maximize2, X } from 'lucide-react' import { useRouter } from 'next/router' -import { useState } from 'react' +import { useId, useState } from 'react' import { useEditorPanelStateSnapshot } from 'state/editor-panel-state' import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state' import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2' @@ -83,6 +83,7 @@ export const EditorPanel = () => { const [showWarning, setShowWarning] = useState<'hasWriteOperation' | 'hasUnknownFunctions'>() const [showResults, setShowResults] = useState(true) const [isTemplatesOpen, setIsTemplatesOpen] = useState(false) + const templatesListboxId = useId() const errorHeader = error?.formattedError?.split('\n')?.filter((x: string) => x.length > 0)?.[0] const errorContent = @@ -167,12 +168,13 @@ export const EditorPanel = () => { role="combobox" className="mr-2" aria-expanded={isTemplatesOpen} + aria-controls={templatesListboxId} icon={} > Templates - + diff --git a/apps/studio/components/ui/OrganizationProjectSelector.tsx b/apps/studio/components/ui/OrganizationProjectSelector.tsx index f53cfa2bfbf38..d030f24554979 100644 --- a/apps/studio/components/ui/OrganizationProjectSelector.tsx +++ b/apps/studio/components/ui/OrganizationProjectSelector.tsx @@ -3,7 +3,7 @@ import { useDebounce, useIntersectionObserver } from '@uidotdev/usehooks' import { OrgProject, useOrgProjectsInfiniteQuery } from 'data/projects/org-projects-infinite-query' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { Check, ChevronsUpDown, HelpCircle } from 'lucide-react' -import { ReactNode, useEffect, useMemo, useRef, useState } from 'react' +import { ReactNode, useEffect, useId, useMemo, useRef, useState } from 'react' import { Button, cn, @@ -34,9 +34,13 @@ interface OrganizationProjectSelectorSelectorProps { renderTrigger?: ({ isLoading, project, + listboxId, + open, }: { isLoading: boolean project?: OrgProject + listboxId: string + open: boolean }) => ReactNode renderActions?: (setOpen: (value: boolean) => void) => ReactNode onSelect?: (project: OrgProject) => void @@ -69,6 +73,7 @@ export const OrganizationProjectSelector = ({ const [openInternal, setOpenInternal] = useState(false) const open = _open ?? openInternal const setOpen = _setOpen ?? setOpenInternal + const listboxId = useId() const [search, setSearch] = useState('') const debouncedSearch = useDebounce(search, 500) @@ -130,13 +135,20 @@ export const OrganizationProjectSelector = ({ {renderTrigger ? ( - renderTrigger({ isLoading: isLoadingProjects || isFetching, project: selectedProject }) + renderTrigger({ + isLoading: isLoadingProjects || isFetching, + project: selectedProject, + listboxId, + open, + }) ) : ( - + diff --git a/apps/www/_events/2026-03-19-agency-webinar-ai-prototyping-production.mdx b/apps/www/_events/2026-03-19-agency-webinar-ai-prototyping-production.mdx new file mode 100644 index 0000000000000..a5cc2c4a7182a --- /dev/null +++ b/apps/www/_events/2026-03-19-agency-webinar-ai-prototyping-production.mdx @@ -0,0 +1,40 @@ +--- +title: 'Ship Fast, Stay Safe: AI Prototyping That Survives Production' +meta_title: 'Ship Fast, Stay Safe: AI Prototyping That Survives Production' +subtitle: >- + Learn how top agencies balance velocity with control when using AI coding tools to build production applications on Supabase +meta_description: >- + Learn how top agencies balance AI prototyping speed with production safety. Database schema design, RLS policies, validation practices, and client handoff strategies. +type: webinar +onDemand: false +date: '2026-03-19T09:00:00.000-07:00' +timezone: America/Los_Angeles +duration: 45 mins +categories: + - webinar +main_cta: + { + url: 'https://attendee.gotowebinar.com/register/5606981989518150747', + target: '_blank', + label: 'Register now', + } +speakers: 'seth_kramer,dave_wilson' +--- + +AI coding tools have made prototyping almost instant. Agencies now send working demos to clients within minutes of a kickoff call. But speed creates new risks: databases with no row-level security, permissions that break under load, and prototypes that fall apart the moment they hit production. + +Join agency leaders and Supabase as they share how top agencies balance velocity with control. Learn when to let AI move fast, and where experienced developers still need to step in. + +## Key takeaways + +- Why leading agencies design database schemas and RLS policies before touching the UI + +- When to use visual AI builders for speed vs code-first tools for control + +- How to prevent AI tools from making breaking changes to production environments + +- Lightweight validation practices that preserve velocity without sacrificing safety + +- How to hand projects off to clients without handing over production risk + +Join us live to participate in the Q&A. Can't make it? We'll send you a link to the recording. diff --git a/apps/www/public/rss.xml b/apps/www/public/rss.xml index ee7d8c3ebfcc0..bd8ffb5024d5b 100644 --- a/apps/www/public/rss.xml +++ b/apps/www/public/rss.xml @@ -4,15 +4,43 @@ https://supabase.com Latest news from Supabase en - Tue, 03 Feb 2026 00:00:00 -0700 + Fri, 13 Feb 2026 00:00:00 -0700 + https://supabase.com/blog/supabase-incident-on-february-12-2026 + Supabase incident on February 12, 2026 + https://supabase.com/blog/supabase-incident-on-february-12-2026 + A detailed account of the February 12 outage in us-east-2, what caused it, and the steps we are taking to prevent it from happening again. + Fri, 13 Feb 2026 00:00:00 -0700 + + + https://supabase.com/blog/hydra-joins-supabase + Hydra joins Supabase + https://supabase.com/blog/hydra-joins-supabase + The Hydra team, maintainers of pg_duckdb, is joining Supabase to focus on Postgres + Analytics and Open Warehouse Architecture. + Tue, 10 Feb 2026 00:00:00 -0700 + + + https://supabase.com/blog/x-twitter-oauth-2-provider + X / Twitter OAuth 2.0 is now available for Supabase Auth + https://supabase.com/blog/x-twitter-oauth-2-provider + You can now add "Sign in with X" to your application using the new X / Twitter (OAuth 2.0) provider in Supabase Auth. + Fri, 06 Feb 2026 00:00:00 -0700 + + https://supabase.com/blog/bknd-joins-supabase BKND joins Supabase https://supabase.com/blog/bknd-joins-supabase Dennis Senn, creator of BKND, is joining Supabase to build a Lite offering for agentic workloads. Tue, 03 Feb 2026 00:00:00 -0700 + + https://supabase.com/blog/supabase-is-now-an-official-claude-connector + Supabase is now an official Claude connector + https://supabase.com/blog/supabase-is-now-an-official-claude-connector + Connect your Supabase projects to Claude and manage your entire database infrastructure by telling Claude what you need. + Tue, 03 Feb 2026 00:00:00 -0700 + https://supabase.com/blog/supabase-privatelink-available Supabase PrivateLink is now available diff --git a/docker/docker-compose.s3.yml b/docker/docker-compose.s3.yml index b7f8a2c70beff..cb3e25c3e7542 100644 --- a/docker/docker-compose.s3.yml +++ b/docker/docker-compose.s3.yml @@ -1,16 +1,16 @@ services: minio: - image: minio/minio - ports: - - '9000:9000' - - '9001:9001' + image: cgr.dev/chainguard/minio + #ports: + # - '9000:9000' + # - '9001:9001' environment: MINIO_ROOT_USER: ${MINIO_ROOT_USER} MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD} command: server --console-address ":9001" /data healthcheck: - test: [ "CMD", "curl", "-f", "http://minio:9000/minio/health/live" ] + test: [ "CMD", "mc", "ready", "local" ] interval: 2s timeout: 10s retries: 5 @@ -18,7 +18,7 @@ services: - ./volumes/storage:/data:z minio-createbucket: - image: minio/mc + image: cgr.dev/chainguard/minio-client:latest-dev depends_on: minio: condition: service_healthy @@ -30,7 +30,8 @@ services: storage: container_name: supabase-storage - image: supabase/storage-api:v1.37.1 + image: supabase/storage-api:v1.37.8 + restart: unless-stopped depends_on: db: # Disable this if you are using an external Postgres database @@ -41,6 +42,8 @@ services: condition: service_started minio-createbucket: condition: service_completed_successfully + minio: + condition: service_healthy healthcheck: test: [ @@ -54,7 +57,6 @@ services: timeout: 5s interval: 5s retries: 3 - restart: unless-stopped environment: ANON_KEY: ${ANON_KEY} SERVICE_KEY: ${SERVICE_ROLE_KEY} @@ -64,33 +66,22 @@ services: REQUEST_ALLOW_X_FORWARDED_PATH: "true" FILE_SIZE_LIMIT: 52428800 STORAGE_BACKEND: s3 + # S3 bucket when using S3 backend, directory name when using 'file' GLOBAL_S3_BUCKET: ${GLOBAL_S3_BUCKET} + # S3 Backend configuration GLOBAL_S3_ENDPOINT: http://minio:9000 GLOBAL_S3_PROTOCOL: http GLOBAL_S3_FORCE_PATH_STYLE: true AWS_ACCESS_KEY_ID: ${MINIO_ROOT_USER} AWS_SECRET_ACCESS_KEY: ${MINIO_ROOT_PASSWORD} - AWS_DEFAULT_REGION: stub - FILE_STORAGE_BACKEND_PATH: /var/lib/storage + #FILE_STORAGE_BACKEND_PATH: /var/lib/storage TENANT_ID: ${STORAGE_TENANT_ID} # TODO: https://github.com/supabase/storage-api/issues/55 REGION: ${REGION} ENABLE_IMAGE_TRANSFORMATION: "true" IMGPROXY_URL: http://imgproxy:5001 + # S3 protocol endpoint configuration S3_PROTOCOL_ACCESS_KEY_ID: ${S3_PROTOCOL_ACCESS_KEY_ID} S3_PROTOCOL_ACCESS_KEY_SECRET: ${S3_PROTOCOL_ACCESS_KEY_SECRET} - volumes: - - ./volumes/storage:/var/lib/storage:z - - imgproxy: - container_name: supabase-imgproxy - image: darthsim/imgproxy:v3.8.0 - healthcheck: - test: [ "CMD", "imgproxy", "health" ] - timeout: 5s - interval: 5s - retries: 3 - environment: - IMGPROXY_BIND: ":5001" - IMGPROXY_USE_ETAG: "true" - IMGPROXY_ENABLE_WEBP_DETECTION: ${IMGPROXY_ENABLE_WEBP_DETECTION} + #volumes: + # - ./volumes/storage:/var/lib/storage:z diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 11f952c7d6439..fc6dcb3729fb3 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -244,8 +244,14 @@ services: container_name: supabase-storage image: supabase/storage-api:v1.37.8 restart: unless-stopped - volumes: - - ./volumes/storage:/var/lib/storage:z + depends_on: + db: + # Disable this if you are using an external Postgres database + condition: service_healthy + rest: + condition: service_started + imgproxy: + condition: service_started healthcheck: test: [ @@ -259,14 +265,6 @@ services: timeout: 5s interval: 5s retries: 3 - depends_on: - db: - # Disable this if you are using an external Postgres database - condition: service_healthy - rest: - condition: service_started - imgproxy: - condition: service_started environment: ANON_KEY: ${ANON_KEY} SERVICE_KEY: ${SERVICE_ROLE_KEY} @@ -276,15 +274,25 @@ services: REQUEST_ALLOW_X_FORWARDED_PATH: "true" FILE_SIZE_LIMIT: 52428800 STORAGE_BACKEND: file + # S3 bucket when using S3 backend, directory name when using 'file' + GLOBAL_S3_BUCKET: ${GLOBAL_S3_BUCKET} + # S3 Backend configuration + #GLOBAL_S3_ENDPOINT: https://your-s3-endpoint + #GLOBAL_S3_PROTOCOL: https + #GLOBAL_S3_FORCE_PATH_STYLE: true + #AWS_ACCESS_KEY_ID: your-access-key-id + #AWS_SECRET_ACCESS_KEY: your-secret-access-key FILE_STORAGE_BACKEND_PATH: /var/lib/storage TENANT_ID: ${STORAGE_TENANT_ID} # TODO: https://github.com/supabase/storage-api/issues/55 REGION: ${REGION} - GLOBAL_S3_BUCKET: ${GLOBAL_S3_BUCKET} ENABLE_IMAGE_TRANSFORMATION: "true" IMGPROXY_URL: http://imgproxy:5001 + # S3 protocol endpoint configuration S3_PROTOCOL_ACCESS_KEY_ID: ${S3_PROTOCOL_ACCESS_KEY_ID} S3_PROTOCOL_ACCESS_KEY_SECRET: ${S3_PROTOCOL_ACCESS_KEY_SECRET} + volumes: + - ./volumes/storage:/var/lib/storage:z imgproxy: container_name: supabase-imgproxy @@ -334,6 +342,7 @@ services: restart: unless-stopped volumes: - ./volumes/functions:/home/deno/functions:Z + - deno-cache:/root/.cache/deno depends_on: analytics: condition: service_healthy @@ -543,3 +552,4 @@ services: volumes: db-config: + deno-cache: diff --git a/docker/volumes/functions/hello/index.ts b/docker/volumes/functions/hello/index.ts index f1e20b90e0157..e3f138b5ecafc 100644 --- a/docker/volumes/functions/hello/index.ts +++ b/docker/volumes/functions/hello/index.ts @@ -2,9 +2,7 @@ // https://deno.land/manual/getting_started/setup_your_environment // This enables autocomplete, go to definition, etc. -import { serve } from "https://deno.land/std@0.177.1/http/server.ts" - -serve(async () => { +Deno.serve(async () => { return new Response( `"Hello from Edge Functions!"`, { headers: { "Content-Type": "application/json" } }, diff --git a/docker/volumes/functions/main/index.ts b/docker/volumes/functions/main/index.ts index a094010b9dade..b593527d687a7 100644 --- a/docker/volumes/functions/main/index.ts +++ b/docker/volumes/functions/main/index.ts @@ -1,4 +1,3 @@ -import { serve } from 'https://deno.land/std@0.131.0/http/server.ts' import * as jose from 'https://deno.land/x/jose@v4.14.4/index.ts' console.log('main function started') @@ -30,7 +29,7 @@ async function verifyJWT(jwt: string): Promise { return true } -serve(async (req: Request) => { +Deno.serve(async (req: Request) => { if (req.method !== 'OPTIONS' && VERIFY_JWT) { try { const token = getAuthToken(req) diff --git a/e2e/studio/features/realtime-inspector.spec.ts b/e2e/studio/features/realtime-inspector.spec.ts new file mode 100644 index 0000000000000..64737faa4a7dd --- /dev/null +++ b/e2e/studio/features/realtime-inspector.spec.ts @@ -0,0 +1,153 @@ +import { expect } from '@playwright/test' +import { test } from '../utils/test.js' +import { + getMessageCount, + joinChannel, + leaveChannel, + navigateToRealtimeInspector, + openBroadcastModal, + startListening, + stopListening, + waitForRealtimeMessage, +} from '../utils/realtime-helpers.js' + +const testChannelName = 'pw_realtime_test_channel' + +test.describe('Realtime Inspector', () => { + test.beforeEach(async ({ page, ref }) => { + await navigateToRealtimeInspector(page, ref) + }) + + test.describe('Basic Inspector UI', () => { + test('inspector page loads correctly with empty state', async ({ page }) => { + await expect(page.getByRole('button', { name: 'Join a channel' })).toBeVisible() + + const startButton = page.getByRole('button', { name: 'Start listening' }) + await expect(startButton).toBeVisible() + await expect(startButton).toBeDisabled() + + await expect(page.getByText('Create realtime experiences')).toBeVisible() + }) + + test('channel selection popover opens and works', async ({ page }) => { + await page.getByRole('button', { name: 'Join a channel' }).click() + + await expect(page.getByPlaceholder('Enter a channel name')).toBeVisible({ timeout: 5000 }) + await expect(page.getByRole('button', { name: 'Listen to channel' })).toBeVisible() + await expect(page.getByText('Is channel private?')).toBeVisible() + + await page.keyboard.press('Escape') + }) + + test('can join and leave a channel', async ({ page }) => { + await joinChannel(page, testChannelName) + + await expect(page.getByText('Listening', { exact: true })).toBeVisible({ timeout: 10000 }) + await expect(page.getByRole('button', { name: `Channel: ${testChannelName}` })).toBeVisible() + + await leaveChannel(page) + + await expect(page.getByRole('button', { name: 'Join a channel' })).toBeVisible() + }) + + test('start/stop listening button works', async ({ page }) => { + await joinChannel(page, testChannelName) + + await expect(page.getByText('Listening', { exact: true })).toBeVisible({ timeout: 10000 }) + await expect(page.getByRole('button', { name: 'Stop listening' })).toBeVisible() + + await stopListening(page) + + await expect(page.getByRole('button', { name: 'Start listening' })).toBeVisible() + await expect(page.getByText('Listening', { exact: true })).not.toBeVisible() + + await startListening(page) + + await expect(page.getByText('Listening', { exact: true })).toBeVisible({ timeout: 10000 }) + + await leaveChannel(page) + }) + }) + + test.describe('Broadcast Messages', () => { + test('broadcast messages appear in the UI when listening', async ({ page }) => { + await joinChannel(page, testChannelName) + + await expect(page.getByText('Listening', { exact: true })).toBeVisible({ timeout: 10000 }) + + await openBroadcastModal(page) + await page.getByRole('button', { name: 'Confirm' }).click() + + await expect(page.getByText('Successfully broadcasted message')).toBeVisible({ + timeout: 10000, + }) + + const messageRow = await waitForRealtimeMessage(page, { timeout: 30000 }) + await expect(messageRow).toBeVisible() + + const count = await getMessageCount(page) + expect(count).toBeGreaterThanOrEqual(1) + + await leaveChannel(page) + }) + + test('clicking broadcast message shows detail panel', async ({ page }) => { + await joinChannel(page, testChannelName) + + await openBroadcastModal(page) + await page.getByRole('button', { name: 'Confirm' }).click() + await expect(page.getByText('Successfully broadcasted message')).toBeVisible({ + timeout: 10000, + }) + await waitForRealtimeMessage(page, { timeout: 30000 }) + + const messageRow = page.getByRole('row').filter({ hasText: 'broadcast' }).first() + await expect(messageRow).toBeVisible({ timeout: 5000 }) + await messageRow.click() + + await expect(page.getByText('Timestamp')).toBeVisible({ timeout: 5000 }) + + await leaveChannel(page) + }) + + test('broadcast modal validates JSON payload', async ({ page }) => { + await joinChannel(page, testChannelName) + + await openBroadcastModal(page) + + const codeEditor = page.getByRole('textbox', { name: /Editor content/i }) + await codeEditor.click({ force: true }) + await page.keyboard.press('ControlOrMeta+KeyA') + await page.keyboard.type('{ invalid json }') + + await page.getByRole('button', { name: 'Confirm' }).click() + + await expect(page.getByText('Please provide a valid JSON')).toBeVisible({ timeout: 5000 }) + + await page.getByRole('button', { name: 'Cancel' }).click() + + await leaveChannel(page) + }) + }) + + test.describe('Message Display', () => { + test('messages counter shows correct count', async ({ page }) => { + await joinChannel(page, `${testChannelName}_counter`) + + const initialCount = await getMessageCount(page) + + await openBroadcastModal(page) + await page.getByRole('button', { name: 'Confirm' }).click() + await expect(page.getByText('Successfully broadcasted message')).toBeVisible({ + timeout: 10000, + }) + + await waitForRealtimeMessage(page, { timeout: 30000 }) + + const newCount = await getMessageCount(page) + expect(newCount).toBeGreaterThan(initialCount) + + await leaveChannel(page) + }) + }) +}) diff --git a/e2e/studio/utils/realtime-helpers.ts b/e2e/studio/utils/realtime-helpers.ts new file mode 100644 index 0000000000000..e1729f2c8f71c --- /dev/null +++ b/e2e/studio/utils/realtime-helpers.ts @@ -0,0 +1,69 @@ +import { expect, Page } from '@playwright/test' +import { toUrl } from './to-url.js' + +export async function navigateToRealtimeInspector(page: Page, ref: string) { + await page.goto(toUrl(`/project/${ref}/realtime/inspector`)) + await expect(page.locator('text=Join a channel')).toBeVisible({ timeout: 30000 }) +} + +export async function joinChannel(page: Page, channelName: string) { + await page.getByRole('button', { name: /Join a channel|Channel:/ }).click() + await expect(page.getByPlaceholder('Enter a channel name')).toBeVisible({ timeout: 5000 }) + await page.getByPlaceholder('Enter a channel name').fill(channelName) + await page.getByRole('button', { name: 'Listen to channel' }).click() + await expect(page.getByText('Listening', { exact: true })).toBeVisible({ timeout: 10000 }) +} + +export async function leaveChannel(page: Page) { + await page.getByRole('button', { name: /Channel:/ }).click() + await expect(page.getByRole('button', { name: 'Leave channel' })).toBeVisible({ timeout: 5000 }) + await page.getByRole('button', { name: 'Leave channel' }).click() + await expect(page.getByRole('button', { name: 'Join a channel' })).toBeVisible({ timeout: 5000 }) +} + +export async function startListening(page: Page) { + const listenButton = page.getByRole('button', { name: 'Start listening' }) + await expect(listenButton).toBeVisible({ timeout: 5000 }) + await listenButton.click() + await expect(page.getByText('Listening', { exact: true })).toBeVisible({ timeout: 10000 }) +} + +export async function stopListening(page: Page) { + const stopButton = page.getByRole('button', { name: 'Stop listening' }) + await expect(stopButton).toBeVisible({ timeout: 5000 }) + await stopButton.click() + await expect(page.getByRole('button', { name: 'Start listening' })).toBeVisible({ timeout: 5000 }) +} + +export async function openBroadcastModal(page: Page) { + const broadcastButton = page.getByRole('button', { name: 'Broadcast a message' }) + await expect(broadcastButton).toBeVisible({ timeout: 5000 }) + await broadcastButton.click() + await expect(page.getByText('Broadcast a message to all clients')).toBeVisible({ timeout: 5000 }) +} + +export async function waitForRealtimeMessage(page: Page, options?: { timeout?: number }) { + const timeout = options?.timeout ?? 30000 + const gridRow = page.getByRole('row').filter({ hasText: /^\d{4}-\d{2}-\d{2}/ }).first() + await expect(gridRow).toBeVisible({ timeout }) + return gridRow +} + +export async function getMessageCount(page: Page): Promise { + const countText = page.locator('text=/Found \\d+ messages/') + if ((await countText.count()) === 0) { + const noMessages = page.locator('text=No message found yet') + if ((await noMessages.count()) > 0) { + return 0 + } + const maxMessages = page.locator('text=/showing only the latest 100/') + if ((await maxMessages.count()) > 0) { + return 100 + } + return 0 + } + + const text = await countText.textContent() + const match = text?.match(/Found (\d+) messages/) + return match ? parseInt(match[1], 10) : 0 +} diff --git a/supabase/migrations/20260218202528_status_cache.sql b/supabase/migrations/20260218202528_status_cache.sql new file mode 100644 index 0000000000000..49fa7bcdd7fbe --- /dev/null +++ b/supabase/migrations/20260218202528_status_cache.sql @@ -0,0 +1,35 @@ +create table if not exists public.incident_status_cache ( + id bigint primary key generated always as identity, + incident_id text unique not null, + shortlink text unique not null, + updated_at timestamptz not null default now(), + affects_project_creation boolean not null default false, + affected_regions text[] +); + +alter table public.incident_status_cache +enable row level security; + +revoke all on public.incident_status_cache from anon; +revoke all on public.incident_status_cache from authenticated; + +create index if not exists idx_incident_status_cache_incident_id +on public.incident_status_cache (incident_id); + +create index if not exists idx_incident_status_cache_shortlink +on public.incident_status_cache (shortlink); + +create or replace function public.set_updated_at() +returns trigger +language plpgsql +as $$ +begin + new.updated_at = now(); + return new; +end; +$$; + +create trigger set_incident_status_cache_updated_at +before update on public.incident_status_cache +for each row +execute function public.set_updated_at();