mirror of
https://github.com/supabase/supabase.git
synced 2026-10-11 20:35:07 +03:00
Merge branch 'dnywh/chore/polish-database-settings' into dnywh/temp/jit-prototype
This commit is contained in:
commit
f588b1e720
394 files changed
+62650
-50881
No files matched your search
@@ -0,0 +1,103 @@
|
||||
---
|
||||
name: studio-testing
|
||||
description: Testing strategy for Supabase Studio. Use when writing tests, deciding what
|
||||
type of test to write, extracting logic from components into testable utility
|
||||
functions, or reviewing test coverage. Covers unit tests, component tests,
|
||||
and E2E test selection criteria.
|
||||
---
|
||||
|
||||
# Studio Testing Strategy
|
||||
|
||||
How to write and structure tests for `apps/studio/`. The core principle: push
|
||||
logic out of React components into pure utility functions, then test those
|
||||
functions exhaustively. Only use component tests for complex UI interactions.
|
||||
Use E2E tests for features shared between self-hosted and platform.
|
||||
|
||||
## When to Apply
|
||||
|
||||
Reference these guidelines when:
|
||||
|
||||
- Writing new tests for Studio code
|
||||
- Deciding which type of test to write (unit, component, E2E)
|
||||
- Extracting logic from a component to make it testable
|
||||
- Reviewing whether test coverage is sufficient
|
||||
- Adding a new feature that needs tests
|
||||
|
||||
## Rule Categories by Priority
|
||||
|
||||
| Priority | Category | Impact | Prefix |
|
||||
| -------- | ---------------- | -------- | ---------- |
|
||||
| 1 | Logic Extraction | CRITICAL | `testing-` |
|
||||
| 2 | Test Coverage | CRITICAL | `testing-` |
|
||||
| 3 | Component Tests | HIGH | `testing-` |
|
||||
| 4 | E2E Tests | HIGH | `testing-` |
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### 1. Logic Extraction (CRITICAL)
|
||||
|
||||
- `testing-extract-logic` - Remove logic from components into `.utils.ts` files
|
||||
as pure functions: args in, return out
|
||||
|
||||
### 2. Test Coverage (CRITICAL)
|
||||
|
||||
- `testing-exhaustive-permutations` - Test every permutation of utility functions:
|
||||
happy path, malformed input, empty values, edge cases
|
||||
|
||||
### 3. Component Tests (HIGH)
|
||||
|
||||
- `testing-component-tests-ui-only` - Only write component tests for complex UI
|
||||
interaction logic, not business logic
|
||||
|
||||
### 4. E2E Tests (HIGH)
|
||||
|
||||
- `testing-e2e-shared-features` - Write E2E tests for features used in both
|
||||
self-hosted and platform; cover clicks AND keyboard shortcuts
|
||||
|
||||
## Decision Tree: Which Test Type?
|
||||
|
||||
```
|
||||
Is the logic a pure transformation (parse, format, validate, compute)?
|
||||
YES -> Extract to .utils.ts, write unit test with vitest
|
||||
NO -> Does the feature involve complex UI interactions?
|
||||
YES -> Is it used in both self-hosted and platform?
|
||||
YES -> Write E2E test in e2e/studio/features/
|
||||
NO -> Write component test with customRender
|
||||
NO -> Can you extract the logic to make it pure?
|
||||
YES -> Do that, then unit test it
|
||||
NO -> Write a component test
|
||||
```
|
||||
|
||||
## How to Use
|
||||
|
||||
Read individual rule files for detailed explanations and code examples:
|
||||
|
||||
```
|
||||
rules/testing-extract-logic.md
|
||||
rules/testing-exhaustive-permutations.md
|
||||
```
|
||||
|
||||
Each rule file contains:
|
||||
|
||||
- Brief explanation of why it matters
|
||||
- Incorrect code example with explanation
|
||||
- Correct code example with explanation
|
||||
- Real codebase references
|
||||
|
||||
## Full Compiled Document
|
||||
|
||||
For the complete guide with all rules expanded: `AGENTS.md`
|
||||
|
||||
## Codebase References
|
||||
|
||||
| What | Where |
|
||||
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Util test examples | `tests/components/Grid/Grid.utils.test.ts`, `tests/components/Billing/TaxID.utils.test.ts`, `tests/components/Editor/SpreadsheetImport.utils.test.ts` |
|
||||
| Component test examples | `tests/features/logs/LogsFilterPopover.test.tsx`, `tests/components/CopyButton.test.tsx` |
|
||||
| E2E test example | `e2e/studio/features/filter-bar.spec.ts` |
|
||||
| E2E helpers pattern | `e2e/studio/utils/filter-bar-helpers.ts` |
|
||||
| Custom render | `tests/lib/custom-render.tsx` |
|
||||
| MSW mock setup | `tests/lib/msw.ts` (`addAPIMock`) |
|
||||
| Test README | `tests/README.md` |
|
||||
| Vitest config | `vitest.config.ts` |
|
||||
| Related skills | `e2e-studio-tests` (running E2E), `vitest` (API reference), `vercel-composition-patterns` (component architecture) |
|
||||
@@ -0,0 +1,86 @@
|
||||
---
|
||||
title: Component Tests Are for Complex UI Logic Only
|
||||
impact: HIGH
|
||||
impactDescription: prevents slow, brittle tests that should be unit tests
|
||||
tags: testing, components, ui, react
|
||||
---
|
||||
|
||||
## Component Tests Are for Complex UI Logic Only
|
||||
|
||||
Only write component tests (`.test.tsx`) when there is complex UI interaction
|
||||
logic that cannot be captured by testing utility functions alone.
|
||||
|
||||
**Valid reasons for a component test:**
|
||||
|
||||
- Conditional rendering based on user interaction sequences
|
||||
- Popover/dropdown open/close behavior with keyboard and mouse
|
||||
- Form state transitions across multiple steps
|
||||
- Components that coordinate multiple async operations visually
|
||||
|
||||
**Not a valid reason:** testing a calculation, transformation, parsing, or
|
||||
validation that happens to live inside a component. Extract that logic into a
|
||||
`.utils.ts` file and unit test it instead.
|
||||
|
||||
**Incorrect (rendering a component just to test logic):**
|
||||
|
||||
```tsx
|
||||
test('formats the display value correctly', () => {
|
||||
render(<PriceDisplay amount={1234} currency="USD" />)
|
||||
expect(screen.getByText('$12.34')).toBeInTheDocument()
|
||||
})
|
||||
```
|
||||
|
||||
This is really testing a formatting function. Extract it:
|
||||
|
||||
```ts
|
||||
// PriceDisplay.utils.ts
|
||||
export function formatPrice(amount: number, currency: string): string { ... }
|
||||
|
||||
// PriceDisplay.utils.test.ts
|
||||
test('formats USD cents to dollars', () => {
|
||||
expect(formatPrice(1234, 'USD')).toBe('$12.34')
|
||||
})
|
||||
```
|
||||
|
||||
**Correct (component test for real UI interaction logic):**
|
||||
|
||||
```tsx
|
||||
// Testing popover open/close, filter application, keyboard dismiss
|
||||
describe('LogsFilterPopover', () => {
|
||||
test('opens popover and shows filter options', async () => {
|
||||
customRender(<LogsFilterPopover onFiltersChange={vi.fn()} />)
|
||||
await userEvent.click(screen.getByRole('button'))
|
||||
expect(screen.getByText('Apply')).toBeVisible()
|
||||
})
|
||||
|
||||
test('applies selected filters on submit', async () => {
|
||||
const onChange = vi.fn()
|
||||
customRender(<LogsFilterPopover onFiltersChange={onChange} />)
|
||||
// ... interact with UI ...
|
||||
await userEvent.click(screen.getByText('Apply'))
|
||||
expect(onChange).toHaveBeenCalledWith(expectedFilters)
|
||||
})
|
||||
|
||||
test('closes on Escape key', async () => {
|
||||
customRender(<LogsFilterPopover onFiltersChange={vi.fn()} />)
|
||||
await userEvent.click(screen.getByRole('button'))
|
||||
await userEvent.keyboard('{Escape}')
|
||||
expect(screen.queryByText('Apply')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
**Studio component test conventions:**
|
||||
|
||||
```tsx
|
||||
// Always use customRender, not raw render
|
||||
import { fireEvent } from '@testing-library/react'
|
||||
// Use userEvent for popovers, fireEvent for dropdowns
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { customRender } from 'tests/lib/custom-render'
|
||||
// Use addAPIMock for API mocking in beforeEach
|
||||
import { addAPIMock } from 'tests/lib/msw'
|
||||
```
|
||||
|
||||
See `tests/README.md` for full conventions on custom render, MSW mocking,
|
||||
and nuqs URL parameter testing.
|
||||
@@ -0,0 +1,98 @@
|
||||
---
|
||||
title: E2E Tests for Self-Hosted and Platform Features
|
||||
impact: HIGH
|
||||
impactDescription: ensures critical shared features work across deployment targets
|
||||
tags: testing, e2e, playwright, self-hosted, platform
|
||||
---
|
||||
|
||||
## E2E Tests for Self-Hosted and Platform Features
|
||||
|
||||
If a feature exists in both self-hosted and the Supabase platform, create an
|
||||
E2E test to cover it. E2E tests live in `e2e/studio/features/*.spec.ts`.
|
||||
|
||||
**What to cover in E2E tests:**
|
||||
|
||||
- Mouse/click interactions AND keyboard shortcuts (Tab, Enter, Escape, Arrow keys)
|
||||
- Full user flows end-to-end
|
||||
- Both adding and removing/clearing state
|
||||
- Setup and teardown (create resources in `try`, clean up in `finally`)
|
||||
|
||||
**Incorrect (only tests mouse clicks):**
|
||||
|
||||
```ts
|
||||
test('can add a filter', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Add filter' }).click()
|
||||
await page.getByRole('option', { name: 'id' }).click()
|
||||
// ... only click-based interactions
|
||||
})
|
||||
```
|
||||
|
||||
**Correct (covers clicks AND keyboard shortcuts):**
|
||||
|
||||
```ts
|
||||
test.describe('Basic Filter Operations', () => {
|
||||
test('can add a filter by clicking', async ({ page }) => {
|
||||
await addFilter(page, ref, 'id', 'equals', '1')
|
||||
await expect(page.getByTestId('filter-condition')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Keyboard Navigation - Freeform Input', () => {
|
||||
test('Enter selects column from suggestions', async ({ page }) => {
|
||||
await getFilterBarInput(page).press('Enter')
|
||||
await expect(page.getByTestId('operator-input')).toBeFocused()
|
||||
})
|
||||
|
||||
test('Backspace on empty input highlights last condition', async ({ page }) => {
|
||||
await addFilter(page, ref, 'id', 'equals', '1')
|
||||
await getFilterBarInput(page).press('Backspace')
|
||||
await expect(page.getByTestId('filter-condition')).toHaveAttribute('data-highlighted', 'true')
|
||||
})
|
||||
|
||||
test('Escape clears highlight', async ({ page }) => {
|
||||
// ...
|
||||
await getFilterBarInput(page).press('Escape')
|
||||
await expect(page.getByTestId('filter-condition')).toHaveAttribute('data-highlighted', 'false')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
**E2E helper pattern:** Extract reusable interactions into helper files at
|
||||
`e2e/studio/utils/*-helpers.ts`:
|
||||
|
||||
```ts
|
||||
// e2e/studio/utils/filter-bar-helpers.ts
|
||||
export async function addFilter(page, ref, column, operator, value) {
|
||||
await selectColumnFilter(page, column)
|
||||
await selectOperator(page, column, operator)
|
||||
// ... fill value, wait for API response
|
||||
}
|
||||
|
||||
export async function setupFilterBarPage(page, ref, editorUrl) {
|
||||
await page.goto(editorUrl)
|
||||
await enableFilterBar(page)
|
||||
await page.reload()
|
||||
}
|
||||
```
|
||||
|
||||
This keeps spec files focused on assertions while helpers handle the
|
||||
interaction mechanics.
|
||||
|
||||
**Always use try/finally for resource cleanup:**
|
||||
|
||||
```ts
|
||||
test('filters the table', async ({ page, ref }) => {
|
||||
const tableName = await createTable(page, ref)
|
||||
try {
|
||||
await setupFilterBarPage(page, ref, editorUrl)
|
||||
await navigateToTable(page, ref, tableName)
|
||||
await addFilter(page, ref, 'id', 'equals', '1')
|
||||
// assertions...
|
||||
} finally {
|
||||
await dropTable(page, ref, tableName)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
For E2E execution details (running tests, selectors, debugging), use the
|
||||
`e2e-studio-tests` skill.
|
||||
@@ -0,0 +1,84 @@
|
||||
---
|
||||
title: Test Every Permutation of Utility Functions
|
||||
impact: CRITICAL
|
||||
impactDescription: catches edge cases and regressions in business logic
|
||||
tags: testing, utils, coverage, permutations
|
||||
---
|
||||
|
||||
## Test Every Permutation of Utility Functions
|
||||
|
||||
Once logic is extracted into a pure function, test it exhaustively. Every code
|
||||
path should have a test. Don't just test the happy path.
|
||||
|
||||
**What to cover:**
|
||||
|
||||
- Valid inputs (happy path for each branch)
|
||||
- Invalid / malformed inputs
|
||||
- Empty values, null values, missing fields
|
||||
- Edge cases (timestamps with colons, special characters, boundary values)
|
||||
- Security-sensitive inputs (XSS payloads, external URLs) where relevant
|
||||
|
||||
**Incorrect (only tests the happy path):**
|
||||
|
||||
```ts
|
||||
describe('formatFilterURLParams', () => {
|
||||
test('parses a filter', () => {
|
||||
const result = formatFilterURLParams('id:gte:20')
|
||||
expect(result).toStrictEqual({ column: 'id', operator: 'gte', value: '20' })
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
**Correct (tests every permutation):**
|
||||
|
||||
```ts
|
||||
describe('formatFilterURLParams', () => {
|
||||
test('parses valid filter', () => {
|
||||
const result = formatFilterURLParams('id:gte:20')
|
||||
expect(result).toStrictEqual({ column: 'id', operator: 'gte', value: '20' })
|
||||
})
|
||||
|
||||
test('handles timestamp with colons in value', () => {
|
||||
const result = formatFilterURLParams('created:gte:2024-01-01T00:00:00')
|
||||
expect(result).toStrictEqual({
|
||||
column: 'created',
|
||||
operator: 'gte',
|
||||
value: '2024-01-01T00:00:00',
|
||||
})
|
||||
})
|
||||
|
||||
test('rejects malformed filter with missing parts', () => {
|
||||
const result = formatFilterURLParams('id')
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
test('rejects unrecognized operator', () => {
|
||||
const result = formatFilterURLParams('id:nope:20')
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
test('allows empty filter value', () => {
|
||||
const result = formatFilterURLParams('name:eq:')
|
||||
expect(result).toStrictEqual({ column: 'name', operator: 'eq', value: '' })
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
**Another real example -- `inferColumnType` tests every data type:**
|
||||
|
||||
```ts
|
||||
describe('inferColumnType', () => {
|
||||
test('defaults to text for empty data', () => { ... })
|
||||
test('defaults to text for missing column', () => { ... })
|
||||
test('defaults to text for null values', () => { ... })
|
||||
test('detects integer', () => { ... }) // "42" -> int8
|
||||
test('detects float', () => { ... }) // "161.72" -> float8
|
||||
test('detects boolean', () => { ... }) // "true"/"false" -> bool
|
||||
test('detects boolean with nulls', () => { ... })
|
||||
test('detects JSON object', () => { ... }) // "{}" -> jsonb
|
||||
test('detects timestamp', () => { ... }) // multiple formats -> timestamptz
|
||||
})
|
||||
```
|
||||
|
||||
The goal: if someone changes the function, at least one test should break for
|
||||
any behavioral change.
|
||||
@@ -0,0 +1,94 @@
|
||||
---
|
||||
title: Extract Logic Into Utility Files
|
||||
impact: CRITICAL
|
||||
impactDescription: makes business logic trivially testable without rendering components
|
||||
tags: testing, utils, extraction, pure-functions
|
||||
---
|
||||
|
||||
## Extract Logic Into Utility Files
|
||||
|
||||
Remove as much logic from components as possible. Put it in co-located
|
||||
`.utils.ts` files as pure functions: arguments in, return value out. No React
|
||||
hooks, no context, no side effects.
|
||||
|
||||
**File naming convention:**
|
||||
|
||||
- Utility file: `ComponentName.utils.ts` next to the component
|
||||
- Test file: `tests/components/.../ComponentName.utils.test.ts` mirroring the source path
|
||||
- Or under `tests/unit/` for non-component utilities
|
||||
|
||||
**Incorrect (logic buried inside a component):**
|
||||
|
||||
```tsx
|
||||
// components/Billing/TaxIdForm.tsx
|
||||
function TaxIdForm({ taxIdValue, taxIdName }: Props) {
|
||||
const handleSubmit = () => {
|
||||
// Logic buried in the component -- hard to test without rendering
|
||||
const taxId = TAX_IDS.find((t) => t.name === taxIdName)
|
||||
let sanitized = taxIdValue
|
||||
if (taxId?.vatPrefix && !taxIdValue.startsWith(taxId.vatPrefix)) {
|
||||
sanitized = taxId.vatPrefix + taxIdValue
|
||||
}
|
||||
submitToApi(sanitized)
|
||||
}
|
||||
|
||||
return <form onSubmit={handleSubmit}>...</form>
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (logic extracted to a utility file):**
|
||||
|
||||
```ts
|
||||
// components/Billing/TaxID.utils.ts
|
||||
import { TAX_IDS } from './TaxID.constants'
|
||||
|
||||
// Pure function: args in, return out
|
||||
export function sanitizeTaxIdValue({ value, name }: { value: string; name: string }): string {
|
||||
const taxId = TAX_IDS.find((t) => t.name === name)
|
||||
if (taxId?.vatPrefix && !value.startsWith(taxId.vatPrefix)) {
|
||||
return taxId.vatPrefix + value
|
||||
}
|
||||
return value
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// components/Billing/TaxIdForm.tsx
|
||||
import { sanitizeTaxIdValue } from './TaxID.utils'
|
||||
|
||||
function TaxIdForm({ taxIdValue, taxIdName }: Props) {
|
||||
const handleSubmit = () => {
|
||||
const sanitized = sanitizeTaxIdValue({ value: taxIdValue, name: taxIdName })
|
||||
submitToApi(sanitized)
|
||||
}
|
||||
return <form onSubmit={handleSubmit}>...</form>
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
// tests/components/Billing/TaxID.utils.test.ts
|
||||
import { sanitizeTaxIdValue } from 'components/.../TaxID.utils'
|
||||
|
||||
describe('sanitizeTaxIdValue', () => {
|
||||
test('prefixes unprefixed EU tax ID', () => {
|
||||
expect(sanitizeTaxIdValue({ value: '12345678', name: 'AT VAT' })).toBe('ATU12345678')
|
||||
})
|
||||
|
||||
test('passes through already-prefixed EU tax ID', () => {
|
||||
expect(sanitizeTaxIdValue({ value: 'ATU12345678', name: 'AT VAT' })).toBe('ATU12345678')
|
||||
})
|
||||
|
||||
test('passes through non-EU tax ID unchanged', () => {
|
||||
expect(sanitizeTaxIdValue({ value: '12-3456789', name: 'US EIN' })).toBe('12-3456789')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
The component becomes a thin shell that calls the utility. All business logic
|
||||
is testable without rendering anything.
|
||||
|
||||
**Real codebase examples:**
|
||||
|
||||
- `components/grid/SupabaseGrid.utils.ts` -- URL param parsing, used by 15+ components
|
||||
- `components/.../SpreadsheetImport/SpreadsheetImport.utils.tsx` -- CSV parsing, column type inference
|
||||
- `components/.../BillingCustomerData/TaxID.utils.ts` -- tax ID sanitization and comparison
|
||||
@@ -0,0 +1,946 @@
|
||||
# React Composition Patterns
|
||||
|
||||
**Version 1.0.0**
|
||||
Engineering
|
||||
January 2026
|
||||
|
||||
> **Note:**
|
||||
> This document is mainly for agents and LLMs to follow when maintaining,
|
||||
> generating, or refactoring React codebases using composition. Humans
|
||||
> may also find it useful, but guidance here is optimized for automation
|
||||
> and consistency by AI-assisted workflows.
|
||||
|
||||
---
|
||||
|
||||
## Abstract
|
||||
|
||||
Composition patterns for building flexible, maintainable React components. Avoid boolean prop proliferation by using compound components, lifting state, and composing internals. These patterns make codebases easier for both humans and AI agents to work with as they scale.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Component Architecture](#1-component-architecture) — **HIGH**
|
||||
- 1.1 [Avoid Boolean Prop Proliferation](#11-avoid-boolean-prop-proliferation)
|
||||
- 1.2 [Use Compound Components](#12-use-compound-components)
|
||||
2. [State Management](#2-state-management) — **MEDIUM**
|
||||
- 2.1 [Decouple State Management from UI](#21-decouple-state-management-from-ui)
|
||||
- 2.2 [Define Generic Context Interfaces for Dependency Injection](#22-define-generic-context-interfaces-for-dependency-injection)
|
||||
- 2.3 [Lift State into Provider Components](#23-lift-state-into-provider-components)
|
||||
3. [Implementation Patterns](#3-implementation-patterns) — **MEDIUM**
|
||||
- 3.1 [Create Explicit Component Variants](#31-create-explicit-component-variants)
|
||||
- 3.2 [Prefer Composing Children Over Render Props](#32-prefer-composing-children-over-render-props)
|
||||
4. [React 19 APIs](#4-react-19-apis) — **MEDIUM**
|
||||
- 4.1 [React 19 API Changes](#41-react-19-api-changes)
|
||||
|
||||
---
|
||||
|
||||
## 1. Component Architecture
|
||||
|
||||
**Impact: HIGH**
|
||||
|
||||
Fundamental patterns for structuring components to avoid prop
|
||||
proliferation and enable flexible composition.
|
||||
|
||||
### 1.1 Avoid Boolean Prop Proliferation
|
||||
|
||||
**Impact: CRITICAL (prevents unmaintainable component variants)**
|
||||
|
||||
Don't add boolean props like `isThread`, `isEditing`, `isDMThread` to customize
|
||||
|
||||
component behavior. Each boolean doubles possible states and creates
|
||||
|
||||
unmaintainable conditional logic. Use composition instead.
|
||||
|
||||
**Incorrect: boolean props create exponential complexity**
|
||||
|
||||
```tsx
|
||||
function Composer({
|
||||
onSubmit,
|
||||
isThread,
|
||||
channelId,
|
||||
isDMThread,
|
||||
dmId,
|
||||
isEditing,
|
||||
isForwarding,
|
||||
}: Props) {
|
||||
return (
|
||||
<form>
|
||||
<Header />
|
||||
<Input />
|
||||
{isDMThread ? (
|
||||
<AlsoSendToDMField id={dmId} />
|
||||
) : isThread ? (
|
||||
<AlsoSendToChannelField id={channelId} />
|
||||
) : null}
|
||||
{isEditing ? (
|
||||
<EditActions />
|
||||
) : isForwarding ? (
|
||||
<ForwardActions />
|
||||
) : (
|
||||
<DefaultActions />
|
||||
)}
|
||||
<Footer onSubmit={onSubmit} />
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Correct: composition eliminates conditionals**
|
||||
|
||||
```tsx
|
||||
// Channel composer
|
||||
function ChannelComposer() {
|
||||
return (
|
||||
<Composer.Frame>
|
||||
<Composer.Header />
|
||||
<Composer.Input />
|
||||
<Composer.Footer>
|
||||
<Composer.Attachments />
|
||||
<Composer.Formatting />
|
||||
<Composer.Emojis />
|
||||
<Composer.Submit />
|
||||
</Composer.Footer>
|
||||
</Composer.Frame>
|
||||
)
|
||||
}
|
||||
|
||||
// Thread composer - adds "also send to channel" field
|
||||
function ThreadComposer({ channelId }: { channelId: string }) {
|
||||
return (
|
||||
<Composer.Frame>
|
||||
<Composer.Header />
|
||||
<Composer.Input />
|
||||
<AlsoSendToChannelField id={channelId} />
|
||||
<Composer.Footer>
|
||||
<Composer.Formatting />
|
||||
<Composer.Emojis />
|
||||
<Composer.Submit />
|
||||
</Composer.Footer>
|
||||
</Composer.Frame>
|
||||
)
|
||||
}
|
||||
|
||||
// Edit composer - different footer actions
|
||||
function EditComposer() {
|
||||
return (
|
||||
<Composer.Frame>
|
||||
<Composer.Input />
|
||||
<Composer.Footer>
|
||||
<Composer.Formatting />
|
||||
<Composer.Emojis />
|
||||
<Composer.CancelEdit />
|
||||
<Composer.SaveEdit />
|
||||
</Composer.Footer>
|
||||
</Composer.Frame>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Each variant is explicit about what it renders. We can share internals without
|
||||
|
||||
sharing a single monolithic parent.
|
||||
|
||||
### 1.2 Use Compound Components
|
||||
|
||||
**Impact: HIGH (enables flexible composition without prop drilling)**
|
||||
|
||||
Structure complex components as compound components with a shared context. Each
|
||||
|
||||
subcomponent accesses shared state via context, not props. Consumers compose the
|
||||
|
||||
pieces they need.
|
||||
|
||||
**Incorrect: monolithic component with render props**
|
||||
|
||||
```tsx
|
||||
function Composer({
|
||||
renderHeader,
|
||||
renderFooter,
|
||||
renderActions,
|
||||
showAttachments,
|
||||
showFormatting,
|
||||
showEmojis,
|
||||
}: Props) {
|
||||
return (
|
||||
<form>
|
||||
{renderHeader?.()}
|
||||
<Input />
|
||||
{showAttachments && <Attachments />}
|
||||
{renderFooter ? (
|
||||
renderFooter()
|
||||
) : (
|
||||
<Footer>
|
||||
{showFormatting && <Formatting />}
|
||||
{showEmojis && <Emojis />}
|
||||
{renderActions?.()}
|
||||
</Footer>
|
||||
)}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Correct: compound components with shared context**
|
||||
|
||||
```tsx
|
||||
const ComposerContext = createContext<ComposerContextValue | null>(null)
|
||||
|
||||
function ComposerProvider({ children, state, actions, meta }: ProviderProps) {
|
||||
return (
|
||||
<ComposerContext value={{ state, actions, meta }}>
|
||||
{children}
|
||||
</ComposerContext>
|
||||
)
|
||||
}
|
||||
|
||||
function ComposerFrame({ children }: { children: React.ReactNode }) {
|
||||
return <form>{children}</form>
|
||||
}
|
||||
|
||||
function ComposerInput() {
|
||||
const {
|
||||
state,
|
||||
actions: { update },
|
||||
meta: { inputRef },
|
||||
} = use(ComposerContext)
|
||||
return (
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
value={state.input}
|
||||
onChangeText={(text) => update((s) => ({ ...s, input: text }))}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ComposerSubmit() {
|
||||
const {
|
||||
actions: { submit },
|
||||
} = use(ComposerContext)
|
||||
return <Button onPress={submit}>Send</Button>
|
||||
}
|
||||
|
||||
// Export as compound component
|
||||
const Composer = {
|
||||
Provider: ComposerProvider,
|
||||
Frame: ComposerFrame,
|
||||
Input: ComposerInput,
|
||||
Submit: ComposerSubmit,
|
||||
Header: ComposerHeader,
|
||||
Footer: ComposerFooter,
|
||||
Attachments: ComposerAttachments,
|
||||
Formatting: ComposerFormatting,
|
||||
Emojis: ComposerEmojis,
|
||||
}
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
|
||||
```tsx
|
||||
<Composer.Provider state={state} actions={actions} meta={meta}>
|
||||
<Composer.Frame>
|
||||
<Composer.Header />
|
||||
<Composer.Input />
|
||||
<Composer.Footer>
|
||||
<Composer.Formatting />
|
||||
<Composer.Submit />
|
||||
</Composer.Footer>
|
||||
</Composer.Frame>
|
||||
</Composer.Provider>
|
||||
```
|
||||
|
||||
Consumers explicitly compose exactly what they need. No hidden conditionals. And the state, actions and meta are dependency-injected by a parent provider, allowing multiple usages of the same component structure.
|
||||
|
||||
---
|
||||
|
||||
## 2. State Management
|
||||
|
||||
**Impact: MEDIUM**
|
||||
|
||||
Patterns for lifting state and managing shared context across
|
||||
composed components.
|
||||
|
||||
### 2.1 Decouple State Management from UI
|
||||
|
||||
**Impact: MEDIUM (enables swapping state implementations without changing UI)**
|
||||
|
||||
The provider component should be the only place that knows how state is managed.
|
||||
|
||||
UI components consume the context interface—they don't know if state comes from
|
||||
|
||||
useState, Zustand, or a server sync.
|
||||
|
||||
**Incorrect: UI coupled to state implementation**
|
||||
|
||||
```tsx
|
||||
function ChannelComposer({ channelId }: { channelId: string }) {
|
||||
// UI component knows about global state implementation
|
||||
const state = useGlobalChannelState(channelId)
|
||||
const { submit, updateInput } = useChannelSync(channelId)
|
||||
|
||||
return (
|
||||
<Composer.Frame>
|
||||
<Composer.Input
|
||||
value={state.input}
|
||||
onChange={(text) => sync.updateInput(text)}
|
||||
/>
|
||||
<Composer.Submit onPress={() => sync.submit()} />
|
||||
</Composer.Frame>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Correct: state management isolated in provider**
|
||||
|
||||
```tsx
|
||||
// Provider handles all state management details
|
||||
function ChannelProvider({
|
||||
channelId,
|
||||
children,
|
||||
}: {
|
||||
channelId: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const { state, update, submit } = useGlobalChannel(channelId)
|
||||
const inputRef = useRef(null)
|
||||
|
||||
return (
|
||||
<Composer.Provider
|
||||
state={state}
|
||||
actions={{ update, submit }}
|
||||
meta={{ inputRef }}
|
||||
>
|
||||
{children}
|
||||
</Composer.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// UI component only knows about the context interface
|
||||
function ChannelComposer() {
|
||||
return (
|
||||
<Composer.Frame>
|
||||
<Composer.Header />
|
||||
<Composer.Input />
|
||||
<Composer.Footer>
|
||||
<Composer.Submit />
|
||||
</Composer.Footer>
|
||||
</Composer.Frame>
|
||||
)
|
||||
}
|
||||
|
||||
// Usage
|
||||
function Channel({ channelId }: { channelId: string }) {
|
||||
return (
|
||||
<ChannelProvider channelId={channelId}>
|
||||
<ChannelComposer />
|
||||
</ChannelProvider>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Different providers, same UI:**
|
||||
|
||||
```tsx
|
||||
// Local state for ephemeral forms
|
||||
function ForwardMessageProvider({ children }) {
|
||||
const [state, setState] = useState(initialState)
|
||||
const forwardMessage = useForwardMessage()
|
||||
|
||||
return (
|
||||
<Composer.Provider
|
||||
state={state}
|
||||
actions={{ update: setState, submit: forwardMessage }}
|
||||
>
|
||||
{children}
|
||||
</Composer.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// Global synced state for channels
|
||||
function ChannelProvider({ channelId, children }) {
|
||||
const { state, update, submit } = useGlobalChannel(channelId)
|
||||
|
||||
return (
|
||||
<Composer.Provider state={state} actions={{ update, submit }}>
|
||||
{children}
|
||||
</Composer.Provider>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
The same `Composer.Input` component works with both providers because it only
|
||||
|
||||
depends on the context interface, not the implementation.
|
||||
|
||||
### 2.2 Define Generic Context Interfaces for Dependency Injection
|
||||
|
||||
**Impact: HIGH (enables dependency-injectable state across use-cases)**
|
||||
|
||||
Define a **generic interface** for your component context with three parts:
|
||||
|
||||
`state`, `actions`, and `meta`. This interface is a contract that any provider
|
||||
|
||||
can implement—enabling the same UI components to work with completely different
|
||||
|
||||
state implementations.
|
||||
|
||||
**Core principle:** Lift state, compose internals, make state
|
||||
|
||||
dependency-injectable.
|
||||
|
||||
**Incorrect: UI coupled to specific state implementation**
|
||||
|
||||
```tsx
|
||||
function ComposerInput() {
|
||||
// Tightly coupled to a specific hook
|
||||
const { input, setInput } = useChannelComposerState()
|
||||
return <TextInput value={input} onChangeText={setInput} />
|
||||
}
|
||||
```
|
||||
|
||||
**Correct: generic interface enables dependency injection**
|
||||
|
||||
```tsx
|
||||
// Define a GENERIC interface that any provider can implement
|
||||
interface ComposerState {
|
||||
input: string
|
||||
attachments: Attachment[]
|
||||
isSubmitting: boolean
|
||||
}
|
||||
|
||||
interface ComposerActions {
|
||||
update: (updater: (state: ComposerState) => ComposerState) => void
|
||||
submit: () => void
|
||||
}
|
||||
|
||||
interface ComposerMeta {
|
||||
inputRef: React.RefObject<TextInput>
|
||||
}
|
||||
|
||||
interface ComposerContextValue {
|
||||
state: ComposerState
|
||||
actions: ComposerActions
|
||||
meta: ComposerMeta
|
||||
}
|
||||
|
||||
const ComposerContext = createContext<ComposerContextValue | null>(null)
|
||||
```
|
||||
|
||||
**UI components consume the interface, not the implementation:**
|
||||
|
||||
```tsx
|
||||
function ComposerInput() {
|
||||
const {
|
||||
state,
|
||||
actions: { update },
|
||||
meta,
|
||||
} = use(ComposerContext)
|
||||
|
||||
// This component works with ANY provider that implements the interface
|
||||
return (
|
||||
<TextInput
|
||||
ref={meta.inputRef}
|
||||
value={state.input}
|
||||
onChangeText={(text) => update((s) => ({ ...s, input: text }))}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Different providers implement the same interface:**
|
||||
|
||||
```tsx
|
||||
// Provider A: Local state for ephemeral forms
|
||||
function ForwardMessageProvider({ children }: { children: React.ReactNode }) {
|
||||
const [state, setState] = useState(initialState)
|
||||
const inputRef = useRef(null)
|
||||
const submit = useForwardMessage()
|
||||
|
||||
return (
|
||||
<ComposerContext
|
||||
value={{
|
||||
state,
|
||||
actions: { update: setState, submit },
|
||||
meta: { inputRef },
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ComposerContext>
|
||||
)
|
||||
}
|
||||
|
||||
// Provider B: Global synced state for channels
|
||||
function ChannelProvider({ channelId, children }: Props) {
|
||||
const { state, update, submit } = useGlobalChannel(channelId)
|
||||
const inputRef = useRef(null)
|
||||
|
||||
return (
|
||||
<ComposerContext
|
||||
value={{
|
||||
state,
|
||||
actions: { update, submit },
|
||||
meta: { inputRef },
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ComposerContext>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**The same composed UI works with both:**
|
||||
|
||||
```tsx
|
||||
// Works with ForwardMessageProvider (local state)
|
||||
<ForwardMessageProvider>
|
||||
<Composer.Frame>
|
||||
<Composer.Input />
|
||||
<Composer.Submit />
|
||||
</Composer.Frame>
|
||||
</ForwardMessageProvider>
|
||||
|
||||
// Works with ChannelProvider (global synced state)
|
||||
<ChannelProvider channelId="abc">
|
||||
<Composer.Frame>
|
||||
<Composer.Input />
|
||||
<Composer.Submit />
|
||||
</Composer.Frame>
|
||||
</ChannelProvider>
|
||||
```
|
||||
|
||||
**Custom UI outside the component can access state and actions:**
|
||||
|
||||
```tsx
|
||||
function ForwardMessageDialog() {
|
||||
return (
|
||||
<ForwardMessageProvider>
|
||||
<Dialog>
|
||||
{/* The composer UI */}
|
||||
<Composer.Frame>
|
||||
<Composer.Input placeholder="Add a message, if you'd like." />
|
||||
<Composer.Footer>
|
||||
<Composer.Formatting />
|
||||
<Composer.Emojis />
|
||||
</Composer.Footer>
|
||||
</Composer.Frame>
|
||||
|
||||
{/* Custom UI OUTSIDE the composer, but INSIDE the provider */}
|
||||
<MessagePreview />
|
||||
|
||||
{/* Actions at the bottom of the dialog */}
|
||||
<DialogActions>
|
||||
<CancelButton />
|
||||
<ForwardButton />
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</ForwardMessageProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// This button lives OUTSIDE Composer.Frame but can still submit based on its context!
|
||||
function ForwardButton() {
|
||||
const {
|
||||
actions: { submit },
|
||||
} = use(ComposerContext)
|
||||
return <Button onPress={submit}>Forward</Button>
|
||||
}
|
||||
|
||||
// This preview lives OUTSIDE Composer.Frame but can read composer's state!
|
||||
function MessagePreview() {
|
||||
const { state } = use(ComposerContext)
|
||||
return <Preview message={state.input} attachments={state.attachments} />
|
||||
}
|
||||
```
|
||||
|
||||
The provider boundary is what matters—not the visual nesting. Components that
|
||||
|
||||
need shared state don't have to be inside the `Composer.Frame`. They just need
|
||||
|
||||
to be within the provider.
|
||||
|
||||
The `ForwardButton` and `MessagePreview` are not visually inside the composer
|
||||
|
||||
box, but they can still access its state and actions. This is the power of
|
||||
|
||||
lifting state into providers.
|
||||
|
||||
The UI is reusable bits you compose together. The state is dependency-injected
|
||||
|
||||
by the provider. Swap the provider, keep the UI.
|
||||
|
||||
### 2.3 Lift State into Provider Components
|
||||
|
||||
**Impact: HIGH (enables state sharing outside component boundaries)**
|
||||
|
||||
Move state management into dedicated provider components. This allows sibling
|
||||
|
||||
components outside the main UI to access and modify state without prop drilling
|
||||
|
||||
or awkward refs.
|
||||
|
||||
**Incorrect: state trapped inside component**
|
||||
|
||||
```tsx
|
||||
function ForwardMessageComposer() {
|
||||
const [state, setState] = useState(initialState)
|
||||
const forwardMessage = useForwardMessage()
|
||||
|
||||
return (
|
||||
<Composer.Frame>
|
||||
<Composer.Input />
|
||||
<Composer.Footer />
|
||||
</Composer.Frame>
|
||||
)
|
||||
}
|
||||
|
||||
// Problem: How does this button access composer state?
|
||||
function ForwardMessageDialog() {
|
||||
return (
|
||||
<Dialog>
|
||||
<ForwardMessageComposer />
|
||||
<MessagePreview /> {/* Needs composer state */}
|
||||
<DialogActions>
|
||||
<CancelButton />
|
||||
<ForwardButton /> {/* Needs to call submit */}
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Incorrect: useEffect to sync state up**
|
||||
|
||||
```tsx
|
||||
function ForwardMessageDialog() {
|
||||
const [input, setInput] = useState('')
|
||||
return (
|
||||
<Dialog>
|
||||
<ForwardMessageComposer onInputChange={setInput} />
|
||||
<MessagePreview input={input} />
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function ForwardMessageComposer({ onInputChange }) {
|
||||
const [state, setState] = useState(initialState)
|
||||
useEffect(() => {
|
||||
onInputChange(state.input) // Sync on every change 😬
|
||||
}, [state.input])
|
||||
}
|
||||
```
|
||||
|
||||
**Incorrect: reading state from ref on submit**
|
||||
|
||||
```tsx
|
||||
function ForwardMessageDialog() {
|
||||
const stateRef = useRef(null)
|
||||
return (
|
||||
<Dialog>
|
||||
<ForwardMessageComposer stateRef={stateRef} />
|
||||
<ForwardButton onPress={() => submit(stateRef.current)} />
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Correct: state lifted to provider**
|
||||
|
||||
```tsx
|
||||
function ForwardMessageProvider({ children }: { children: React.ReactNode }) {
|
||||
const [state, setState] = useState(initialState)
|
||||
const forwardMessage = useForwardMessage()
|
||||
const inputRef = useRef(null)
|
||||
|
||||
return (
|
||||
<Composer.Provider
|
||||
state={state}
|
||||
actions={{ update: setState, submit: forwardMessage }}
|
||||
meta={{ inputRef }}
|
||||
>
|
||||
{children}
|
||||
</Composer.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function ForwardMessageDialog() {
|
||||
return (
|
||||
<ForwardMessageProvider>
|
||||
<Dialog>
|
||||
<ForwardMessageComposer />
|
||||
<MessagePreview /> {/* Custom components can access state and actions */}
|
||||
<DialogActions>
|
||||
<CancelButton />
|
||||
<ForwardButton /> {/* Custom components can access state and actions */}
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</ForwardMessageProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function ForwardButton() {
|
||||
const { actions } = use(Composer.Context)
|
||||
return <Button onPress={actions.submit}>Forward</Button>
|
||||
}
|
||||
```
|
||||
|
||||
The ForwardButton lives outside the Composer.Frame but still has access to the
|
||||
|
||||
submit action because it's within the provider. Even though it's a one-off
|
||||
|
||||
component, it can still access the composer's state and actions from outside the
|
||||
|
||||
UI itself.
|
||||
|
||||
**Key insight:** Components that need shared state don't have to be visually
|
||||
|
||||
nested inside each other—they just need to be within the same provider.
|
||||
|
||||
---
|
||||
|
||||
## 3. Implementation Patterns
|
||||
|
||||
**Impact: MEDIUM**
|
||||
|
||||
Specific techniques for implementing compound components and
|
||||
context providers.
|
||||
|
||||
### 3.1 Create Explicit Component Variants
|
||||
|
||||
**Impact: MEDIUM (self-documenting code, no hidden conditionals)**
|
||||
|
||||
Instead of one component with many boolean props, create explicit variant
|
||||
|
||||
components. Each variant composes the pieces it needs. The code documents
|
||||
|
||||
itself.
|
||||
|
||||
**Incorrect: one component, many modes**
|
||||
|
||||
```tsx
|
||||
// What does this component actually render?
|
||||
<Composer
|
||||
isThread
|
||||
isEditing={false}
|
||||
channelId='abc'
|
||||
showAttachments
|
||||
showFormatting={false}
|
||||
/>
|
||||
```
|
||||
|
||||
**Correct: explicit variants**
|
||||
|
||||
```tsx
|
||||
// Immediately clear what this renders
|
||||
<ThreadComposer channelId="abc" />
|
||||
|
||||
// Or
|
||||
<EditMessageComposer messageId="xyz" />
|
||||
|
||||
// Or
|
||||
<ForwardMessageComposer messageId="123" />
|
||||
```
|
||||
|
||||
Each implementation is unique, explicit and self-contained. Yet they can each
|
||||
|
||||
use shared parts.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```tsx
|
||||
function ThreadComposer({ channelId }: { channelId: string }) {
|
||||
return (
|
||||
<ThreadProvider channelId={channelId}>
|
||||
<Composer.Frame>
|
||||
<Composer.Input />
|
||||
<AlsoSendToChannelField channelId={channelId} />
|
||||
<Composer.Footer>
|
||||
<Composer.Formatting />
|
||||
<Composer.Emojis />
|
||||
<Composer.Submit />
|
||||
</Composer.Footer>
|
||||
</Composer.Frame>
|
||||
</ThreadProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function EditMessageComposer({ messageId }: { messageId: string }) {
|
||||
return (
|
||||
<EditMessageProvider messageId={messageId}>
|
||||
<Composer.Frame>
|
||||
<Composer.Input />
|
||||
<Composer.Footer>
|
||||
<Composer.Formatting />
|
||||
<Composer.Emojis />
|
||||
<Composer.CancelEdit />
|
||||
<Composer.SaveEdit />
|
||||
</Composer.Footer>
|
||||
</Composer.Frame>
|
||||
</EditMessageProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function ForwardMessageComposer({ messageId }: { messageId: string }) {
|
||||
return (
|
||||
<ForwardMessageProvider messageId={messageId}>
|
||||
<Composer.Frame>
|
||||
<Composer.Input placeholder="Add a message, if you'd like." />
|
||||
<Composer.Footer>
|
||||
<Composer.Formatting />
|
||||
<Composer.Emojis />
|
||||
<Composer.Mentions />
|
||||
</Composer.Footer>
|
||||
</Composer.Frame>
|
||||
</ForwardMessageProvider>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Each variant is explicit about:
|
||||
|
||||
- What provider/state it uses
|
||||
|
||||
- What UI elements it includes
|
||||
|
||||
- What actions are available
|
||||
|
||||
No boolean prop combinations to reason about. No impossible states.
|
||||
|
||||
### 3.2 Prefer Composing Children Over Render Props
|
||||
|
||||
**Impact: MEDIUM (cleaner composition, better readability)**
|
||||
|
||||
Use `children` for composition instead of `renderX` props. Children are more
|
||||
|
||||
readable, compose naturally, and don't require understanding callback
|
||||
|
||||
signatures.
|
||||
|
||||
**Incorrect: render props**
|
||||
|
||||
```tsx
|
||||
function Composer({
|
||||
renderHeader,
|
||||
renderFooter,
|
||||
renderActions,
|
||||
}: {
|
||||
renderHeader?: () => React.ReactNode
|
||||
renderFooter?: () => React.ReactNode
|
||||
renderActions?: () => React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<form>
|
||||
{renderHeader?.()}
|
||||
<Input />
|
||||
{renderFooter ? renderFooter() : <DefaultFooter />}
|
||||
{renderActions?.()}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
// Usage is awkward and inflexible
|
||||
return (
|
||||
<Composer
|
||||
renderHeader={() => <CustomHeader />}
|
||||
renderFooter={() => (
|
||||
<>
|
||||
<Formatting />
|
||||
<Emojis />
|
||||
</>
|
||||
)}
|
||||
renderActions={() => <SubmitButton />}
|
||||
/>
|
||||
)
|
||||
```
|
||||
|
||||
**Correct: compound components with children**
|
||||
|
||||
```tsx
|
||||
function ComposerFrame({ children }: { children: React.ReactNode }) {
|
||||
return <form>{children}</form>
|
||||
}
|
||||
|
||||
function ComposerFooter({ children }: { children: React.ReactNode }) {
|
||||
return <footer className='flex'>{children}</footer>
|
||||
}
|
||||
|
||||
// Usage is flexible
|
||||
return (
|
||||
<Composer.Frame>
|
||||
<CustomHeader />
|
||||
<Composer.Input />
|
||||
<Composer.Footer>
|
||||
<Composer.Formatting />
|
||||
<Composer.Emojis />
|
||||
<SubmitButton />
|
||||
</Composer.Footer>
|
||||
</Composer.Frame>
|
||||
)
|
||||
```
|
||||
|
||||
**When render props are appropriate:**
|
||||
|
||||
```tsx
|
||||
// Render props work well when you need to pass data back
|
||||
<List
|
||||
data={items}
|
||||
renderItem={({ item, index }) => <Item item={item} index={index} />}
|
||||
/>
|
||||
```
|
||||
|
||||
Use render props when the parent needs to provide data or state to the child.
|
||||
|
||||
Use children when composing static structure.
|
||||
|
||||
---
|
||||
|
||||
## 4. React 19 APIs
|
||||
|
||||
**Impact: MEDIUM**
|
||||
|
||||
React 19+ only. Don't use `forwardRef`; use `use()` instead of `useContext()`.
|
||||
|
||||
### 4.1 React 19 API Changes
|
||||
|
||||
**Impact: MEDIUM (cleaner component definitions and context usage)**
|
||||
|
||||
> **⚠️ React 19+ only.** Skip this if you're on React 18 or earlier.
|
||||
|
||||
In React 19, `ref` is now a regular prop (no `forwardRef` wrapper needed), and `use()` replaces `useContext()`.
|
||||
|
||||
**Incorrect: forwardRef in React 19**
|
||||
|
||||
```tsx
|
||||
const ComposerInput = forwardRef<TextInput, Props>((props, ref) => {
|
||||
return <TextInput ref={ref} {...props} />
|
||||
})
|
||||
```
|
||||
|
||||
**Correct: ref as a regular prop**
|
||||
|
||||
```tsx
|
||||
function ComposerInput({ ref, ...props }: Props & { ref?: React.Ref<TextInput> }) {
|
||||
return <TextInput ref={ref} {...props} />
|
||||
}
|
||||
```
|
||||
|
||||
**Incorrect: useContext in React 19**
|
||||
|
||||
```tsx
|
||||
const value = useContext(MyContext)
|
||||
```
|
||||
|
||||
**Correct: use instead of useContext**
|
||||
|
||||
```tsx
|
||||
const value = use(MyContext)
|
||||
```
|
||||
|
||||
`use()` can also be called conditionally, unlike `useContext()`.
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
1. [https://react.dev](https://react.dev)
|
||||
2. [https://react.dev/learn/passing-data-deeply-with-context](https://react.dev/learn/passing-data-deeply-with-context)
|
||||
3. [https://react.dev/reference/react/use](https://react.dev/reference/react/use)
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
name: vercel-composition-patterns
|
||||
description:
|
||||
React composition patterns that scale. Use when refactoring components with
|
||||
boolean prop proliferation, building flexible component libraries, or
|
||||
designing reusable APIs. Triggers on tasks involving compound components,
|
||||
render props, context providers, or component architecture. Includes React 19
|
||||
API changes.
|
||||
license: MIT
|
||||
metadata:
|
||||
author: vercel
|
||||
version: '1.0.0'
|
||||
---
|
||||
|
||||
# React Composition Patterns
|
||||
|
||||
Composition patterns for building flexible, maintainable React components. Avoid
|
||||
boolean prop proliferation by using compound components, lifting state, and
|
||||
composing internals. These patterns make codebases easier for both humans and AI
|
||||
agents to work with as they scale.
|
||||
|
||||
## When to Apply
|
||||
|
||||
Reference these guidelines when:
|
||||
|
||||
- Refactoring components with many boolean props
|
||||
- Building reusable component libraries
|
||||
- Designing flexible component APIs
|
||||
- Reviewing component architecture
|
||||
- Working with compound components or context providers
|
||||
|
||||
## Rule Categories by Priority
|
||||
|
||||
| Priority | Category | Impact | Prefix |
|
||||
| -------- | ----------------------- | ------ | --------------- |
|
||||
| 1 | Component Architecture | HIGH | `architecture-` |
|
||||
| 2 | State Management | MEDIUM | `state-` |
|
||||
| 3 | Implementation Patterns | MEDIUM | `patterns-` |
|
||||
| 4 | React 19 APIs | MEDIUM | `react19-` |
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### 1. Component Architecture (HIGH)
|
||||
|
||||
- `architecture-avoid-boolean-props` - Don't add boolean props to customize
|
||||
behavior; use composition
|
||||
- `architecture-compound-components` - Structure complex components with shared
|
||||
context
|
||||
|
||||
### 2. State Management (MEDIUM)
|
||||
|
||||
- `state-decouple-implementation` - Provider is the only place that knows how
|
||||
state is managed
|
||||
- `state-context-interface` - Define generic interface with state, actions, meta
|
||||
for dependency injection
|
||||
- `state-lift-state` - Move state into provider components for sibling access
|
||||
|
||||
### 3. Implementation Patterns (MEDIUM)
|
||||
|
||||
- `patterns-explicit-variants` - Create explicit variant components instead of
|
||||
boolean modes
|
||||
- `patterns-children-over-render-props` - Use children for composition instead
|
||||
of renderX props
|
||||
|
||||
### 4. React 19 APIs (MEDIUM)
|
||||
|
||||
> **⚠️ React 19+ only.** Skip this section if using React 18 or earlier.
|
||||
|
||||
- `react19-no-forwardref` - Don't use `forwardRef`; use `use()` instead of `useContext()`
|
||||
|
||||
## How to Use
|
||||
|
||||
Read individual rule files for detailed explanations and code examples:
|
||||
|
||||
```
|
||||
rules/architecture-avoid-boolean-props.md
|
||||
rules/state-context-interface.md
|
||||
```
|
||||
|
||||
Each rule file contains:
|
||||
|
||||
- Brief explanation of why it matters
|
||||
- Incorrect code example with explanation
|
||||
- Correct code example with explanation
|
||||
- Additional context and references
|
||||
|
||||
## Full Compiled Document
|
||||
|
||||
For the complete guide with all rules expanded: `AGENTS.md`
|
||||
@@ -0,0 +1,100 @@
|
||||
---
|
||||
title: Avoid Boolean Prop Proliferation
|
||||
impact: CRITICAL
|
||||
impactDescription: prevents unmaintainable component variants
|
||||
tags: composition, props, architecture
|
||||
---
|
||||
|
||||
## Avoid Boolean Prop Proliferation
|
||||
|
||||
Don't add boolean props like `isThread`, `isEditing`, `isDMThread` to customize
|
||||
component behavior. Each boolean doubles possible states and creates
|
||||
unmaintainable conditional logic. Use composition instead.
|
||||
|
||||
**Incorrect (boolean props create exponential complexity):**
|
||||
|
||||
```tsx
|
||||
function Composer({
|
||||
onSubmit,
|
||||
isThread,
|
||||
channelId,
|
||||
isDMThread,
|
||||
dmId,
|
||||
isEditing,
|
||||
isForwarding,
|
||||
}: Props) {
|
||||
return (
|
||||
<form>
|
||||
<Header />
|
||||
<Input />
|
||||
{isDMThread ? (
|
||||
<AlsoSendToDMField id={dmId} />
|
||||
) : isThread ? (
|
||||
<AlsoSendToChannelField id={channelId} />
|
||||
) : null}
|
||||
{isEditing ? (
|
||||
<EditActions />
|
||||
) : isForwarding ? (
|
||||
<ForwardActions />
|
||||
) : (
|
||||
<DefaultActions />
|
||||
)}
|
||||
<Footer onSubmit={onSubmit} />
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (composition eliminates conditionals):**
|
||||
|
||||
```tsx
|
||||
// Channel composer
|
||||
function ChannelComposer() {
|
||||
return (
|
||||
<Composer.Frame>
|
||||
<Composer.Header />
|
||||
<Composer.Input />
|
||||
<Composer.Footer>
|
||||
<Composer.Attachments />
|
||||
<Composer.Formatting />
|
||||
<Composer.Emojis />
|
||||
<Composer.Submit />
|
||||
</Composer.Footer>
|
||||
</Composer.Frame>
|
||||
)
|
||||
}
|
||||
|
||||
// Thread composer - adds "also send to channel" field
|
||||
function ThreadComposer({ channelId }: { channelId: string }) {
|
||||
return (
|
||||
<Composer.Frame>
|
||||
<Composer.Header />
|
||||
<Composer.Input />
|
||||
<AlsoSendToChannelField id={channelId} />
|
||||
<Composer.Footer>
|
||||
<Composer.Formatting />
|
||||
<Composer.Emojis />
|
||||
<Composer.Submit />
|
||||
</Composer.Footer>
|
||||
</Composer.Frame>
|
||||
)
|
||||
}
|
||||
|
||||
// Edit composer - different footer actions
|
||||
function EditComposer() {
|
||||
return (
|
||||
<Composer.Frame>
|
||||
<Composer.Input />
|
||||
<Composer.Footer>
|
||||
<Composer.Formatting />
|
||||
<Composer.Emojis />
|
||||
<Composer.CancelEdit />
|
||||
<Composer.SaveEdit />
|
||||
</Composer.Footer>
|
||||
</Composer.Frame>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Each variant is explicit about what it renders. We can share internals without
|
||||
sharing a single monolithic parent.
|
||||
@@ -0,0 +1,112 @@
|
||||
---
|
||||
title: Use Compound Components
|
||||
impact: HIGH
|
||||
impactDescription: enables flexible composition without prop drilling
|
||||
tags: composition, compound-components, architecture
|
||||
---
|
||||
|
||||
## Use Compound Components
|
||||
|
||||
Structure complex components as compound components with a shared context. Each
|
||||
subcomponent accesses shared state via context, not props. Consumers compose the
|
||||
pieces they need.
|
||||
|
||||
**Incorrect (monolithic component with render props):**
|
||||
|
||||
```tsx
|
||||
function Composer({
|
||||
renderHeader,
|
||||
renderFooter,
|
||||
renderActions,
|
||||
showAttachments,
|
||||
showFormatting,
|
||||
showEmojis,
|
||||
}: Props) {
|
||||
return (
|
||||
<form>
|
||||
{renderHeader?.()}
|
||||
<Input />
|
||||
{showAttachments && <Attachments />}
|
||||
{renderFooter ? (
|
||||
renderFooter()
|
||||
) : (
|
||||
<Footer>
|
||||
{showFormatting && <Formatting />}
|
||||
{showEmojis && <Emojis />}
|
||||
{renderActions?.()}
|
||||
</Footer>
|
||||
)}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (compound components with shared context):**
|
||||
|
||||
```tsx
|
||||
const ComposerContext = createContext<ComposerContextValue | null>(null)
|
||||
|
||||
function ComposerProvider({ children, state, actions, meta }: ProviderProps) {
|
||||
return (
|
||||
<ComposerContext value={{ state, actions, meta }}>
|
||||
{children}
|
||||
</ComposerContext>
|
||||
)
|
||||
}
|
||||
|
||||
function ComposerFrame({ children }: { children: React.ReactNode }) {
|
||||
return <form>{children}</form>
|
||||
}
|
||||
|
||||
function ComposerInput() {
|
||||
const {
|
||||
state,
|
||||
actions: { update },
|
||||
meta: { inputRef },
|
||||
} = use(ComposerContext)
|
||||
return (
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
value={state.input}
|
||||
onChangeText={(text) => update((s) => ({ ...s, input: text }))}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ComposerSubmit() {
|
||||
const {
|
||||
actions: { submit },
|
||||
} = use(ComposerContext)
|
||||
return <Button onPress={submit}>Send</Button>
|
||||
}
|
||||
|
||||
// Export as compound component
|
||||
const Composer = {
|
||||
Provider: ComposerProvider,
|
||||
Frame: ComposerFrame,
|
||||
Input: ComposerInput,
|
||||
Submit: ComposerSubmit,
|
||||
Header: ComposerHeader,
|
||||
Footer: ComposerFooter,
|
||||
Attachments: ComposerAttachments,
|
||||
Formatting: ComposerFormatting,
|
||||
Emojis: ComposerEmojis,
|
||||
}
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
|
||||
```tsx
|
||||
<Composer.Provider state={state} actions={actions} meta={meta}>
|
||||
<Composer.Frame>
|
||||
<Composer.Header />
|
||||
<Composer.Input />
|
||||
<Composer.Footer>
|
||||
<Composer.Formatting />
|
||||
<Composer.Submit />
|
||||
</Composer.Footer>
|
||||
</Composer.Frame>
|
||||
</Composer.Provider>
|
||||
```
|
||||
|
||||
Consumers explicitly compose exactly what they need. No hidden conditionals. And the state, actions and meta are dependency-injected by a parent provider, allowing multiple usages of the same component structure.
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
---
|
||||
title: Prefer Composing Children Over Render Props
|
||||
impact: MEDIUM
|
||||
impactDescription: cleaner composition, better readability
|
||||
tags: composition, children, render-props
|
||||
---
|
||||
|
||||
## Prefer Children Over Render Props
|
||||
|
||||
Use `children` for composition instead of `renderX` props. Children are more
|
||||
readable, compose naturally, and don't require understanding callback
|
||||
signatures.
|
||||
|
||||
**Incorrect (render props):**
|
||||
|
||||
```tsx
|
||||
function Composer({
|
||||
renderHeader,
|
||||
renderFooter,
|
||||
renderActions,
|
||||
}: {
|
||||
renderHeader?: () => React.ReactNode
|
||||
renderFooter?: () => React.ReactNode
|
||||
renderActions?: () => React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<form>
|
||||
{renderHeader?.()}
|
||||
<Input />
|
||||
{renderFooter ? renderFooter() : <DefaultFooter />}
|
||||
{renderActions?.()}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
// Usage is awkward and inflexible
|
||||
return (
|
||||
<Composer
|
||||
renderHeader={() => <CustomHeader />}
|
||||
renderFooter={() => (
|
||||
<>
|
||||
<Formatting />
|
||||
<Emojis />
|
||||
</>
|
||||
)}
|
||||
renderActions={() => <SubmitButton />}
|
||||
/>
|
||||
)
|
||||
```
|
||||
|
||||
**Correct (compound components with children):**
|
||||
|
||||
```tsx
|
||||
function ComposerFrame({ children }: { children: React.ReactNode }) {
|
||||
return <form>{children}</form>
|
||||
}
|
||||
|
||||
function ComposerFooter({ children }: { children: React.ReactNode }) {
|
||||
return <footer className='flex'>{children}</footer>
|
||||
}
|
||||
|
||||
// Usage is flexible
|
||||
return (
|
||||
<Composer.Frame>
|
||||
<CustomHeader />
|
||||
<Composer.Input />
|
||||
<Composer.Footer>
|
||||
<Composer.Formatting />
|
||||
<Composer.Emojis />
|
||||
<SubmitButton />
|
||||
</Composer.Footer>
|
||||
</Composer.Frame>
|
||||
)
|
||||
```
|
||||
|
||||
**When render props are appropriate:**
|
||||
|
||||
```tsx
|
||||
// Render props work well when you need to pass data back
|
||||
<List
|
||||
data={items}
|
||||
renderItem={({ item, index }) => <Item item={item} index={index} />}
|
||||
/>
|
||||
```
|
||||
|
||||
Use render props when the parent needs to provide data or state to the child.
|
||||
Use children when composing static structure.
|
||||
@@ -0,0 +1,100 @@
|
||||
---
|
||||
title: Create Explicit Component Variants
|
||||
impact: MEDIUM
|
||||
impactDescription: self-documenting code, no hidden conditionals
|
||||
tags: composition, variants, architecture
|
||||
---
|
||||
|
||||
## Create Explicit Component Variants
|
||||
|
||||
Instead of one component with many boolean props, create explicit variant
|
||||
components. Each variant composes the pieces it needs. The code documents
|
||||
itself.
|
||||
|
||||
**Incorrect (one component, many modes):**
|
||||
|
||||
```tsx
|
||||
// What does this component actually render?
|
||||
<Composer
|
||||
isThread
|
||||
isEditing={false}
|
||||
channelId='abc'
|
||||
showAttachments
|
||||
showFormatting={false}
|
||||
/>
|
||||
```
|
||||
|
||||
**Correct (explicit variants):**
|
||||
|
||||
```tsx
|
||||
// Immediately clear what this renders
|
||||
<ThreadComposer channelId="abc" />
|
||||
|
||||
// Or
|
||||
<EditMessageComposer messageId="xyz" />
|
||||
|
||||
// Or
|
||||
<ForwardMessageComposer messageId="123" />
|
||||
```
|
||||
|
||||
Each implementation is unique, explicit and self-contained. Yet they can each
|
||||
use shared parts.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```tsx
|
||||
function ThreadComposer({ channelId }: { channelId: string }) {
|
||||
return (
|
||||
<ThreadProvider channelId={channelId}>
|
||||
<Composer.Frame>
|
||||
<Composer.Input />
|
||||
<AlsoSendToChannelField channelId={channelId} />
|
||||
<Composer.Footer>
|
||||
<Composer.Formatting />
|
||||
<Composer.Emojis />
|
||||
<Composer.Submit />
|
||||
</Composer.Footer>
|
||||
</Composer.Frame>
|
||||
</ThreadProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function EditMessageComposer({ messageId }: { messageId: string }) {
|
||||
return (
|
||||
<EditMessageProvider messageId={messageId}>
|
||||
<Composer.Frame>
|
||||
<Composer.Input />
|
||||
<Composer.Footer>
|
||||
<Composer.Formatting />
|
||||
<Composer.Emojis />
|
||||
<Composer.CancelEdit />
|
||||
<Composer.SaveEdit />
|
||||
</Composer.Footer>
|
||||
</Composer.Frame>
|
||||
</EditMessageProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function ForwardMessageComposer({ messageId }: { messageId: string }) {
|
||||
return (
|
||||
<ForwardMessageProvider messageId={messageId}>
|
||||
<Composer.Frame>
|
||||
<Composer.Input placeholder="Add a message, if you'd like." />
|
||||
<Composer.Footer>
|
||||
<Composer.Formatting />
|
||||
<Composer.Emojis />
|
||||
<Composer.Mentions />
|
||||
</Composer.Footer>
|
||||
</Composer.Frame>
|
||||
</ForwardMessageProvider>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Each variant is explicit about:
|
||||
|
||||
- What provider/state it uses
|
||||
- What UI elements it includes
|
||||
- What actions are available
|
||||
|
||||
No boolean prop combinations to reason about. No impossible states.
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
title: React 19 API Changes
|
||||
impact: MEDIUM
|
||||
impactDescription: cleaner component definitions and context usage
|
||||
tags: react19, refs, context, hooks
|
||||
---
|
||||
|
||||
## React 19 API Changes
|
||||
|
||||
> **⚠️ React 19+ only.** Skip this if you're on React 18 or earlier.
|
||||
|
||||
In React 19, `ref` is now a regular prop (no `forwardRef` wrapper needed), and `use()` replaces `useContext()`.
|
||||
|
||||
**Incorrect (forwardRef in React 19):**
|
||||
|
||||
```tsx
|
||||
const ComposerInput = forwardRef<TextInput, Props>((props, ref) => {
|
||||
return <TextInput ref={ref} {...props} />
|
||||
})
|
||||
```
|
||||
|
||||
**Correct (ref as a regular prop):**
|
||||
|
||||
```tsx
|
||||
function ComposerInput({ ref, ...props }: Props & { ref?: React.Ref<TextInput> }) {
|
||||
return <TextInput ref={ref} {...props} />
|
||||
}
|
||||
```
|
||||
|
||||
**Incorrect (useContext in React 19):**
|
||||
|
||||
```tsx
|
||||
const value = useContext(MyContext)
|
||||
```
|
||||
|
||||
**Correct (use instead of useContext):**
|
||||
|
||||
```tsx
|
||||
const value = use(MyContext)
|
||||
```
|
||||
|
||||
`use()` can also be called conditionally, unlike `useContext()`.
|
||||
@@ -0,0 +1,191 @@
|
||||
---
|
||||
title: Define Generic Context Interfaces for Dependency Injection
|
||||
impact: HIGH
|
||||
impactDescription: enables dependency-injectable state across use-cases
|
||||
tags: composition, context, state, typescript, dependency-injection
|
||||
---
|
||||
|
||||
## Define Generic Context Interfaces for Dependency Injection
|
||||
|
||||
Define a **generic interface** for your component context with three parts:
|
||||
`state`, `actions`, and `meta`. This interface is a contract that any provider
|
||||
can implement—enabling the same UI components to work with completely different
|
||||
state implementations.
|
||||
|
||||
**Core principle:** Lift state, compose internals, make state
|
||||
dependency-injectable.
|
||||
|
||||
**Incorrect (UI coupled to specific state implementation):**
|
||||
|
||||
```tsx
|
||||
function ComposerInput() {
|
||||
// Tightly coupled to a specific hook
|
||||
const { input, setInput } = useChannelComposerState()
|
||||
return <TextInput value={input} onChangeText={setInput} />
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (generic interface enables dependency injection):**
|
||||
|
||||
```tsx
|
||||
// Define a GENERIC interface that any provider can implement
|
||||
interface ComposerState {
|
||||
input: string
|
||||
attachments: Attachment[]
|
||||
isSubmitting: boolean
|
||||
}
|
||||
|
||||
interface ComposerActions {
|
||||
update: (updater: (state: ComposerState) => ComposerState) => void
|
||||
submit: () => void
|
||||
}
|
||||
|
||||
interface ComposerMeta {
|
||||
inputRef: React.RefObject<TextInput>
|
||||
}
|
||||
|
||||
interface ComposerContextValue {
|
||||
state: ComposerState
|
||||
actions: ComposerActions
|
||||
meta: ComposerMeta
|
||||
}
|
||||
|
||||
const ComposerContext = createContext<ComposerContextValue | null>(null)
|
||||
```
|
||||
|
||||
**UI components consume the interface, not the implementation:**
|
||||
|
||||
```tsx
|
||||
function ComposerInput() {
|
||||
const {
|
||||
state,
|
||||
actions: { update },
|
||||
meta,
|
||||
} = use(ComposerContext)
|
||||
|
||||
// This component works with ANY provider that implements the interface
|
||||
return (
|
||||
<TextInput
|
||||
ref={meta.inputRef}
|
||||
value={state.input}
|
||||
onChangeText={(text) => update((s) => ({ ...s, input: text }))}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Different providers implement the same interface:**
|
||||
|
||||
```tsx
|
||||
// Provider A: Local state for ephemeral forms
|
||||
function ForwardMessageProvider({ children }: { children: React.ReactNode }) {
|
||||
const [state, setState] = useState(initialState)
|
||||
const inputRef = useRef(null)
|
||||
const submit = useForwardMessage()
|
||||
|
||||
return (
|
||||
<ComposerContext
|
||||
value={{
|
||||
state,
|
||||
actions: { update: setState, submit },
|
||||
meta: { inputRef },
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ComposerContext>
|
||||
)
|
||||
}
|
||||
|
||||
// Provider B: Global synced state for channels
|
||||
function ChannelProvider({ channelId, children }: Props) {
|
||||
const { state, update, submit } = useGlobalChannel(channelId)
|
||||
const inputRef = useRef(null)
|
||||
|
||||
return (
|
||||
<ComposerContext
|
||||
value={{
|
||||
state,
|
||||
actions: { update, submit },
|
||||
meta: { inputRef },
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ComposerContext>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**The same composed UI works with both:**
|
||||
|
||||
```tsx
|
||||
// Works with ForwardMessageProvider (local state)
|
||||
<ForwardMessageProvider>
|
||||
<Composer.Frame>
|
||||
<Composer.Input />
|
||||
<Composer.Submit />
|
||||
</Composer.Frame>
|
||||
</ForwardMessageProvider>
|
||||
|
||||
// Works with ChannelProvider (global synced state)
|
||||
<ChannelProvider channelId="abc">
|
||||
<Composer.Frame>
|
||||
<Composer.Input />
|
||||
<Composer.Submit />
|
||||
</Composer.Frame>
|
||||
</ChannelProvider>
|
||||
```
|
||||
|
||||
**Custom UI outside the component can access state and actions:**
|
||||
|
||||
The provider boundary is what matters—not the visual nesting. Components that
|
||||
need shared state don't have to be inside the `Composer.Frame`. They just need
|
||||
to be within the provider.
|
||||
|
||||
```tsx
|
||||
function ForwardMessageDialog() {
|
||||
return (
|
||||
<ForwardMessageProvider>
|
||||
<Dialog>
|
||||
{/* The composer UI */}
|
||||
<Composer.Frame>
|
||||
<Composer.Input placeholder="Add a message, if you'd like." />
|
||||
<Composer.Footer>
|
||||
<Composer.Formatting />
|
||||
<Composer.Emojis />
|
||||
</Composer.Footer>
|
||||
</Composer.Frame>
|
||||
|
||||
{/* Custom UI OUTSIDE the composer, but INSIDE the provider */}
|
||||
<MessagePreview />
|
||||
|
||||
{/* Actions at the bottom of the dialog */}
|
||||
<DialogActions>
|
||||
<CancelButton />
|
||||
<ForwardButton />
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</ForwardMessageProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// This button lives OUTSIDE Composer.Frame but can still submit based on its context!
|
||||
function ForwardButton() {
|
||||
const {
|
||||
actions: { submit },
|
||||
} = use(ComposerContext)
|
||||
return <Button onPress={submit}>Forward</Button>
|
||||
}
|
||||
|
||||
// This preview lives OUTSIDE Composer.Frame but can read composer's state!
|
||||
function MessagePreview() {
|
||||
const { state } = use(ComposerContext)
|
||||
return <Preview message={state.input} attachments={state.attachments} />
|
||||
}
|
||||
```
|
||||
|
||||
The `ForwardButton` and `MessagePreview` are not visually inside the composer
|
||||
box, but they can still access its state and actions. This is the power of
|
||||
lifting state into providers.
|
||||
|
||||
The UI is reusable bits you compose together. The state is dependency-injected
|
||||
by the provider. Swap the provider, keep the UI.
|
||||
@@ -0,0 +1,113 @@
|
||||
---
|
||||
title: Decouple State Management from UI
|
||||
impact: MEDIUM
|
||||
impactDescription: enables swapping state implementations without changing UI
|
||||
tags: composition, state, architecture
|
||||
---
|
||||
|
||||
## Decouple State Management from UI
|
||||
|
||||
The provider component should be the only place that knows how state is managed.
|
||||
UI components consume the context interface—they don't know if state comes from
|
||||
useState, Zustand, or a server sync.
|
||||
|
||||
**Incorrect (UI coupled to state implementation):**
|
||||
|
||||
```tsx
|
||||
function ChannelComposer({ channelId }: { channelId: string }) {
|
||||
// UI component knows about global state implementation
|
||||
const state = useGlobalChannelState(channelId)
|
||||
const { submit, updateInput } = useChannelSync(channelId)
|
||||
|
||||
return (
|
||||
<Composer.Frame>
|
||||
<Composer.Input
|
||||
value={state.input}
|
||||
onChange={(text) => sync.updateInput(text)}
|
||||
/>
|
||||
<Composer.Submit onPress={() => sync.submit()} />
|
||||
</Composer.Frame>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (state management isolated in provider):**
|
||||
|
||||
```tsx
|
||||
// Provider handles all state management details
|
||||
function ChannelProvider({
|
||||
channelId,
|
||||
children,
|
||||
}: {
|
||||
channelId: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const { state, update, submit } = useGlobalChannel(channelId)
|
||||
const inputRef = useRef(null)
|
||||
|
||||
return (
|
||||
<Composer.Provider
|
||||
state={state}
|
||||
actions={{ update, submit }}
|
||||
meta={{ inputRef }}
|
||||
>
|
||||
{children}
|
||||
</Composer.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// UI component only knows about the context interface
|
||||
function ChannelComposer() {
|
||||
return (
|
||||
<Composer.Frame>
|
||||
<Composer.Header />
|
||||
<Composer.Input />
|
||||
<Composer.Footer>
|
||||
<Composer.Submit />
|
||||
</Composer.Footer>
|
||||
</Composer.Frame>
|
||||
)
|
||||
}
|
||||
|
||||
// Usage
|
||||
function Channel({ channelId }: { channelId: string }) {
|
||||
return (
|
||||
<ChannelProvider channelId={channelId}>
|
||||
<ChannelComposer />
|
||||
</ChannelProvider>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Different providers, same UI:**
|
||||
|
||||
```tsx
|
||||
// Local state for ephemeral forms
|
||||
function ForwardMessageProvider({ children }) {
|
||||
const [state, setState] = useState(initialState)
|
||||
const forwardMessage = useForwardMessage()
|
||||
|
||||
return (
|
||||
<Composer.Provider
|
||||
state={state}
|
||||
actions={{ update: setState, submit: forwardMessage }}
|
||||
>
|
||||
{children}
|
||||
</Composer.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// Global synced state for channels
|
||||
function ChannelProvider({ channelId, children }) {
|
||||
const { state, update, submit } = useGlobalChannel(channelId)
|
||||
|
||||
return (
|
||||
<Composer.Provider state={state} actions={{ update, submit }}>
|
||||
{children}
|
||||
</Composer.Provider>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
The same `Composer.Input` component works with both providers because it only
|
||||
depends on the context interface, not the implementation.
|
||||
@@ -0,0 +1,125 @@
|
||||
---
|
||||
title: Lift State into Provider Components
|
||||
impact: HIGH
|
||||
impactDescription: enables state sharing outside component boundaries
|
||||
tags: composition, state, context, providers
|
||||
---
|
||||
|
||||
## Lift State into Provider Components
|
||||
|
||||
Move state management into dedicated provider components. This allows sibling
|
||||
components outside the main UI to access and modify state without prop drilling
|
||||
or awkward refs.
|
||||
|
||||
**Incorrect (state trapped inside component):**
|
||||
|
||||
```tsx
|
||||
function ForwardMessageComposer() {
|
||||
const [state, setState] = useState(initialState)
|
||||
const forwardMessage = useForwardMessage()
|
||||
|
||||
return (
|
||||
<Composer.Frame>
|
||||
<Composer.Input />
|
||||
<Composer.Footer />
|
||||
</Composer.Frame>
|
||||
)
|
||||
}
|
||||
|
||||
// Problem: How does this button access composer state?
|
||||
function ForwardMessageDialog() {
|
||||
return (
|
||||
<Dialog>
|
||||
<ForwardMessageComposer />
|
||||
<MessagePreview /> {/* Needs composer state */}
|
||||
<DialogActions>
|
||||
<CancelButton />
|
||||
<ForwardButton /> {/* Needs to call submit */}
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Incorrect (useEffect to sync state up):**
|
||||
|
||||
```tsx
|
||||
function ForwardMessageDialog() {
|
||||
const [input, setInput] = useState('')
|
||||
return (
|
||||
<Dialog>
|
||||
<ForwardMessageComposer onInputChange={setInput} />
|
||||
<MessagePreview input={input} />
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function ForwardMessageComposer({ onInputChange }) {
|
||||
const [state, setState] = useState(initialState)
|
||||
useEffect(() => {
|
||||
onInputChange(state.input) // Sync on every change 😬
|
||||
}, [state.input])
|
||||
}
|
||||
```
|
||||
|
||||
**Incorrect (reading state from ref on submit):**
|
||||
|
||||
```tsx
|
||||
function ForwardMessageDialog() {
|
||||
const stateRef = useRef(null)
|
||||
return (
|
||||
<Dialog>
|
||||
<ForwardMessageComposer stateRef={stateRef} />
|
||||
<ForwardButton onPress={() => submit(stateRef.current)} />
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (state lifted to provider):**
|
||||
|
||||
```tsx
|
||||
function ForwardMessageProvider({ children }: { children: React.ReactNode }) {
|
||||
const [state, setState] = useState(initialState)
|
||||
const forwardMessage = useForwardMessage()
|
||||
const inputRef = useRef(null)
|
||||
|
||||
return (
|
||||
<Composer.Provider
|
||||
state={state}
|
||||
actions={{ update: setState, submit: forwardMessage }}
|
||||
meta={{ inputRef }}
|
||||
>
|
||||
{children}
|
||||
</Composer.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function ForwardMessageDialog() {
|
||||
return (
|
||||
<ForwardMessageProvider>
|
||||
<Dialog>
|
||||
<ForwardMessageComposer />
|
||||
<MessagePreview /> {/* Custom components can access state and actions */}
|
||||
<DialogActions>
|
||||
<CancelButton />
|
||||
<ForwardButton /> {/* Custom components can access state and actions */}
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</ForwardMessageProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function ForwardButton() {
|
||||
const { actions } = use(Composer.Context)
|
||||
return <Button onPress={actions.submit}>Forward</Button>
|
||||
}
|
||||
```
|
||||
|
||||
The ForwardButton lives outside the Composer.Frame but still has access to the
|
||||
submit action because it's within the provider. Even though it's a one-off
|
||||
component, it can still access the composer's state and actions from outside the
|
||||
UI itself.
|
||||
|
||||
**Key insight:** Components that need shared state don't have to be visually
|
||||
nested inside each other—they just need to be within the same provider.
|
||||
@@ -18,29 +18,26 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
|
||||
- name: Find Dashboard PRs older than 24 hours
|
||||
id: find-prs
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
|
||||
with:
|
||||
script: |
|
||||
const findStalePRs = require('./scripts/actions/find-stale-dashboard-prs.js');
|
||||
return await findStalePRs({ github, context, core });
|
||||
sparse-checkout: |
|
||||
scripts
|
||||
|
||||
- name: Send Slack notification
|
||||
if: fromJSON(steps.find-prs.outputs.count) > 0
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
|
||||
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
name: Install pnpm
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Find stale Dashboard PRs and notify Slack
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_DASHBOARD_WEBHOOK_URL }}
|
||||
STALE_PRS_JSON: ${{ steps.find-prs.outputs.stale_prs }}
|
||||
with:
|
||||
script: |
|
||||
const sendSlackNotification = require('./scripts/actions/send-slack-pr-notification.js');
|
||||
const stalePRs = JSON.parse(process.env.STALE_PRS_JSON);
|
||||
const webhookUrl = process.env.SLACK_WEBHOOK_URL;
|
||||
await sendSlackNotification(stalePRs, webhookUrl);
|
||||
|
||||
- name: No stale PRs found
|
||||
if: fromJSON(steps.find-prs.outputs.count) == 0
|
||||
run: |
|
||||
echo "✓ No Dashboard PRs older than 24 hours found"
|
||||
run: pnpm tsx scripts/actions/find-stale-dashboard-prs.ts | pnpm tsx scripts/actions/send-slack-pr-notification.ts
|
||||
@@ -0,0 +1,50 @@
|
||||
name: Marketing site tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: ['master']
|
||||
paths:
|
||||
- 'apps/www/**/*.ts*'
|
||||
- 'apps/www/next.config.mjs'
|
||||
- 'apps/www/next.config.js'
|
||||
|
||||
# Cancel old builds on new commit for same workflow + branch/PR
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CI: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
sparse-checkout: |
|
||||
apps/www
|
||||
packages
|
||||
supabase
|
||||
|
||||
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
name: Install pnpm
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run tests
|
||||
run: pnpm run test
|
||||
working-directory: ./apps/www
|
||||
@@ -31,7 +31,7 @@
|
||||
"next-contentlayer2": "0.4.6",
|
||||
"next-themes": "^0.3.0",
|
||||
"react": "catalog:",
|
||||
"react-data-grid": "7.0.0-beta.41",
|
||||
"react-data-grid": "7.0.0-beta.47",
|
||||
"react-day-picker": "^9.11.1",
|
||||
"react-dom": "catalog:",
|
||||
"react-hook-form": "^7.45.0",
|
||||
|
||||
@@ -33,7 +33,7 @@ export default function DataGridDemo() {
|
||||
headerCellClass: 'border-default border-r border-b',
|
||||
renderCell: ({ row }) => {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const [isRowSelected, onRowSelectionChange] = useRowSelection()
|
||||
const { isRowSelected, onRowSelectionChange } = useRowSelection()
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
@@ -43,7 +43,6 @@ export default function DataGridDemo() {
|
||||
e.stopPropagation()
|
||||
onRowSelectionChange({
|
||||
row,
|
||||
type: 'ROW',
|
||||
checked: !isRowSelected,
|
||||
isShiftClick: e.shiftKey,
|
||||
})
|
||||
|
||||
@@ -60,7 +60,7 @@ const HomePageCover = (props) => {
|
||||
tooltip: 'TanStack Start',
|
||||
icon: '/docs/img/icons/tanstack-icon',
|
||||
href: '/guides/getting-started/quickstarts/tanstack',
|
||||
hasLightIcon: false,
|
||||
hasLightIcon: true,
|
||||
},
|
||||
{
|
||||
tooltip: 'Vue',
|
||||
|
||||
@@ -2480,7 +2480,17 @@ export const platform: NavMenuConstant = {
|
||||
{ name: 'Custom Domains', url: '/guides/platform/custom-domains' },
|
||||
{ name: 'Database Backups', url: '/guides/platform/backups' },
|
||||
{ name: 'IPv4 Address', url: '/guides/platform/ipv4-address' },
|
||||
{ name: 'Read Replicas', url: '/guides/platform/read-replicas' },
|
||||
{
|
||||
name: 'Read Replicas',
|
||||
url: '/guides/platform/read-replicas',
|
||||
items: [
|
||||
{ name: 'Overview', url: '/guides/platform/read-replicas' as `/${string}` },
|
||||
{
|
||||
name: 'Getting started',
|
||||
url: '/guides/platform/read-replicas/getting-started' as `/${string}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -2828,8 +2838,13 @@ export const self_hosting: NavMenuConstant = {
|
||||
{ name: 'Overview', url: '/guides/self-hosting' },
|
||||
{ name: 'Self-Hosting with Docker', url: '/guides/self-hosting/docker' },
|
||||
{
|
||||
name: 'Configuration',
|
||||
items: [{ name: 'Enabling MCP server', url: '/guides/self-hosting/enable-mcp' }],
|
||||
name: 'How-to Guides',
|
||||
items: [
|
||||
{ name: 'Enabling MCP server', url: '/guides/self-hosting/enable-mcp' },
|
||||
{ name: 'Restore from Platform', url: '/guides/self-hosting/restore-from-platform' },
|
||||
{ name: 'Configure S3 Storage', url: '/guides/self-hosting/self-hosted-s3' },
|
||||
{ name: 'Copy Storage from Platform', url: '/guides/self-hosting/copy-from-platform-s3' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Auth Server',
|
||||
|
||||
@@ -61,6 +61,10 @@ description = "The rate of joins per second from your clients has reached the ch
|
||||
|
||||
[RealtimeDisabledForTenant]
|
||||
description = "Realtime has been disabled for the tenant."
|
||||
resolution = "Your project may have been suspended for exceeding usage quotas. Contact support with your project reference ID and a description of your Realtime use case."
|
||||
[[RealtimeDisabledForTenant.references]]
|
||||
href = "https://supabase.com/docs/troubleshooting/realtime-project-suspended-for-exceeding-quotas"
|
||||
description = "Troubleshooting guide for suspended projects"
|
||||
|
||||
[UnableToConnectToTenantDatabase]
|
||||
description = "Realtime was not able to connect to the tenant's database."
|
||||
|
||||
@@ -111,7 +111,7 @@ data=[{'predictions': {'time': 0.08492901099998562, 'image': {'width': 640, 'hei
|
||||
|
||||
You can use the Supabase vector database functionality to store and query CLIP embeddings.
|
||||
|
||||
Roboflow Inference provides a HTTP interface through which you can calculate image and text embeddings using CLIP.
|
||||
Roboflow Inference provides an HTTP interface through which you can calculate image and text embeddings using CLIP.
|
||||
|
||||
### Step 1: Install and start Roboflow Inference
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@ notify pgrst, 'reload config';
|
||||
|
||||
<$Partial path="db_pre_request_warning.mdx" />
|
||||
|
||||
Inside the function you can perform any additional checks on the request headers or JWT and raise an exception to prevent the request from completing. For example, this exception raises a HTTP 402 Payment Required response with a `hint` and additional `X-Powered-By` header:
|
||||
Inside the function you can perform any additional checks on the request headers or JWT and raise an exception to prevent the request from completing. For example, this exception raises an HTTP 402 Payment Required response with a `hint` and additional `X-Powered-By` header:
|
||||
|
||||
```sql
|
||||
raise sqlstate 'PGRST' using
|
||||
@@ -186,7 +186,7 @@ You can only rate-limit `POST`, `PUT`, `PATCH` and `DELETE` requests. This is be
|
||||
Outline:
|
||||
|
||||
- A new row is added to a `private.rate_limits` table each time a modifying action is done to the database containing the IP address and the timestamp of the action.
|
||||
- If there are over 100 requests from the same IP address in the last 5 minutes, the request is rejected with a HTTP 420 code.
|
||||
- If there are over 100 requests from the same IP address in the last 5 minutes, the request is rejected with an HTTP 420 code.
|
||||
|
||||
Create the table:
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ The templating system provides the following variables for use:
|
||||
| `{{ .SiteURL }}` | Contains your application's Site URL. This can be configured in your project's [authentication settings](/dashboard/project/_/auth/url-configuration). |
|
||||
| `{{ .RedirectTo }}` | Contains the redirect URL passed when `signUp`, `signInWithOtp`, `signInWithOAuth`, `resetPasswordForEmail` or `inviteUserByEmail` is called. The redirect URL allow list can be configured in your project's [authentication settings](/dashboard/project/_/auth/url-configuration). |
|
||||
| `{{ .Data }}` | Contains metadata from `auth.users.user_metadata`. Use this to personalize the email message. |
|
||||
| `{{ .Email }}` | Contains the original email address of the user. Empty when when trying to [link an email address to an anonymous user](/docs/guides/auth/auth-anonymous#link-an-email--phone-identity). |
|
||||
| `{{ .Email }}` | Contains the original email address of the user. Empty when trying to [link an email address to an anonymous user](/docs/guides/auth/auth-anonymous#link-an-email--phone-identity). |
|
||||
| `{{ .NewEmail }}` | Contains the new email address of the user. This variable is only supported in the "Change email address" template. |
|
||||
| `{{ .OldEmail }}` | Contains the old email address of the user. This variable is only supported in the "Email address changed notification" template. |
|
||||
| `{{ .Phone }}` | Contains the new phone number of the user. This variable is only supported in the "Phone number changed notification" template. |
|
||||
|
||||
@@ -42,7 +42,7 @@ A [Postgres function](/docs/guides/database/functions) can be configured as a ho
|
||||
</TabPanel>
|
||||
<TabPanel id="http" label="HTTP Endpoint">
|
||||
|
||||
A HTTP Hook is an endpoint which takes in a JSON event payload and returns a JSON response. You can use any HTTP endpoint as a Hook, including an endpoint in your application. The easiest way to create a HTTP hook is to create a [Supabase Edge Function](/docs/guides/functions/quickstart).
|
||||
An HTTP Hook is an endpoint which takes in a JSON event payload and returns a JSON response. You can use any HTTP endpoint as a Hook, including an endpoint in your application. The easiest way to create an HTTP hook is to create a [Supabase Edge Function](/docs/guides/functions/quickstart).
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
@@ -97,7 +97,7 @@ HTTP Hooks in Supabase follow the [Standard Webhooks Specification](https://www.
|
||||
|
||||
When the request is made to the HTTP hook, you should use the [Standard Webhooks libraries](https://github.com/standard-webhooks/standard-webhooks/tree/main/libraries) to verify these headers.
|
||||
|
||||
When a HTTP hook is created, the secret generated should be of the `v1,whsec_<base64-secret>` format:
|
||||
When an HTTP hook is created, the secret generated should be of the `v1,whsec_<base64-secret>` format:
|
||||
|
||||
- `v1` denotes the version of the hook
|
||||
- `whsec_` signifies that the secret is symmetric
|
||||
@@ -303,7 +303,7 @@ Here's an example:
|
||||
}
|
||||
```
|
||||
|
||||
Errors returned from a Postgres Hook are not retry-able. When an error is returned, the error is propagated from the hook to Supabase Auth and translated into a HTTP error which is returned to your application. Supabase Auth will only take into account the error and disregard the rest of the payload.
|
||||
Errors returned from a Postgres Hook are not retry-able. When an error is returned, the error is propagated from the hook to Supabase Auth and translated into an HTTP error which is returned to your application. Supabase Auth will only take into account the error and disregard the rest of the payload.
|
||||
|
||||
</TabPanel>
|
||||
|
||||
|
||||
@@ -173,7 +173,7 @@ This response will block the user creation and return the error message to the c
|
||||
|
||||
## Examples
|
||||
|
||||
Each of the following examples shows how to use the `before-user-created` hook to control signup behavior. Each use case includes both a HTTP implementation (e.g. using an Edge Function) and a SQL implementation (Postgres function).
|
||||
Each of the following examples shows how to use the `before-user-created` hook to control signup behavior. Each use case includes both an HTTP implementation (e.g. using an Edge Function) and a SQL implementation (Postgres function).
|
||||
|
||||
<Tabs
|
||||
scrollable
|
||||
|
||||
@@ -161,7 +161,7 @@ revoke all
|
||||
</TabPanel>
|
||||
<TabPanel id="sql-send-email-on-failed-password-attempt" label="Send email notification on failed password attempts">
|
||||
|
||||
You can notify a user via email instead of blocking the user. To do so, make use of [Supabase Vault](/docs/guides/database/vault) to store the API Key of our mail provider and use [`pg_net`](/docs/guides/database/extensions/pg_net) to send a HTTP request to our email provider to send the email. Ensure that you have configured a sender signature for the email account which you are sending emails from.
|
||||
You can notify a user via email instead of blocking the user. To do so, make use of [Supabase Vault](/docs/guides/database/vault) to store the API Key of our mail provider and use [`pg_net`](/docs/guides/database/extensions/pg_net) to send an HTTP request to our email provider to send the email. Ensure that you have configured a sender signature for the email account which you are sending emails from.
|
||||
|
||||
First, create a table to track sign in attempts.
|
||||
|
||||
|
||||
@@ -282,7 +282,7 @@ However, encountering a different AAL level on the server may not actually be a
|
||||
3. User has lost their authenticator device and is confused about the next
|
||||
steps.
|
||||
|
||||
We thus recommend you redirect users to a page where they can authenticate using their additional factor, instead of rendering a HTTP 401 Unauthorized or HTTP 403 Forbidden content.
|
||||
We thus recommend you redirect users to a page where they can authenticate using their additional factor, instead of rendering an HTTP 401 Unauthorized or HTTP 403 Forbidden content.
|
||||
|
||||
### APIs
|
||||
|
||||
|
||||
@@ -77,6 +77,15 @@ supabase.auth.signInWith(OTP) {
|
||||
}
|
||||
```
|
||||
|
||||
To send the OTP via WhatsApp instead of SMS (requires Twilio or Twilio Verify provider):
|
||||
|
||||
```kotlin
|
||||
supabase.auth.signInWith(OTP) {
|
||||
phone = "+13334445555"
|
||||
channel = Phone.Channel.WHATSAPP
|
||||
}
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</$Show>
|
||||
<$Show if="sdk:python">
|
||||
|
||||
@@ -67,10 +67,12 @@ This serves as the `client_id` when you make API calls to authenticate the user.
|
||||
- Set **State** to "ON" in the **Usage settings** section to enable Kakao Login.
|
||||
- Go to **Product Settings** > **Kakao Login** > **Consent Items**.
|
||||
- Set the following scopes under the **Consent Items**:
|
||||
- account_email
|
||||
- account_email (optional)
|
||||
- profile_image
|
||||
- profile_nickname
|
||||
|
||||
If you don't need an email address (or `account_email` isn't available for your app), you can omit `account_email` and enable **Allow users without an email** in the Supabase Kakao provider settings.
|
||||
|
||||

|
||||
|
||||
<Admonition type="tip">
|
||||
@@ -83,6 +85,8 @@ In the Kakao Developers Portal, the "account_email" consent item is only availab
|
||||
|
||||
<$Partial path="social_provider_settings_supabase.mdx" variables={{ "provider": "Kakao" }} />
|
||||
|
||||
If you did not request `account_email` in Kakao, enable **Allow users without an email** in the Kakao provider settings.
|
||||
|
||||
## Add login code to your client app
|
||||
|
||||
<Tabs
|
||||
|
||||
@@ -36,7 +36,7 @@ const supabase = createClient(
|
||||
)
|
||||
```
|
||||
|
||||
Make sure the all users in your application have the `role: 'authenticated'` [custom claim](https://firebase.google.com/docs/auth/admin/custom-claims) set. If you're using the `onCreate` Cloud Function to add this custom claim to newly signed up users, you will need to call `getIdToken(/* forceRefresh */ true)` immediately after sign up as the `onCreate` function does not run synchronously.
|
||||
Make sure all users in your application have the `role: 'authenticated'` [custom claim](https://firebase.google.com/docs/auth/admin/custom-claims) set. If you're using the `onCreate` Cloud Function to add this custom claim to newly signed up users, you will need to call `getIdToken(/* forceRefresh */ true)` immediately after sign up as the `onCreate` function does not run synchronously.
|
||||
|
||||
</TabPanel>
|
||||
|
||||
@@ -58,7 +58,7 @@ await Supabase.initialize(
|
||||
);
|
||||
```
|
||||
|
||||
Make sure the all users in your application have the `role: 'authenticated'` [custom claim](https://firebase.google.com/docs/auth/admin/custom-claims) set. If you're using the `onCreate` Cloud Function to add this custom claim to newly signed up users, you will need to call `getIdToken(/* forceRefresh */ true)` immediately after sign up as the `onCreate` function does not run synchronously.
|
||||
Make sure all users in your application have the `role: 'authenticated'` [custom claim](https://firebase.google.com/docs/auth/admin/custom-claims) set. If you're using the `onCreate` Cloud Function to add this custom claim to newly signed up users, you will need to call `getIdToken(/* forceRefresh */ true)` immediately after sign up as the `onCreate` function does not run synchronously.
|
||||
|
||||
</TabPanel>
|
||||
</$Show>
|
||||
|
||||
@@ -11,7 +11,7 @@ Every [Compute Add-On](/docs/guides/platform/compute-add-ons) has a pre-configur
|
||||
|
||||
### Configuring Supavisor's pool size
|
||||
|
||||
You can change how many database connections Supavisor can manage by altering the pool size in the "Connection pooling configuration" section of the [Database Settings](/dashboard/project/_/database/settings):
|
||||
You can change how many database connections Supavisor can manage by altering the pool size in the "Connection pooling" section of the [Database Settings](/dashboard/project/_/database/settings):
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -51,19 +51,23 @@ Some settings can only be modified by a superuser. Supabase pre-enables the [`su
|
||||
| Setting | Description |
|
||||
| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `auto_explain.*` | Configures the [auto_explain module](https://www.postgresql.org/docs/current/auto-explain.html). Can be configured to log execution plans for queries expected to exceed x seconds, including function queries. |
|
||||
| `deadlock_timeout` | Sets the time to wait on a lock before checking for deadlock. |
|
||||
| `log_lock_waits` | Controls whether a log message is produced when a session waits longer than [deadlock_timeout](https://www.postgresql.org/docs/current/runtime-config-locks.html#GUC-DEADLOCK-TIMEOUT) to acquire a lock. |
|
||||
| `log_min_duration_statement` | Causes the duration of each completed statement to be logged if the statement ran for at least the specified amount of time. |
|
||||
| `log_min_messages` | Minimum severity level of messages to log. |
|
||||
| `log_parameter_max_length` | Sets the maximum length in bytes of data logged for bind parameter values when logging statements. |
|
||||
| `log_replication_commands` | Logs all replication commands |
|
||||
| `log_statement` | Controls which SQL statements are logged. Valid values are `none` (off), `ddl`, `mod`, and `all` (all statements). |
|
||||
| `log_temp_files` | Controls logging of temporary file names and sizes. |
|
||||
| `pg_net.ttl` | Sets how long the [pg_net extension](/docs/guides/database/extensions/pg_net) saves responses |
|
||||
| `pg_net.batch_size` | Sets how many requests the [pg_net extension](/docs/guides/database/extensions/pg_net) can make per second |
|
||||
| `pg_net.ttl` | Sets how long the [pg_net extension](/docs/guides/database/extensions/pg_net) saves responses |
|
||||
| `pg_stat_statements.*` | Configures the [pg_stat_statements extension](https://www.postgresql.org/docs/current/pgstatstatements.html). |
|
||||
| `pgaudit.*` | Configures the [PGAudit extension](/docs/guides/database/extensions/pgaudit). The `log_parameter` is still restricted to protect secrets |
|
||||
| `pgrst.*` | [`PostgREST` settings](https://docs.postgrest.org/en/stable/references/configuration.html#db-aggregates-enabled) |
|
||||
| `plan_filter.*` | Configures the [pg_plan_filter extension](/docs/guides/database/extensions/pg_plan_filter) |
|
||||
| `safeupdate.enabled` | Enables the [safeupdate extension](https://github.com/eradman/pg-safeupdate), which requires a `WHERE` clause on `UPDATE` and `DELETE` statements. |
|
||||
| `session_replication_role` | Sets the session's behavior for triggers and rewrite rules. |
|
||||
| `track_functions` | Controls whether function call counts and timing are tracked. Valid values are `none`, `pl` (only procedural-language functions), and `all`. |
|
||||
| `track_io_timing` | Collects timing statistics for database I/O activity. |
|
||||
| `wal_compression` | This parameter enables compression of WAL using the specified compression method. |
|
||||
|
||||
|
||||
@@ -211,6 +211,460 @@ GET https://[REF].supabase.co/rest/v1/orchestral_sections?select=id,name,instrum
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
## 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
|
||||
|
||||
<Tabs
|
||||
scrollable
|
||||
size="small"
|
||||
type="underlined"
|
||||
defaultActiveId="table"
|
||||
queryGroup="join-types-example"
|
||||
>
|
||||
<TabPanel id="table" label="Tables">
|
||||
|
||||
#### 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 |
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="sql" label="SQL">
|
||||
|
||||
```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);
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
### Left join (default)
|
||||
|
||||
This query filters on a joined field (`instruments.name`) but still returns all parent rows:
|
||||
|
||||
<Tabs
|
||||
scrollable
|
||||
size="small"
|
||||
type="underlined"
|
||||
defaultActiveId="js"
|
||||
queryGroup="join-types-left"
|
||||
>
|
||||
<TabPanel id="js" label="JavaScript">
|
||||
|
||||
```js
|
||||
const { data, error } = await supabase
|
||||
.from('orchestral_sections')
|
||||
.select(
|
||||
`
|
||||
id,
|
||||
name,
|
||||
instruments ( id, name )
|
||||
`
|
||||
)
|
||||
.eq('instruments.name', 'flute')
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<$Show if="sdk:dart">
|
||||
<TabPanel id="dart" label="Dart">
|
||||
|
||||
```dart
|
||||
final data = await supabase
|
||||
.from('orchestral_sections')
|
||||
.select('''
|
||||
id,
|
||||
name,
|
||||
instruments ( id, name )
|
||||
''')
|
||||
.eq('instruments.name', 'flute');
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</$Show>
|
||||
<$Show if="sdk:swift">
|
||||
<TabPanel id="swift" label="Swift">
|
||||
|
||||
```swift
|
||||
try await supabase
|
||||
.from("orchestral_sections")
|
||||
.select(
|
||||
"""
|
||||
id,
|
||||
name,
|
||||
instruments ( id, name )
|
||||
"""
|
||||
)
|
||||
.eq("instruments.name", value: "flute")
|
||||
.execute()
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</$Show>
|
||||
<$Show if="sdk:kotlin">
|
||||
<TabPanel id="kotlin" label="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")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</$Show>
|
||||
<$Show if="sdk:python">
|
||||
<TabPanel id="python" label="Python">
|
||||
|
||||
```python
|
||||
data = (
|
||||
supabase.from_('orchestral_sections')
|
||||
.select('id, name, instruments(id, name)')
|
||||
.eq('instruments.name', 'flute')
|
||||
.execute()
|
||||
)
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</$Show>
|
||||
<TabPanel id="url" label="URL">
|
||||
|
||||
```bash
|
||||
GET https://[REF].supabase.co/rest/v1/orchestral_sections?select=id,name,instruments(id,name)&instruments.name=eq.flute
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
#### 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:
|
||||
|
||||
<Tabs
|
||||
scrollable
|
||||
size="small"
|
||||
type="underlined"
|
||||
defaultActiveId="js"
|
||||
queryGroup="join-types-inner"
|
||||
>
|
||||
<TabPanel id="js" label="JavaScript">
|
||||
|
||||
```js
|
||||
const { data, error } = await supabase
|
||||
.from('orchestral_sections')
|
||||
.select(
|
||||
`
|
||||
id,
|
||||
name,
|
||||
instruments!inner ( id, name )
|
||||
`
|
||||
)
|
||||
.eq('instruments.name', 'flute')
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<$Show if="sdk:dart">
|
||||
<TabPanel id="dart" label="Dart">
|
||||
|
||||
```dart
|
||||
final data = await supabase
|
||||
.from('orchestral_sections')
|
||||
.select('''
|
||||
id,
|
||||
name,
|
||||
instruments!inner ( id, name )
|
||||
''')
|
||||
.eq('instruments.name', 'flute');
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</$Show>
|
||||
<$Show if="sdk:swift">
|
||||
<TabPanel id="swift" label="Swift">
|
||||
|
||||
```swift
|
||||
try await supabase
|
||||
.from("orchestral_sections")
|
||||
.select(
|
||||
"""
|
||||
id,
|
||||
name,
|
||||
instruments!inner ( id, name )
|
||||
"""
|
||||
)
|
||||
.eq("instruments.name", value: "flute")
|
||||
.execute()
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</$Show>
|
||||
<$Show if="sdk:kotlin">
|
||||
<TabPanel id="kotlin" label="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")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</$Show>
|
||||
<$Show if="sdk:python">
|
||||
<TabPanel id="python" label="Python">
|
||||
|
||||
```python
|
||||
data = (
|
||||
supabase.from_('orchestral_sections')
|
||||
.select('id, name, instruments!inner(id, name)')
|
||||
.eq('instruments.name', 'flute')
|
||||
.execute()
|
||||
)
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</$Show>
|
||||
<TabPanel id="url" label="URL">
|
||||
|
||||
```bash
|
||||
GET https://[REF].supabase.co/rest/v1/orchestral_sections?select=id,name,instruments!inner(id,name)&instruments.name=eq.flute
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
#### 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`):
|
||||
|
||||
<Tabs
|
||||
scrollable
|
||||
size="small"
|
||||
type="underlined"
|
||||
defaultActiveId="js"
|
||||
queryGroup="join-types-filtering"
|
||||
>
|
||||
<TabPanel id="js" label="JavaScript">
|
||||
|
||||
```js
|
||||
const { data, error } = await supabase
|
||||
.from('instruments')
|
||||
.select(
|
||||
`
|
||||
id,
|
||||
name,
|
||||
orchestral_sections!inner ( id, name )
|
||||
`
|
||||
)
|
||||
.eq('orchestral_sections.name', 'woodwinds')
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<$Show if="sdk:dart">
|
||||
<TabPanel id="dart" label="Dart">
|
||||
|
||||
```dart
|
||||
final data = await supabase
|
||||
.from('instruments')
|
||||
.select('''
|
||||
id,
|
||||
name,
|
||||
orchestral_sections!inner ( id, name )
|
||||
''')
|
||||
.eq('orchestral_sections.name', 'woodwinds');
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</$Show>
|
||||
<$Show if="sdk:swift">
|
||||
<TabPanel id="swift" label="Swift">
|
||||
|
||||
```swift
|
||||
try await supabase
|
||||
.from("instruments")
|
||||
.select(
|
||||
"""
|
||||
id,
|
||||
name,
|
||||
orchestral_sections!inner ( id, name )
|
||||
"""
|
||||
)
|
||||
.eq("orchestral_sections.name", value: "woodwinds")
|
||||
.execute()
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</$Show>
|
||||
<$Show if="sdk:kotlin">
|
||||
<TabPanel id="kotlin" label="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")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</$Show>
|
||||
<$Show if="sdk:python">
|
||||
<TabPanel id="python" label="Python">
|
||||
|
||||
```python
|
||||
data = (
|
||||
supabase.from_('instruments')
|
||||
.select('id, name, orchestral_sections!inner(id, name)')
|
||||
.eq('orchestral_sections.name', 'woodwinds')
|
||||
.execute()
|
||||
)
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</$Show>
|
||||
<TabPanel id="url" label="URL">
|
||||
|
||||
```bash
|
||||
GET https://[REF].supabase.co/rest/v1/instruments?select=id,name,orchestral_sections!inner(id,name)&orchestral_sections.name=eq.woodwinds
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
#### 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):
|
||||
|
||||
@@ -186,6 +186,15 @@ hideToc: true
|
||||
icon: '/docs/img/icons/vuejs-icon',
|
||||
enabled: isFeatureEnabled('docs:framework_quickstarts'),
|
||||
},
|
||||
{
|
||||
title: 'TanStack Start',
|
||||
href: '/guides/getting-started/quickstarts/tanstack',
|
||||
description:
|
||||
'Learn how to create a Supabase project, add some sample data to your database, and query the data from a TanStack Start app.',
|
||||
icon: '/docs/img/icons/tanstack-icon',
|
||||
hasLightIcon: true,
|
||||
enabled: isFeatureEnabled('docs:framework_quickstarts'),
|
||||
},
|
||||
{
|
||||
title: 'Refine',
|
||||
href: '/guides/getting-started/quickstarts/refine',
|
||||
|
||||
@@ -345,7 +345,7 @@ Now to setup the RLS policies for each tables:
|
||||
|
||||
```sql
|
||||
-- Create a private schema to store all security definer functions utils
|
||||
-- As such functions should never be in a API exposed schema
|
||||
-- As such functions should never be in an API exposed schema
|
||||
create schema if not exists private;
|
||||
-- Helper function for role checks
|
||||
create or replace function private.get_user_org_role(org_id bigint, user_id uuid)
|
||||
|
||||
@@ -39,7 +39,7 @@ Monthly costs for paid plans include a fixed subscription fee based on your chos
|
||||
|
||||
- [Your monthly invoice](/docs/guides/platform/your-monthly-invoice) - For a detailed breakdown of what a monthly invoice includes
|
||||
- [Manage your usage](/docs/guides/platform/manage-your-usage) - For details on how the different usage items are billed, and how to optimize usage and reduce costs
|
||||
- [Control your costs]() - For details on how you can control your costs in case unexpected high usage occurs
|
||||
- [Control your costs](/docs/guides/platform/cost-control) - For details on how you can control your costs in case unexpected high usage occurs
|
||||
|
||||
### Compute costs for projects
|
||||
|
||||
|
||||
@@ -5,7 +5,13 @@ title: 'Manage Read Replica usage'
|
||||
|
||||
## What you are charged for
|
||||
|
||||
Each [Read Replica](/docs/guides/platform/read-replicas) is a dedicated database. You are charged for its resources: [Compute](/docs/guides/platform/compute-and-disk#compute), [Disk Size](/docs/guides/platform/database-size#disk-size), provisioned [Disk IOPS](/docs/guides/platform/compute-and-disk#provisioned-disk-throughput-and-iops), provisioned [Disk Throughput](/docs/guides/platform/compute-and-disk#provisioned-disk-throughput-and-iops), and [IPv4](/docs/guides/platform/ipv4-address).
|
||||
Each [Read Replica](/docs/guides/platform/read-replicas) is a dedicated database. You are charged for its resources, which are the following, and mirrored from the primary database:
|
||||
|
||||
- [Compute](/docs/guides/platform/compute-and-disk#compute)
|
||||
- [Disk Size](/docs/guides/platform/database-size#disk-size)
|
||||
- Provisioned [Disk IOPS](/docs/guides/platform/compute-and-disk#provisioned-disk-throughput-and-iops)
|
||||
- Provisioned [Disk Throughput](/docs/guides/platform/compute-and-disk#provisioned-disk-throughput-and-iops)
|
||||
- [IPv4](/docs/guides/platform/ipv4-address).
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
@@ -13,26 +19,37 @@ Read Replicas are **not** covered by the [Spend Cap](/docs/guides/platform/cost-
|
||||
|
||||
</Admonition>
|
||||
|
||||
## How charges are calculated
|
||||
## How we calculate charges
|
||||
|
||||
Read Replica charges are the total of the charges listed below.
|
||||
|
||||
**Compute**
|
||||
### Compute
|
||||
|
||||
Compute is charged by the hour, meaning you are charged for the exact number of hours that a Read Replica is running and, therefore, incurring Compute usage. If a Read Replica runs for part of an hour, you are still charged for the full hour.
|
||||
|
||||
Read Replicas run on the same Compute size as the primary database.
|
||||
|
||||
**Disk Size**
|
||||
Refer to [Manage Disk Size usage](/docs/guides/platform/manage-your-usage/disk-size) for details on how charges are calculated. The disk size of a Read Replica is 1.25x the size of the primary disk to account for WAL archives. With a Read Replica you go beyond your subscription plan's quota for Disk Size.
|
||||
### Disk size
|
||||
|
||||
**Provisioned Disk IOPS (optional)**
|
||||
Read Replicas inherit any additional provisioned Disk IOPS from the primary database. Refer to [Manage Disk IOPS usage](/docs/guides/platform/manage-your-usage/disk-iops) for details on how charges are calculated.
|
||||
Read [the Manage Disk Size usage guide](/docs/guides/platform/manage-your-usage/disk-size) for details on how we calculate charges. The disk size of a Read Replica is 1.25x the size of the primary disk to account for WAL archives. With a Read Replica you go beyond your subscription plan's quota for Disk Size.
|
||||
|
||||
**Provisioned Disk Throughput (optional)**
|
||||
Read Replicas inherit any additional provisioned Disk Throughput from the primary database. Refer to [Manage Disk Throughput usage](/docs/guides/platform/manage-your-usage/disk-throughput) for details on how charges are calculated.
|
||||
{/* supa-mdx-lint-disable-next-line Rule001HeadingCase */}
|
||||
|
||||
**IPv4 (optional)**
|
||||
If the primary database has a configured IPv4 address, its Read Replicas are also assigned one, with charges for each. Refer to [Manage IPv4 usage](/docs/guides/platform/manage-your-usage/ipv4) for details on how charges are calculated.
|
||||
### Provisioned Disk IOPS (optional)
|
||||
|
||||
Read Replicas inherit any additional provisioned Disk IOPS from the primary database. Read the [Manage Disk IOPS usage guide](/docs/guides/platform/manage-your-usage/disk-iops) for details on how we calculate charges.
|
||||
|
||||
{/* supa-mdx-lint-disable-next-line Rule001HeadingCase */}
|
||||
|
||||
### Provisioned Disk Throughput (optional)
|
||||
|
||||
Read Replicas inherit any additional provisioned Disk Throughput from the primary database. Read the [Manage Disk Throughput usage guide](/docs/guides/platform/manage-your-usage/disk-throughput) for details on how we calculate charges.
|
||||
|
||||
{/* supa-mdx-lint-enable-next-line Rule001HeadingCase */}
|
||||
|
||||
### IPv4 (optional)
|
||||
|
||||
If the primary database has configured an IPv4 address add-on, its Read Replicas are also assigned one, with charges for each. Read the [Manage IPv4 usage guide](/docs/guides/platform/manage-your-usage/ipv4) for details on how we calculate charges.
|
||||
|
||||
### Usage on your invoice
|
||||
|
||||
@@ -42,7 +59,7 @@ Compute incurred by Read Replicas is shown as "Replica Compute Hours" on your in
|
||||
|
||||
### No additional resources configured
|
||||
|
||||
The project has one Read Replica and no IPv4 and no additional Disk IOPS and Disk Throughput configured.
|
||||
The project has one Read Replica, no IPv4, and no additional Disk IOPS and Disk Throughput configured.
|
||||
|
||||
| Line Item | Units | Costs |
|
||||
| ----------------------------- | --------- | --------------------------- |
|
||||
@@ -60,7 +77,7 @@ The project has one Read Replica and no IPv4 and no additional Disk IOPS and Dis
|
||||
|
||||
### Additional resources configured
|
||||
|
||||
The project has two Read Replicas and IPv4 and additional Disk IOPS and Disk Throughput configured.
|
||||
The project has two Read Replicas, IPv4, and additional Disk IOPS and Disk Throughput configured.
|
||||
|
||||
| Line Item | Units | Costs |
|
||||
| ----------------------------- | --------- | ---------------------------- |
|
||||
|
||||
@@ -40,7 +40,7 @@ If you are an organization owner and on the Pro, Team or Enterprise plan, you ca
|
||||
|
||||
## Disable MFA
|
||||
|
||||
You can disable MFA for your user account under your [Supabase account settings](/dashboard/account/security). On subsequent login attempts, you will not be prompted to enter a MFA code.
|
||||
You can disable MFA for your user account under your [Supabase account settings](/dashboard/account/security). On subsequent login attempts, you will not be prompted to enter an MFA code.
|
||||
|
||||
<Admonition type="caution">
|
||||
|
||||
|
||||
@@ -163,7 +163,7 @@ For maximum security, you can disable public internet access for your database:
|
||||
|
||||
During the beta phase:
|
||||
|
||||
- **Read Replicas**: PrivateLink does not currently support read replicas
|
||||
- **Read Replicas**: To establish PrivateLink with a Read Replica, reach out to your account rep.
|
||||
- **Feature Evolution**: The setup process and capabilities may evolve as we refine the offering
|
||||
|
||||
## Compatibility
|
||||
|
||||
@@ -4,7 +4,7 @@ description: 'Deploy read-only databases across multiple regions, for lower late
|
||||
subtitle: 'Deploy read-only databases across multiple regions, for lower latency and better resource management.'
|
||||
---
|
||||
|
||||
Read Replicas are additional databases that are kept in sync with your Primary database. You can read your data from a Read Replica, which helps with:
|
||||
Read Replicas are additional databases kept in sync with your Primary database. You can read your data from a Read Replica, which helps with:
|
||||
|
||||
- **Load balancing:** Read Replicas reduce load on the Primary database. For example, you can use a Read Replica for complex analytical queries and reserve the Primary for user-facing create, update, and delete operations.
|
||||
- **Improved latency:** For projects with a global user base, additional databases can be deployed closer to users to reduce latency.
|
||||
@@ -19,7 +19,7 @@ Read Replicas are additional databases that are kept in sync with your Primary d
|
||||
|
||||
## About Read Replicas
|
||||
|
||||
The database you start with when launching a Supabase project is your Primary database. Read Replicas are kept in sync with the Primary through a process called "replication." Replication is asynchronous to ensure that transactions on the Primary aren't blocked. There is a delay between an update on the Primary and the time that a Read Replica receives the change. This delay is called "replication lag."
|
||||
The database you start with when launching a Supabase project is your Primary database. A process called "replication" keeps Read Replicas in sync with the Primary. Replication is asynchronous to ensure that transactions on the Primary aren't blocked. There is a delay between an update on the Primary and the time that a Read Replica receives the change. This delay is called "replication lag."
|
||||
|
||||
You can only read data from a Read Replica. This is in contrast to a Primary database, where you can both read and write:
|
||||
|
||||
@@ -28,73 +28,33 @@ You can only read data from a Read Replica. This is in contrast to a Primary dat
|
||||
| Primary | ✅ | ✅ | ✅ | ✅ |
|
||||
| Read Replica | ✅ | - | - | - |
|
||||
|
||||
## Prerequisites
|
||||
<Accordion
|
||||
type="default"
|
||||
openBehaviour="multiple"
|
||||
chevronAlign="right"
|
||||
justified
|
||||
size="large"
|
||||
className="text-foreground-light mt-8 mb-6"
|
||||
>
|
||||
<div className="border-b mt-3 pb-3">
|
||||
<AccordionItem
|
||||
header="Do you need Read Replicas?"
|
||||
id="rr-flow"
|
||||
>
|
||||
|
||||
<Admonition type="note">
|
||||
When your database starts slowing down, you face a choice: make your existing database bigger (scale vertically), or spread the load across multiple databases (scale horizontally). Both approaches work. Neither is universally correct. The right answer depends on your workload, your budget, and where the bottleneck actually is.
|
||||
|
||||
Read Replicas are available for all projects on the Pro, Team and Enterprise plans. Spin one up now over at the [Infrastructure Settings page](/dashboard/project/_/settings/infrastructure).
|
||||
<Image
|
||||
src="/docs/img/guides/platform/read-replicas/read-replicas-flow.svg"
|
||||
zoomable
|
||||
alt="Read Replicas decision flowchart"
|
||||
/>
|
||||
|
||||
</Admonition>
|
||||
</AccordionItem>
|
||||
|
||||
Projects must meet these requirements to use Read Replicas:
|
||||
</div>
|
||||
|
||||
1. Running on AWS.
|
||||
1. 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.
|
||||
1. Running on Postgres 15+.
|
||||
- For projects running on older versions of Postgres, you will need to [upgrade to the latest platform version](/docs/guides/platform/migrating-and-upgrading-projects#pgupgrade).
|
||||
1. Using [physical backups](/docs/guides/platform/backups#point-in-time-recovery)
|
||||
- Physical backups are automatically enabled if using [PITR](/docs/guides/platform/backups#point-in-time-recovery)
|
||||
- If you're not using PITR, you'll be able to switch to physical backups as part of the Read Replica setup process. Note that physical backups can't be downloaded from the dashboard in the way logical backups can.
|
||||
|
||||
## Getting started
|
||||
|
||||
To add a Read Replica, go to the [Infrastructure Settings page](/dashboard/project/_/settings/infrastructure) in your dashboard.
|
||||
|
||||
You can also manage Read Replicas using the Management API (beta functionality):
|
||||
|
||||
```bash
|
||||
# Get your access token from https://supabase.com/dashboard/account/tokens
|
||||
export SUPABASE_ACCESS_TOKEN="your-access-token"
|
||||
export PROJECT_REF="your-project-ref"
|
||||
|
||||
# Create a new Read Replica
|
||||
curl -X POST "https://api.supabase.com/v1/projects/$PROJECT_REF/read-replicas/setup" \
|
||||
-H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"region": "us-east-1"
|
||||
}'
|
||||
|
||||
# Delete a Read Replica
|
||||
curl -X POST "https://api.supabase.com/v1/projects/$PROJECT_REF/read-replicas/remove" \
|
||||
-H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"database_identifier": "abcdefghijklmnopqrst"
|
||||
}'
|
||||
```
|
||||
|
||||
Projects on an XL compute add-on or larger can create up to five Read Replicas. Projects on compute add-ons smaller than XL can create up to two Read Replicas. All Read Replicas inherit the compute size of their Primary database.
|
||||
|
||||
### Deploying a Read Replica
|
||||
|
||||
A Read Replica is deployed by using a physical backup as a starting point, and a combination of WAL file archives and direct replication from the Primary database to catch up. Both components may take significant time to complete. The duration of restoring from a physical backup is roughly dependent and directly related to the database size of your project. The time taken to catch up to the primary using WAL archives and direct replication is dependent on the level of activity on the Primary database; a more active database will produce a larger number of WAL files that will need to be processed.
|
||||
|
||||
Along with the progress of the deployment, the dashboard displays rough estimates for each component.
|
||||
|
||||
{/* supa-mdx-lint-disable-next-line Rule001HeadingCase */}
|
||||
|
||||
### What does it mean when "Init failed" is observed?
|
||||
|
||||
The status `Init failed` indicates that the Read Replica has failed to deploy. Some possible scenarios as to why a Read Replica may have failed to be deployed:
|
||||
|
||||
- Underlying instance failed to come up.
|
||||
- Network issue leading to inability to connect to the Primary database.
|
||||
- Possible incompatible database settings between the Primary and Read Replica databases.
|
||||
- Platform issues.
|
||||
|
||||
It is safe to drop this failed Read Replica, and in the event of a transient issue, attempt to spin up another one. If however spinning up Read Replicas for your project consistently fails, do check out our [status page](https://status.supabase.com) for any ongoing incidents, or open a support ticket [here](/dashboard/support/new). To aid the investigation, do not bring down the recently failed Read Replica.
|
||||
</Accordion>
|
||||
|
||||
## Features
|
||||
|
||||
@@ -104,14 +64,18 @@ Read Replicas offer the following features:
|
||||
|
||||
Each Read Replica has its own dedicated database and API endpoints.
|
||||
|
||||
- Find the database endpoint on the projects [**Connect** panel](/dashboard/project/_?showConnect=true)
|
||||
- Find the API endpoint on the [API Settings page](/dashboard/project/_/settings/api) under **Project URL**
|
||||
- Find the database endpoint on the project's [**Connect** panel](/dashboard/project/_?showConnect=true). Toggle between Primary and Read Replicas using the **Source** dropdown.
|
||||
- Find the API endpoint on the [API Settings page](/dashboard/project/_/settings/api) under **Project URL**. Toggle between Primary and Read Replicas using the **Source** dropdown.
|
||||
|
||||
If you use an [IPv4 add-on](/docs/guides/platform/ipv4-address#read-replicas), the database endpoints for your Read Replicas also use an IPv4 add-on.
|
||||
|
||||
Read Replicas only support `GET` requests from the [REST API](/docs/guides/api). If you are calling a read-only Postgres function through the REST API, make sure to set the `get: true` [option](/docs/reference/javascript/rpc?queryGroups=example&example=call-a-read-only-postgres-function).
|
||||
|
||||
<Admonition type="caution">
|
||||
|
||||
Requests to other Supabase products, such as Auth, Storage, and Realtime, aren't able to use a Read Replica or its API endpoint. Support for more products will be added in the future.
|
||||
|
||||
If you're using an [IPv4 add-on](/docs/guides/platform/ipv4-address#read-replicas), the database endpoints for your Read Replicas will also use an IPv4 add-on.
|
||||
</Admonition>
|
||||
|
||||
### Dedicated connection pool
|
||||
|
||||
@@ -119,11 +83,15 @@ A connection pool through Supavisor is also available for each Read Replica. Fin
|
||||
|
||||
### API load balancer
|
||||
|
||||
A load balancer is deployed to automatically balance requests between your Primary database and Read Replicas. Find its endpoint on the [API Settings page](/dashboard/project/_/settings/api).
|
||||
A load balancer automatically balances requests between your Primary database and Read Replicas. Find its endpoint on the [**API Settings page**](/dashboard/project/_/settings/api).
|
||||
|
||||
The load balancer enables geo-routing for Data API requests so that `GET` requests will automatically be routed to the database that is closest to your user ensuring the lowest latency. Non-`GET` requests can also be sent through this endpoint, and will be routed to the Primary database.
|
||||
The load balancer enables geo-routing for Data API requests to automatically route `GET` requests to the database closest to your user ensuring the lowest latency. You can also send Non-`GET` requests through this endpoint, and they are routed to the Primary database automatically.
|
||||
|
||||
You can also interact with Supabase services (Auth, Edge Functions, Realtime, and Storage) through this load balancer so there's no need to worry about which endpoint to use and in which situations. However, geo-routing for these services are not yet available but is coming soon.
|
||||
<Admonition type="note">
|
||||
|
||||
You can also interact with other Supabase services (Auth, Edge Functions, Realtime, and Storage) through this load balancer so there's no need to worry about which endpoint to use and in which situations. Geo-routing for Auth, Realtime, and Storage aren't yet available but are coming soon.
|
||||
|
||||
</Admonition>
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
@@ -137,10 +105,10 @@ If you remove all Read Replicas from your project, the load balancer and its end
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
Starting on April 4th, 2025, we will be changing the routing behavior for eligible Data API requests:
|
||||
From April 4th, 2025, the routing behavior for eligible Data API requests changed:
|
||||
|
||||
- Old behavior: Round-Robin distribution among all databases (all read replicas + primary) of your project, regardless of location
|
||||
- New behavior: Geo-routing, that directs requests to the closest available database (all read replicas + primary)
|
||||
- **Old behavior**: Round-Robin distribution among all databases (all read replicas + primary) of your project, regardless of location
|
||||
- **New behavior**: Geo-routing, that directs requests to the closest available database (all read replicas + primary)
|
||||
|
||||
The new behavior delivers a better experience for your users by minimizing the latency to your project. You can take full advantage of this by placing Read Replicas close to your major customer bases.
|
||||
|
||||
@@ -186,74 +154,6 @@ We recommend ingesting your [project's metrics](/docs/guides/platform/metrics#ac
|
||||
|
||||
All settings configured through the dashboard will be propagated across all databases of a project. This ensures that no Read Replica get out of sync with the Primary database or with other Read Replicas.
|
||||
|
||||
## Operations blocked by Read Replicas
|
||||
|
||||
### Project upgrades and data restorations
|
||||
|
||||
The following procedures require all Read Replicas for a project to be brought down before they can be performed:
|
||||
|
||||
1. [Project upgrades](/docs/guides/platform/migrating-and-upgrading-projects#pgupgrade)
|
||||
1. [Data restorations](/docs/guides/platform/backups#pitr-restoration-process)
|
||||
|
||||
These operations need to be completed before Read Replicas can be re-deployed.
|
||||
|
||||
## About replication
|
||||
|
||||
We use a hybrid approach to replicate data from a Primary to its Read Replicas, combining the native methods of streaming replication and file-based log shipping.
|
||||
|
||||
### Streaming replication
|
||||
|
||||
Postgres generates a Write Ahead Log (WAL) as database changes occur. With streaming replication, these changes stream from the Primary to the Read Replica server. The WAL alone is sufficient to reconstruct the database to its current state.
|
||||
|
||||
This replication method is fast, since changes are streamed directly from the Primary to the Read Replica. On the other hand, it faces challenges when the Read Replica can't keep up with the WAL changes from its Primary. This can happen when the Read Replica is too small, running on degraded hardware, or has a heavier workload running.
|
||||
|
||||
To address this, Postgres does provide tunable configuration, like `wal_keep_size`, to adjust the WAL retained by the Primary. If the Read Replica fails to “catch up” before the WAL surpasses the `wal_keep_size` setting, the replication is terminated. Tuning is a bit of an art - the amount of WAL required is variable for every situation.
|
||||
|
||||
### File-based log shipping
|
||||
|
||||
In this replication method, the Primary continuously buffers WAL changes to a local file and then sends the file to the Read Replica. If multiple Read Replicas are present, files could also be sent to an intermediary location accessible by all. The Read Replica then reads the WAL files and applies those changes. There is higher replication lag than streaming replication since the Primary buffers the changes locally first. It also means there is a small chance that WAL changes do not reach Read Replicas if the Primary goes down before the file is transferred. In these cases, if the Primary fails a Replica using streaming replication would (in most cases) be more up-to-date than a Replica using file-based log shipping.
|
||||
|
||||
### File-based log shipping 🤝 streaming replication
|
||||
|
||||
<Image
|
||||
alt="Map view of Primary and Read Replica databases"
|
||||
caption="Map view of Primary and Read Replica databases"
|
||||
src="/docs/img/guides/platform/read-replicas/streaming-replication-dark.png?v=1"
|
||||
containerClassName="max-w-[700px] mx-auto"
|
||||
zoomable
|
||||
/>
|
||||
|
||||
We bring these two methods together to achieve quick, stable, and reliable replication. Each method addresses the limitations of the other. Streaming replication minimizes replication lag, while file-based log shipping provides a fallback. For file-based log shipping, we use our existing Point In Time Recovery (PITR) infrastructure. We regularly archive files from the Primary using [WAL-G](https://github.com/wal-g/wal-g), an open source archival and restoration tool, and ship the WAL files to S3.
|
||||
|
||||
We combine it with streaming replication to reduce replication lag. Once WAL-G files have been synced from S3, Read Replicas connect to the Primary and stream the WAL directly.
|
||||
|
||||
### Monitoring replication lag
|
||||
|
||||
Replication lag for a specific Read Replica can be monitored through the dashboard. On the [Database Reports page](/dashboard/project/_/observability/database) Read Replicas will have an additional chart under `Replica Information` displaying historical replication lag in seconds. Realtime replication lag in seconds can be observed on the [Infrastructure Settings page](/dashboard/project/_/settings/infrastructure). This is the value on top of the Read Replica. Do note that there is no single threshold to indicate when replication lag should be addressed. It would be fully dependent on the requirements of your project.
|
||||
|
||||
If you are already ingesting your [project's metrics](/docs/guides/platform/metrics#accessing-the-metrics-endpoint) into your own environment, you can also keep track of replication lag and set alarms accordingly with the metric: `physical_replication_lag_physical_replica_lag_seconds`.
|
||||
|
||||
Some common sources of high replication lag include:
|
||||
|
||||
1. Exclusive locks on tables on the Primary.
|
||||
Operations such as `drop table`, `reindex` (amongst others) take an Access Exclusive lock on the table. This can result in increasing replication lag for the duration of the lock.
|
||||
1. Resource Constraints on the database
|
||||
Heavy utilization on the primary or the replica, if run on an under-resourced project, can result in high replication lag. This includes the characteristics of the disk being utilized (IOPS, Throughput).
|
||||
1. Long-running transactions on the Primary.
|
||||
Transactions that run for a long-time on the primary can also result in high replication lag. You can use the `pg_stat_activity` view to identify and terminate such transactions if needed. `pg_stat_activity` is a live view, and does not offer historical data on transactions that might have been active for a long time in the past.
|
||||
|
||||
High replication lag can result in stale data being returned for queries being executed against the affected read replicas.
|
||||
|
||||
You can [consult](https://cloud.google.com/sql/docs/postgres/replication/replication-lag) [additional](https://repost.aws/knowledge-center/rds-postgresql-replication-lag) [resources](https://severalnines.com/blog/what-look-if-your-postgresql-replication-lagging/) on the subject as well.
|
||||
|
||||
## Misc
|
||||
|
||||
### Restart or compute add-on change behaviour
|
||||
|
||||
When a project that utilizes Read Replicas is restarted, or the compute add-on size is changed, the Primary database gets restarted first. During this period, the Read Replicas remain available.
|
||||
|
||||
Once the Primary database has completed restarting (or resizing, in case of a compute add-on change) and become available for usage, all the Read Replicas are restarted (and resized, if needed) concurrently.
|
||||
|
||||
## Pricing
|
||||
|
||||
For a detailed breakdown of how charges are calculated, refer to [Manage Read Replica usage](/docs/guides/platform/manage-your-usage/read-replicas).
|
||||
For a detailed breakdown of how we calculate charges, read the [Manage Read Replica usage guide](/docs/guides/platform/manage-your-usage/read-replicas).
|
||||
@@ -0,0 +1,170 @@
|
||||
---
|
||||
title: 'Getting started with Read Replicas'
|
||||
description: 'Deploy read-only databases across multiple regions, for lower latency.'
|
||||
subtitle: 'Deploy read-only databases across multiple regions, for lower latency and better resource management.'
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
Read Replicas are available for all projects on the Pro, Team and Enterprise plans. Spin one up now over at the [Infrastructure Settings page](/dashboard/project/_/settings/infrastructure).
|
||||
|
||||
</Admonition>
|
||||
|
||||
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.
|
||||
|
||||
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).
|
||||
|
||||
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)
|
||||
|
||||
## Creating a Read Replica
|
||||
|
||||
To add a Read Replica, go to the [Database Replication page](/dashboard/project/_/database/replication) in your project dashboard.
|
||||
|
||||
You can also manage Read Replicas using the Management API (beta functionality):
|
||||
|
||||
```bash
|
||||
# Get your access token from https://supabase.com/dashboard/account/tokens
|
||||
export SUPABASE_ACCESS_TOKEN="your-access-token"
|
||||
export PROJECT_REF="your-project-ref"
|
||||
|
||||
# Create a new Read Replica
|
||||
curl -X POST "https://api.supabase.com/v1/projects/$PROJECT_REF/read-replicas/setup" \
|
||||
-H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"region": "us-east-1"
|
||||
}'
|
||||
|
||||
# Delete a Read Replica
|
||||
curl -X POST "https://api.supabase.com/v1/projects/$PROJECT_REF/read-replicas/remove" \
|
||||
-H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"database_identifier": "abcdefghijklmnopqrst"
|
||||
}'
|
||||
```
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
Projects on an XL compute add-on or larger can create up to five Read Replicas. Projects on compute add-ons smaller than XL can create up to two Read Replicas. All Read Replicas inherit the compute size of their Primary database.
|
||||
|
||||
</Admonition>
|
||||
|
||||
### Deploying a Read Replica
|
||||
|
||||
We deploy a Read Replica using a physical backup as a starting point, and a combination of write ahead logging (WAL) file archives and direct replication from the Primary database to catch up. Both components may take significant time to complete, depending on your specific workload.
|
||||
|
||||
The time to restore from a physical backup is dependent and directly related to the database size of your project. The time taken to catch up to the primary using WAL archives and direct replication is dependent on the level of activity on the Primary database. A more active database produces a larger number of WAL files that need to be processed.
|
||||
|
||||
Along with the progress of the deployment, the dashboard displays rough estimates for each component.
|
||||
|
||||
## Replication method details
|
||||
|
||||
We use a hybrid approach to replicate data from a Primary to its Read Replicas, combining the native methods of streaming replication and file-based log shipping.
|
||||
|
||||
### Streaming replication
|
||||
|
||||
Postgres generates a Write Ahead Log (WAL) as database changes occur. With streaming replication, these changes stream from the Primary to the Read Replica server. The WAL alone is sufficient to reconstruct the database to its current state.
|
||||
|
||||
This replication method is fast, since the Primary streams changes directly to the Read Replica. However, it faces challenges when the Read Replica can't keep up with the WAL changes from its Primary. This can happen when the Read Replica is too small, running on degraded hardware, or has a heavier workload running.
|
||||
|
||||
To address this, Postgres provides tunable configuration, like `wal_keep_size`, to adjust the WAL retained by the Primary. If the Read Replica fails to "catch up" before the WAL surpasses the `wal_keep_size` setting, it terminates the replication. Tuning is an art - the amount of WAL required varies for every situation.
|
||||
|
||||
### File-based log shipping
|
||||
|
||||
In this replication method, the Primary continuously buffers WAL changes to a local file and then sends the file to the Read Replica. If multiple Read Replicas are present, files could also be sent to an intermediary location accessible by all replicas.
|
||||
|
||||
The Read Replica then reads the WAL files and applies those changes. There is higher replication lag than streaming replication since the Primary buffers the changes locally first. It also means there is a small chance that WAL changes do not reach Read Replicas if the Primary goes down before the file is transferred. In these cases, if the Primary fails a Replica using streaming replication would (in most cases) be more up-to-date than a Replica using file-based log shipping.
|
||||
|
||||
### File-based log shipping meets streaming replication
|
||||
|
||||
<Image
|
||||
alt="Map view of Primary and Read Replica databases"
|
||||
caption="Map view of Primary and Read Replica databases"
|
||||
src="/docs/img/guides/platform/read-replicas/streaming-replication-dark.png?v=1"
|
||||
containerClassName="max-w-[700px] mx-auto"
|
||||
zoomable
|
||||
/>
|
||||
|
||||
We bring these two methods together to achieve quick, stable, and reliable replication. Each method addresses the limitations of the other. Streaming replication minimizes replication lag, while file-based log shipping provides a fallback. For file-based log shipping, we use our existing Point In Time Recovery (PITR) infrastructure. We regularly archive files from the Primary using [WAL-G](https://github.com/wal-g/wal-g), an open source archival and restoration tool, and ship the WAL files to off-site, durable cloud storage, such as S3.
|
||||
|
||||
We combine it with streaming replication to reduce replication lag. Once WAL-G files have been synced from S3, Read Replicas connect to the Primary and stream the WAL directly.
|
||||
|
||||
### Restart or compute add-on change behaviour
|
||||
|
||||
When you restart a project that utilizes Read Replicas, or change the compute add-on size, the Primary database gets restarted first. During this period, the Read Replicas remain available.
|
||||
|
||||
Once the Primary database has completed restarting (or resizing, in case of a compute add-on change) and become available for usage, all the Read Replicas are restarted (and resized, if needed) concurrently.
|
||||
|
||||
## Operations blocked by Read Replicas
|
||||
|
||||
### Project upgrades and data restorations
|
||||
|
||||
The following procedures require all Read Replicas for a project to be brought down before performing them:
|
||||
|
||||
1. [Project upgrades](/docs/guides/platform/migrating-and-upgrading-projects#pgupgrade)
|
||||
2. [Data restorations](/docs/guides/platform/backups#pitr-restoration-process)
|
||||
|
||||
These operations need to complete before you can re-deploy Read Replicas.
|
||||
|
||||
### Monitoring replication lag
|
||||
|
||||
You can monitor replication lag for a specific Read Replica through a project dashboard on the [**Database Reports page**](/dashboard/project/_/observability/database). Read Replicas have an additional chart under **Replica Information** displaying historical replication lag in seconds.
|
||||
|
||||
You can see realtime replication lag in seconds on the [**Infrastructure Settings** page](/dashboard/project/_/settings/infrastructure). This is the value on top of the Read Replica.
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
There is no single threshold to indicate when you should address replication lag. It is dependent on the requirements of your project.
|
||||
|
||||
</Admonition>
|
||||
|
||||
<Admonition type="tip">
|
||||
|
||||
If you are already ingesting your [project's metrics](/docs/guides/platform/metrics#accessing-the-metrics-endpoint) into your own environment, you can also keep track of replication lag and set alarms with the `physical_replication_lag_physical_replica_lag_seconds` metric.
|
||||
|
||||
</Admonition>
|
||||
|
||||
### Addressing high replication lag
|
||||
|
||||
Some common sources of high replication lag include:
|
||||
|
||||
1. **Exclusive locks on tables on the Primary**: Operations such as `drop table` and `reindex` take an access-exclusive lock on the table. This can result in increasing replication lag for the duration of the lock.
|
||||
2. **Resource Constraints on the database**: Heavy utilization on the primary or the replica, if run on an under-resourced project, can result in high replication lag. This includes the characteristics of the disk being utilized (IOPS, Throughput).
|
||||
3. **Long-running transactions on the Primary**: Transactions that run for a long-time on the primary can also result in high replication lag. You can use the `pg_stat_activity` view to identify and terminate such transactions if needed. `pg_stat_activity` is a live view, and does not offer historical data on transactions that might have been active for a long time in the past.
|
||||
High replication lag can result in stale data returned for queries executed against the affected read replicas.
|
||||
|
||||
<Admonition type="tip" >
|
||||
|
||||
You can find additional resources on replication lag in [the Google documentation](https://cloud.google.com/sql/docs/postgres/replication/replication-lag), [the AWS documentation](https://repost.aws/knowledge-center/rds-postgresql-replication-lag), and [the several nines blog](https://severalnines.com/blog/what-look-if-your-postgresql-replication-lagging/).
|
||||
|
||||
</Admonition>
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
{/* supa-mdx-lint-disable-next-line Rule001HeadingCase */}
|
||||
|
||||
### An "Init failed" status
|
||||
|
||||
The replica status "Init failed" in the dashboard indicates that the Read Replica has failed to deploy. Some possible scenarios as to why a Read Replica deployment may have failed are the following:
|
||||
|
||||
- An underlying instance failed to come up.
|
||||
- A network issue leading to inability to connect to the Primary database.
|
||||
- A possible incompatible database settings between the Primary and Read Replica databases.
|
||||
- Platform issues.
|
||||
- Very high active workloads combined with large (50+ GB) database sizes
|
||||
|
||||
It is safe to drop this failed Read Replica, and in the event of a transient issue, attempt to spin up another one. If spinning up Read Replicas for your project consistently fails, check the[status page](https://status.supabase.com) for any ongoing incidents, or [open a support ticket](/dashboard/support/new). To aid the investigation, do not bring down the recently failed Read Replica.
|
||||
|
||||
{/* supa-mdx-lint-enable-next-line Rule001HeadingCase */}
|
||||
@@ -45,6 +45,20 @@ When SSO is enabled for an organization:
|
||||
- If an SSO user with the following email of `alice@foocorp.com` attempts to sign in with a GitHub account that uses the same email, a separate Supabase account is created and will not be linked to the SSO user's account.
|
||||
- SSO users will only see organizations/projects they've been invited to or auto-joined into. See [access control](/docs/guides/platform/access-control) for more details.
|
||||
|
||||
## Enabling SSO for an organization
|
||||
|
||||
- Review the steps above to configure your setup.
|
||||
- Invite users to the organization and ensure they join with their SSO linked account.
|
||||
- If a user is already a member of the organization under a non SSO account, they will need to be removed and invited again for them to join under their SSO account.
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
**No automatic linking:** Each user account verified using a SSO identity provider will not be automatically linked to existing user accounts in the system. That is, if a user `valid.email@supabase.io` had signed up with a password, and then uses their company SSO login with your project, there will be two `valid.email@supabase.io` user accounts in the system.
|
||||
|
||||
Users will need to ensure they are logged in with the correct account when accepting invites or accessing organizations/projects.
|
||||
|
||||
</Admonition>
|
||||
|
||||
## Disabling SSO for an organization
|
||||
|
||||
If you disable the SSO provider for an organization, **all SSO users will immediately be unable to sign in**. Before disabling SSO, ensure you have at least one non-SSO owner account to prevent being locked out.
|
||||
|
||||
@@ -293,7 +293,7 @@ You can use the Supabase client libraries to send Broadcast messages.
|
||||
|
||||
|
||||
/**
|
||||
* Sending a message after subscribing will use Websockets
|
||||
* Sending a message after subscribing will use WebSockets
|
||||
*/
|
||||
myChannel.subscribe((status) => {
|
||||
if (status !== 'SUBSCRIBED') {
|
||||
@@ -325,7 +325,7 @@ You can use the Supabase client libraries to send Broadcast messages.
|
||||
);
|
||||
print(res);
|
||||
|
||||
// Sending a message after subscribing will use Websockets
|
||||
// Sending a message after subscribing will use WebSockets
|
||||
myChannel.subscribe((status, error) {
|
||||
if (status != RealtimeSubscribeStatus.subscribed) {
|
||||
return;
|
||||
@@ -352,7 +352,7 @@ You can use the Supabase client libraries to send Broadcast messages.
|
||||
// Sending a message before subscribing will use HTTP
|
||||
await myChannel.broadcast(event: "shout", message: ["message": "HI"])
|
||||
|
||||
// Sending a message after subscribing will use Websockets
|
||||
// Sending a message after subscribing will use WebSockets
|
||||
await myChannel.subscribe()
|
||||
try await myChannel.broadcast(
|
||||
event: "shout",
|
||||
@@ -376,7 +376,7 @@ You can use the Supabase client libraries to send Broadcast messages.
|
||||
put("message", "Hi")
|
||||
})
|
||||
|
||||
// Sending a message after subscribing will use Websockets
|
||||
// Sending a message after subscribing will use WebSockets
|
||||
myChannel.subscribe(blockUntilSubscribed = true)
|
||||
channelB.broadcast(
|
||||
event = "shout",
|
||||
@@ -401,7 +401,7 @@ You can use the Supabase client libraries to send Broadcast messages.
|
||||
```python
|
||||
my_channel = supabase.channel('test-channel')
|
||||
|
||||
# Sending a message after subscribing will use Websockets
|
||||
# Sending a message after subscribing will use WebSockets
|
||||
def on_subscribe(status, err):
|
||||
if status != RealtimeSubscribeStates.SUBSCRIBED:
|
||||
return
|
||||
@@ -909,7 +909,7 @@ SELECT realtime.send (
|
||||
|
||||
#### Setup realtime authorization
|
||||
|
||||
Realtime Authorization is required and enabled by default. To allow your users to listen to messages from topics, create a RLS (Row Level Security) policy:
|
||||
Realtime Authorization is required and enabled by default. To allow your users to listen to messages from topics, create an RLS (Row Level Security) policy:
|
||||
|
||||
```sql
|
||||
CREATE POLICY "authenticated can receive broadcasts"
|
||||
|
||||
@@ -20,6 +20,12 @@ When any client subscribes, disconnects, or updates their presence payload, Supa
|
||||
- **`join`** — a new client has started tracking presence
|
||||
- **`leave`** — a client has stopped tracking presence
|
||||
|
||||
<Admonition type="note" title="Sync event behavior">
|
||||
|
||||
During a `sync` event, you may receive `join` and `leave` events simultaneously, even though no users are actually joining or leaving. This is expected behavior—Presence reconciles its local state with the server state, which can trigger these events as part of the synchronization process. This reflects state reconciliation, not real user movement.
|
||||
|
||||
</Admonition>
|
||||
|
||||
The complete presence state returned by `presenceState()` looks like this:
|
||||
|
||||
```json
|
||||
|
||||
@@ -61,6 +61,6 @@ For configuration information, see [PrivateLink](/docs/guides/platform/privateli
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
PrivateLink is currently in alpha and available exclusively to Enterprise customers.
|
||||
PrivateLink is currently in beta. To establish PrivateLink with a Read Replica, reach out to your account rep.
|
||||
|
||||
</Admonition>
|
||||
@@ -28,10 +28,10 @@ The fastest and recommended way to self-host Supabase is using Docker.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
## Other deployment options
|
||||
## Community-driven projects
|
||||
|
||||
{/* supa-mdx-lint-disable-next-line Rule004ExcludeWords */}
|
||||
There are several other ways to deploy Supabase with the help of community-driven projects. These projects may be outdated and are seeking active maintainers. If you're interested in maintaining one of these projects, [contact the Community team](/open-source/contributing/supasquad).
|
||||
There are several other options to deploy Supabase. If you're interested in helping these projects, visit our [Community](/contribute) page.
|
||||
|
||||
<div className="grid md:grid-cols-12 gap-4 not-prose">
|
||||
{selfHostingCommunity.map((x) => (
|
||||
@@ -41,7 +41,6 @@ There are several other ways to deploy Supabase with the help of community-drive
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
{x.name}
|
||||
<Badge>Maintainer needed</Badge>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
---
|
||||
title: 'Copy Storage Objects from Platform'
|
||||
description: 'Copy storage objects from a managed Supabase project to a self-hosted instance using rclone.'
|
||||
subtitle: 'Copy storage objects from a managed Supabase project to a self-hosted instance using rclone.'
|
||||
---
|
||||
|
||||
{/* supa-mdx-lint-disable-next-line Rule003Spelling */}
|
||||
This guide walks you through copying storage objects from a managed Supabase platform project to a self-hosted instance using [rclone](https://rclone.org/) with S3-to-S3 copy.
|
||||
|
||||
<Admonition type="caution">
|
||||
|
||||
Direct file copy (e.g., downloading files and placing them into `volumes/storage/`) does not work. Self-hosted Storage uses an internal file structure that differs from what you get when downloading files from the platform. Use the S3 protocol to transfer objects so that Storage creates the correct metadata records.
|
||||
|
||||
</Admonition>
|
||||
|
||||
## Before you begin
|
||||
|
||||
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 */}
|
||||
- [rclone](https://rclone.org/install/) installed on the machine running the copy
|
||||
|
||||
## Step 1: Get platform S3 credentials
|
||||
|
||||
In your managed Supabase project dashboard, go to **Storage** > **S3 Configuration** > **Access keys**. Generate a new access key pair and copy:
|
||||
|
||||
- **Endpoint**: `https://<project-ref>.supabase.co/storage/v1/s3`
|
||||
- **Region**: your project's region (e.g., `us-east-1`)
|
||||
- **Access Key ID** and **Secret access key**
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
For better performance with large files, use the direct storage hostname: `https://<project-ref>.storage.supabase.co/storage/v1/s3`
|
||||
|
||||
</Admonition>
|
||||
|
||||
## Step 2: Create buckets on self-hosted
|
||||
|
||||
Buckets must exist on the destination before you can copy objects into them. You can create them through dashboard UI, or with **SQL Editor**.
|
||||
|
||||
<Admonition type="tip">
|
||||
|
||||
If you already restored your platform database to self-hosted using the [restore guide](/docs/guides/self-hosting/restore-from-platform), your bucket definitions are already present. You can skip this step.
|
||||
|
||||
</Admonition>
|
||||
|
||||
To list your platform buckets, connect to your platform database and run:
|
||||
|
||||
```sql
|
||||
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
|
||||
('your-storage-bucket', 'your-storage-bucket', false)
|
||||
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`):
|
||||
|
||||
```ini rclone.conf
|
||||
[platform]
|
||||
type = s3
|
||||
provider = Other
|
||||
access_key_id = your-platform-access-key-id
|
||||
secret_access_key = your-platform-secret-access-key
|
||||
endpoint = https://your-project-ref.supabase.co/storage/v1/s3
|
||||
region = your-project-region
|
||||
|
||||
[self-hosted]
|
||||
type = s3
|
||||
provider = Other
|
||||
access_key_id = your-self-hosted-access-key-id
|
||||
secret_access_key = your-self-hosted-secret-access-key
|
||||
endpoint = http://your-domain:8000/storage/v1/s3
|
||||
region = your-self-hosted-region
|
||||
```
|
||||
|
||||
Replace the credentials with your actual values. For self-hosted, use the `REGION`, `S3_PROTOCOL_ACCESS_KEY_ID` and `S3_PROTOCOL_ACCESS_KEY_SECRET` you configured in [Configure S3 Storage](/docs/guides/self-hosting/self-hosted-s3#enable-the-s3-protocol-endpoint).
|
||||
|
||||
Verify both remotes connect:
|
||||
|
||||
```bash
|
||||
rclone lsd platform:
|
||||
rclone lsd self-hosted:
|
||||
```
|
||||
|
||||
Both commands should list your buckets.
|
||||
|
||||
## Step 4: Copy objects
|
||||
|
||||
Copy a single bucket:
|
||||
|
||||
```bash
|
||||
rclone copy platform:your-storage-bucket self-hosted:your-storage-bucket --progress
|
||||
```
|
||||
|
||||
To copy all buckets:
|
||||
|
||||
```bash
|
||||
for bucket in $(rclone lsf platform: | tr -d '/'); do
|
||||
echo "Copying bucket: $bucket"
|
||||
rclone copy "platform:$bucket" "self-hosted:$bucket" --progress
|
||||
done
|
||||
```
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
For large migrations, consider adding `--transfers 4` to increase parallelism, or `--checkers 8` to speed up the comparison phase. See the [flags documentation](https://rclone.org/flags/) for all options.
|
||||
|
||||
</Admonition>
|
||||
|
||||
## Verify
|
||||
|
||||
Compare object counts between source and destination:
|
||||
|
||||
```bash
|
||||
rclone size platform:your-storage-bucket && \
|
||||
rclone size self-hosted:your-storage-bucket
|
||||
```
|
||||
|
||||
Open Studio on your self-hosted instance and browse the storage buckets to confirm files are accessible.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Signature errors
|
||||
|
||||
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 */}
|
||||
- **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:
|
||||
|
||||
```bash
|
||||
rclone copy platform:your-storage-bucket self-hosted:your-storage-bucket --timeout 30m
|
||||
```
|
||||
|
||||
### Empty listing on platform
|
||||
|
||||
If `rclone lsd platform:` returns nothing, verify the endpoint URL ends with `/storage/v1/s3` and that the S3 access keys have not expired. Regenerate them from the dashboard if needed.
|
||||
@@ -27,7 +27,7 @@ This guide assumes you're comfortable with:
|
||||
- Docker and Docker Compose
|
||||
- Networking fundamentals (ports, DNS, firewalls)
|
||||
|
||||
If you're new to these topics, consider starting with [managed Supabase](/dashboard) for free, or try [local development with the CLI](/docs/guides/local-development).
|
||||
If you're new to these topics, consider starting with managed [Supabase platform](/dashboard) for free.
|
||||
|
||||
You need the following installed on your system:
|
||||
|
||||
@@ -147,7 +147,7 @@ sh ./utils/generate-keys.sh
|
||||
|
||||
The script is experimental, so review the output before proceeding and also check `.env` after it's updated by the script.
|
||||
|
||||
Alternatively, configure all secrets manually as follows.
|
||||
**Alternatively, configure all secrets manually as follows.**
|
||||
|
||||
### Configure database password
|
||||
|
||||
@@ -423,17 +423,10 @@ SMTP_SENDER_NAME=
|
||||
We recommend using [AWS SES](https://aws.amazon.com/ses/). It's extremely cheap and reliable. Restart all services to pick up the new configuration.
|
||||
|
||||
#### Configuring S3 Storage
|
||||
{/* supa-mdx-lint-disable-next-line Rule003Spelling */}
|
||||
By default all files are stored locally on the server. You can connect Storage to an S3-compatible backend (AWS S3, MinIO, Cloudflare R2), enable the S3 protocol endpoint for tools like `rclone`, or both. These are independent features.
|
||||
|
||||
By default all files are stored locally on the server. You can configure the Storage service to use S3 by updating the following environment variables:
|
||||
|
||||
```yaml docker-compose.yml
|
||||
storage:
|
||||
environment: STORAGE_BACKEND=s3
|
||||
GLOBAL_S3_BUCKET=name-of-your-s3-bucket
|
||||
REGION=region-of-your-s3-bucket
|
||||
```
|
||||
|
||||
You can find all the available options in the [storage repository](https://github.com/supabase/storage-api/blob/master/.env.sample). Restart the `storage` service to pick up the changes: `docker compose restart storage --no-deps`
|
||||
See the [Configure S3 Storage](/docs/guides/self-hosting/self-hosted-s3) guide for detailed setup instructions.
|
||||
|
||||
#### Configuring Supabase AI Assistant
|
||||
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
---
|
||||
title: 'Restore a Platform Project to Self-Hosted'
|
||||
description: 'Restore your database from the Supabase platform to a self-hosted instance.'
|
||||
subtitle: 'Restore your database from the Supabase platform to a self-hosted instance.'
|
||||
---
|
||||
|
||||
This guide walks you through restoring your database from a Supabase platform project to a [self-hosted Docker instance](/docs/guides/self-hosting/docker). Storage objects transfer or redeploying edge functions is not covered here.
|
||||
|
||||
## Before you begin
|
||||
|
||||
You need:
|
||||
|
||||
- A new self-hosted Supabase instance ([Docker setup guide](/docs/guides/self-hosting/docker))
|
||||
- [Supabase CLI](/docs/guides/local-development/cli/getting-started) installed (or use `npx supabase`)
|
||||
- [Docker Desktop](https://docs.docker.com/get-started/get-docker/) installed (required by the CLI)
|
||||
- `psql` installed ([official installation guide](https://www.postgresql.org/download/))
|
||||
- Your Supabase database passwords (for platform and self-hosted)
|
||||
|
||||
## Step 1: Get your platform connection string
|
||||
|
||||
On your managed Supabase project dashboard, click [**Connect**](/dashboard/project/_?showConnect=true) and copy the connection string (use the session pooler or direct connection).
|
||||
|
||||
## Step 2: Back up your platform database
|
||||
|
||||
Export roles, schema, and data as three separate SQL files:
|
||||
|
||||
```bash
|
||||
supabase db dump --db-url "[CONNECTION_STRING]" -f roles.sql --role-only
|
||||
```
|
||||
|
||||
```bash
|
||||
supabase db dump --db-url "[CONNECTION_STRING]" -f schema.sql
|
||||
```
|
||||
|
||||
```bash
|
||||
supabase db dump --db-url "[CONNECTION_STRING]" -f data.sql --use-copy --data-only
|
||||
```
|
||||
|
||||
This produces SQL files that are compatible across Postgres versions.
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
Using `supabase db dump` executes `pg_dump` under the hood but applies Supabase-specific filtering - it excludes internal schemas, strips reserved roles, and adds idempotent `IF NOT EXISTS` clauses. Using raw `pg_dump` directly will include Supabase internals and cause permission errors during restore. CLI requires Docker because it runs `pg_dump` inside a container from the Supabase Postgres image rather than requiring a local Postgres installation.
|
||||
|
||||
</Admonition>
|
||||
|
||||
## Step 3: Prepare your self-hosted instance
|
||||
|
||||
Before restoring, check the following on your self-hosted instance:
|
||||
|
||||
- **Extensions**: Enable any non-default extensions your Supabase project uses. You can check which extensions are active by querying `select * from pg_extension;` on your managed database (or check Database Extensions in Dashboard).
|
||||
|
||||
## Step 4: Restore to your self-hosted database
|
||||
|
||||
Connect to your self-hosted Postgres and restore the dump files. The [default](/docs/guides/self-hosting/docker#accessing-postgres) connection string for self-hosted Supabase is:
|
||||
|
||||
```
|
||||
postgres://postgres.your-tenant-id:[POSTGRES_PASSWORD]@[your-domain]:5432/postgres
|
||||
```
|
||||
|
||||
Where `[POSTGRES_PASSWORD]` is the value of `POSTGRES_PASSWORD` in your self-hosted `.env` file.
|
||||
|
||||
Use your domain name, your server IP, or localhost for `[your-domain]` depending on whether you are running self-hosted Supabase on a VPS, or locally.
|
||||
|
||||
Run `psql` to restore:
|
||||
|
||||
```bash
|
||||
psql \
|
||||
--single-transaction \
|
||||
--variable ON_ERROR_STOP=1 \
|
||||
--file roles.sql \
|
||||
--file schema.sql \
|
||||
--command 'SET session_replication_role = replica' \
|
||||
--file data.sql \
|
||||
--dbname "postgres://postgres.your-tenant-id:[POSTGRES_PASSWORD]@[your-domain]:5432/postgres"
|
||||
```
|
||||
|
||||
Setting `session_replication_role` to `replica` disables triggers during the data import, preventing issues like double-encryption of columns.
|
||||
|
||||
## Step 5: Verify the restore
|
||||
|
||||
Connect to your self-hosted database and run a few checks:
|
||||
|
||||
```bash
|
||||
psql "postgres://postgres.your-tenant-id:[POSTGRES_PASSWORD]@[your-domain]:5432/postgres"
|
||||
```
|
||||
|
||||
```sql
|
||||
-- Check your tables are present
|
||||
\dt public.*
|
||||
|
||||
-- Verify row counts on key tables
|
||||
SELECT count(*) FROM auth.users;
|
||||
|
||||
-- Check extensions
|
||||
SELECT * FROM pg_extension;
|
||||
```
|
||||
|
||||
## What's included in the restore and what's not
|
||||
|
||||
The database dump includes your schema, data, roles, RLS policies, database functions, triggers, and `auth.users`. However, several things require separate configuration on your self-hosted instance:
|
||||
|
||||
| Requires manual setup | How to configure |
|
||||
| ------------------------------------------- | ------------------------------------------------- |
|
||||
| JWT secrets and API keys | Generate new ones and update `.env` |
|
||||
| Auth provider settings (OAuth, Apple, etc.) | Configure `GOTRUE_EXTERNAL_*` variables in `.env` |
|
||||
| Edge functions | Manually copy to your self-hosted instance |
|
||||
| Storage objects | Transfer separately (not covered in this guide) |
|
||||
| SMTP / email settings | Configure `SMTP_*` variables in `.env` |
|
||||
| Custom domains and DNS | Point your DNS to the self-hosted server |
|
||||
|
||||
## Auth considerations
|
||||
|
||||
Your `auth.users` table and related data are included in the database dump, so user accounts are preserved. However:
|
||||
|
||||
- **JWT secrets differ** between your platform and self-hosted instances. Existing tokens issued by the platform project will not be valid. Users will need to re-authenticate.
|
||||
- **Social auth providers** (Apple, Google, GitHub, etc.) need to be configured in your self-hosted `.env` file. Set the relevant `GOTRUE_EXTERNAL_*` variables. See the Auth repository [README](https://github.com/supabase/auth) for all available options.
|
||||
- **Redirect URLs** in your OAuth provider consoles (Apple Developer, Google Cloud Console, etc.) must be updated to point to your self-hosted hostname instead of `*.supabase.co`.
|
||||
|
||||
## Postgres version compatibility
|
||||
|
||||
Managed Supabase may run a newer Postgres version (Postgres 17) than the self-hosted Docker image (currently Postgres 15). The `supabase db dump` command produces plain SQL files that work across major Postgres versions.
|
||||
|
||||
Keep in mind:
|
||||
|
||||
- The data dump may include Postgres 17-only settings or reference tables/columns from newer Auth and Storage versions that don't exist on self-hosted yet. See [Version mismatches](#version-mismatches-between-platform-and-self-hosted) in the troubleshooting section.
|
||||
- Run the restore on a test self-hosted instance first to identify any incompatibilities.
|
||||
- Check that all extensions you use are available on the self-hosted Postgres version.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Version mismatches between platform and self-hosted
|
||||
|
||||
The platform may run a newer Postgres version (17 vs 15) and newer Auth service versions than self-hosted. The data dump can contain settings, tables, or columns that don't exist on your new self-hosted instance.
|
||||
|
||||
**Common issues in `data.sql`:**
|
||||
|
||||
- `SET transaction_timeout = 0` - a Postgres 17-only setting that fails on Postgres 15
|
||||
- `COPY` statements for tables that don't exist on self-hosted (e.g., `auth.oauth_clients`, `storage.buckets_vectors`, `storage.vector_indexes`)
|
||||
- `COPY` statements with columns added in newer Auth versions (e.g., `auth.flow_state` with `oauth_client_state_id`, `linking_target_id`)
|
||||
|
||||
**Workaround:** Edit `data.sql` before restoring:
|
||||
|
||||
```bash
|
||||
# Comment out PG17-only transaction_timeout
|
||||
sed -i 's/^SET transaction_timeout/-- &/' data.sql
|
||||
```
|
||||
|
||||
For missing tables or column mismatches, comment out the relevant `COPY ... FROM stdin;` line and its corresponding `\.` terminator. Run the restore without `--single-transaction` first to identify all failures, then fix them and run the final restore with `--single-transaction`.
|
||||
|
||||
Keeping your self-hosted configuration [up to date](https://github.com/supabase/supabase/blob/master/docker/CHANGELOG.md) will minimize these gaps.
|
||||
|
||||
### Extension not available
|
||||
|
||||
If the restore fails because an extension isn't available, check whether it's supported on your self-hosted Postgres version. You can list available extensions with:
|
||||
|
||||
```sql
|
||||
select * from pg_available_extensions;
|
||||
```
|
||||
|
||||
### Connection refused
|
||||
|
||||
Make sure your self-hosted Postgres port is accessible. In the default [self-hosted Supabase](/docs/guides/self-hosting/docker#accessing-postgres) setup, the user is `postgres.your-tenant-id` with Supavisor on port `5432`.
|
||||
|
||||
### Legacy Studio configuration
|
||||
|
||||
Studio in self-hosted Supabase historically used `supabase_admin` role (superuser) instead of `postgres`. Objects created via Studio UI were owned by `supabase_admin`. Check your `docker-compose.yml` [configuration](https://github.com/supabase/supabase/blob/2cb5befaa377a42b6d6ca152b98105b59054f2f4/docker/docker-compose.yml#L30) to see if `POSTGRES_USER_READ_WRITE` is set to `postgres`.
|
||||
|
||||
### Custom roles missing passwords
|
||||
|
||||
If you created custom database roles with the `LOGIN` attribute on your platform project, their passwords are not included in the dump. Set them manually after restore:
|
||||
|
||||
```sql
|
||||
ALTER ROLE your_custom_role WITH PASSWORD 'new-password';
|
||||
```
|
||||
|
||||
### Additional resources
|
||||
|
||||
- [Backup and Restore using the CLI](/docs/guides/platform/migrating-within-supabase/backup-restore)
|
||||
- [Restore Dashboard backup](/docs/guides/platform/migrating-within-supabase/dashboard-restore)
|
||||
@@ -0,0 +1,158 @@
|
||||
---
|
||||
title: 'Configure S3 Storage'
|
||||
description: 'Enable S3-compatible client endpoint and set up an S3 backend for self-hosted Supabase Storage.'
|
||||
subtitle: 'Enable S3-compatible client endpoint and set up an S3 backend for self-hosted Supabase Storage.'
|
||||
---
|
||||
|
||||
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 */}
|
||||
- **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.
|
||||
|
||||
## Enable the S3 protocol endpoint
|
||||
|
||||
The S3 protocol endpoint at `/storage/v1/s3` allows standard S3 clients to interact with your self-hosted Storage instance. It works with any storage backend, including the default file-based storage - you do not need to configure an S3 backend first. The Supabase REST API and SDK do not use the S3 protocol.
|
||||
|
||||
Make sure to check that `REGION`, `S3_PROTOCOL_ACCESS_KEY_ID` and `S3_PROTOCOL_ACCESS_KEY_SECRET` are properly configured in you `.env` file. Read more about the secrets and passwords in [Configuring and securing Supabase](/docs/guides/self-hosting/docker#configuring-and-securing-supabase).
|
||||
|
||||
```yaml
|
||||
storage:
|
||||
environment:
|
||||
# ... existing variables ...
|
||||
REGION: ${REGION}
|
||||
S3_PROTOCOL_ACCESS_KEY_ID: ${S3_PROTOCOL_ACCESS_KEY_ID}
|
||||
S3_PROTOCOL_ACCESS_KEY_SECRET: ${S3_PROTOCOL_ACCESS_KEY_SECRET}
|
||||
```
|
||||
|
||||
### Test with the AWS CLI
|
||||
|
||||
```bash
|
||||
( set -a && \
|
||||
source .env > /dev/null 2>&1 && \
|
||||
echo "" && \
|
||||
AWS_ACCESS_KEY_ID=$S3_PROTOCOL_ACCESS_KEY_ID \
|
||||
AWS_SECRET_ACCESS_KEY=$S3_PROTOCOL_ACCESS_KEY_SECRET \
|
||||
aws s3 ls \
|
||||
--endpoint-url http://localhost:8000/storage/v1/s3 \
|
||||
--region $REGION \
|
||||
s3://your-storage-bucket )
|
||||
```
|
||||
|
||||
{/* supa-mdx-lint-disable-next-line Rule003Spelling */}
|
||||
|
||||
### Test with rclone
|
||||
|
||||
```bash
|
||||
( set -a && \
|
||||
source .env > /dev/null 2>&1 && \
|
||||
echo "" && \
|
||||
rclone ls \
|
||||
--s3-endpoint http://localhost:8000/storage/v1/s3 \
|
||||
--s3-region $REGION \
|
||||
--s3-provider Other \
|
||||
--s3-access-key-id "$S3_PROTOCOL_ACCESS_KEY_ID" \
|
||||
--s3-secret-access-key "$S3_PROTOCOL_ACCESS_KEY_SECRET" \
|
||||
:s3:your-storage-bucket )
|
||||
```
|
||||
|
||||
Use `aws login` and `rclone config` for persistent configuration.
|
||||
|
||||
## How to configure an S3 backend
|
||||
|
||||
In general, the following configuration variables define S3 backend configuration for Storage in `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
storage:
|
||||
environment:
|
||||
# ... existing variables ...
|
||||
STORAGE_BACKEND: s3
|
||||
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'
|
||||
AWS_ACCESS_KEY_ID: your-access-key-id
|
||||
AWS_SECRET_ACCESS_KEY: your-secret-access-key
|
||||
REGION: your-region
|
||||
```
|
||||
|
||||
{/* supa-mdx-lint-disable-next-line Rule003Spelling */}
|
||||
Depending on your setup, you may need to adjust these values - for example, to use a local S3-compatible service like MinIO or a cloud provider like AWS.
|
||||
|
||||
{/* 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:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.yml -f docker-compose.s3.yml up -d
|
||||
```
|
||||
|
||||
Make sure to review the Storage section in your `.env` file for related configuration options.
|
||||
|
||||
### Using AWS S3
|
||||
|
||||
Create an S3 bucket and an IAM user with access to it. Then configure the storage service:
|
||||
|
||||
```yaml docker-compose.yml
|
||||
storage:
|
||||
environment:
|
||||
# ... existing variables ...
|
||||
STORAGE_BACKEND: s3
|
||||
GLOBAL_S3_BUCKET: your-aws-bucket-name
|
||||
AWS_ACCESS_KEY_ID: your-aws-access-key
|
||||
AWS_SECRET_ACCESS_KEY: your-aws-secret-key
|
||||
REGION: your-aws-region
|
||||
```
|
||||
|
||||
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.:
|
||||
|
||||
```yaml
|
||||
storage:
|
||||
environment:
|
||||
# ... existing variables ...
|
||||
STORAGE_BACKEND: s3
|
||||
GLOBAL_S3_BUCKET: your-bucket-name
|
||||
GLOBAL_S3_ENDPOINT: https://your-account-id.r2.cloudflarestorage.com
|
||||
```
|
||||
|
||||
## 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.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Signature mismatch errors
|
||||
|
||||
S3 clients sign requests using the access key ID and secret. If you see `SignatureDoesNotMatch`, verify that the `REGION`, `S3_PROTOCOL_ACCESS_KEY_ID` and `S3_PROTOCOL_ACCESS_KEY_SECRET` in your `.env` file match what your S3 client is using.
|
||||
|
||||
### TUS upload errors on Cloudflare R2
|
||||
|
||||
If resumable (TUS) uploads fail with HTTP 500 and a message about `x-amz-tagging`, add `TUS_ALLOW_S3_TAGS: "false"` to the storage service environment. Cloudflare R2 does not implement this S3 feature.
|
||||
|
||||
### Permission denied on uploads
|
||||
|
||||
Setting a bucket to "Public" only allows unauthenticated **downloads**. Uploads are always blocked unless you create an RLS policy on the `storage.objects` table. Go to **Storage** > **Files** > **Policies** in Studio and create a policy that allows `INSERT` for the appropriate roles.
|
||||
|
||||
### Upload URLs point to localhost
|
||||
|
||||
If uploads from a browser fail (CORS or mixed content errors), check that `API_EXTERNAL_URL` and `SUPABASE_PUBLIC_URL` in your `.env` file match your actual domain and protocol - not `http://localhost:8000`.
|
||||
|
||||
### Additional resources
|
||||
|
||||
- [Storage repository `.env.sample`](https://github.com/supabase/storage/blob/master/.env.sample)
|
||||
- [S3 Authentication](/docs/guides/storage/s3/authentication)
|
||||
@@ -126,7 +126,7 @@ Here's a list of the most common error codes and their potential resolutions:
|
||||
Indicates that the resource is not found or you don't have the correct permission to access it
|
||||
**Resolution:**
|
||||
|
||||
- Add a RLS policy to grant permission to the resource. See our [Access Control docs](/docs/guides/storage/security/access-control) for more information.
|
||||
- Add an RLS policy to grant permission to the resource. See our [Access Control docs](/docs/guides/storage/security/access-control) for more information.
|
||||
- Ensure you include the user `Authorization` header
|
||||
- Verify the object exists
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ The following table lists the supported destinations and the required setup conf
|
||||
| Loki | HTTP | URL <br /> Headers |
|
||||
| Sentry | HTTP | DSN |
|
||||
| Amazon S3 | AWS SDK | S3 Bucket <br/> Region <br/> Access Key ID <br/> Secret Access Key <br/> Batch Timeout |
|
||||
| OTLP | HTTP | Endpoint <br /> Protocol <br/> Gzip <br /> Headers |
|
||||
|
||||
HTTP requests are batched with a max of 250 logs or 1 second intervals, whichever happens first. Logs are compressed via Gzip if the destination supports it.
|
||||
|
||||
@@ -81,7 +82,7 @@ This will create an infinite loop, as we are generating an additional log event
|
||||
|
||||
2. Configure the HTTP Drain
|
||||
|
||||
Create a HTTP drain under the [Project Settings > Log Drains](/dashboard/project/_/settings/log-drains).
|
||||
Create an HTTP drain under the [Project Settings > Log Drains](/dashboard/project/_/settings/log-drains).
|
||||
|
||||
- Disable the Gzip, as we want to receive the payload without compression.
|
||||
- Under URL, set it to your edge function URL `https://[PROJECT REF].supabase.co/functions/v1/hello-world`
|
||||
@@ -219,12 +220,12 @@ with timestamp modified to be parsed by ingestion endpoint.
|
||||
|
||||
To set up the Axiom log drain, you have to:
|
||||
|
||||
1. Create a dataset for ingestion in Axiom dashboard -> Datasets
|
||||
1. Create a dataset for ingestion in Axiom Console -> Datasets
|
||||
2. Generate an Axiom API Token with permission to ingest into the created dataset (see [Axiom docs](https://axiom.co/docs/reference/tokens#create-basic-api-token))
|
||||
3. Create log drain in [Supabase dashboard](/dashboard/project/_/settings/log-drains), providing:
|
||||
- Name of the dataset
|
||||
- API token
|
||||
4. Watch for events in the Stream panel of Axiom dashboard
|
||||
4. Watch for events in the Stream panel of Axiom Console
|
||||
|
||||
## Amazon S3
|
||||
|
||||
@@ -236,7 +237,7 @@ Required configuration when creating an S3 Log Drain:
|
||||
- Region: the AWS region where the bucket is located.
|
||||
- Access Key ID: used for authentication.
|
||||
- Secret Access Key: used for authentication.
|
||||
- Batch Timeout (ms): maximum time to wait before flushing a batch. Recommended 2000–5000ms.
|
||||
- Batch Timeout (ms): maximum time to wait before flushing a batch. Recommended 2000-5000ms.
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
@@ -244,6 +245,113 @@ Ensure the AWS account tied to the Access Key ID has permissions to write to the
|
||||
|
||||
</Admonition>
|
||||
|
||||
## OpenTelemetry protocol (OTLP)
|
||||
|
||||
Logs are sent to any OTLP-compatible endpoint using the OpenTelemetry Protocol over HTTP with Protocol Buffers encoding.
|
||||
|
||||
OTLP is an open-standard protocol for telemetry data, making it compatible with many observability platforms including:
|
||||
|
||||
<ul>
|
||||
<li>OpenTelemetry Collector</li>
|
||||
<li>Grafana Cloud</li>
|
||||
<li>New Relic</li>
|
||||
<li>Honeycomb</li>
|
||||
<li>Datadog (OTLP ingestion)</li>
|
||||
<li>Elastic</li>
|
||||
<li>And many more</li>
|
||||
</ul>
|
||||
|
||||
Required configuration when creating an OTLP Log Drain:
|
||||
|
||||
<ul>
|
||||
<li>Endpoint: The full URL of your OTLP HTTP endpoint (typically ending in `/v1/logs`)</li>
|
||||
<li>Protocol: Currently only `http/protobuf` is supported</li>
|
||||
<li>Gzip: Enable compression to reduce bandwidth (recommended: enabled)</li>
|
||||
<li>Headers: Optional authentication headers (e.g., `Authorization`, `X-API-Key`)</li>
|
||||
</ul>
|
||||
|
||||
Logs are sent as OTLP log record messages using Protocol Buffers encoding, following the [OpenTelemetry Logs specification](https://opentelemetry.io/docs/specs/otel/logs/).
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
Ensure your OTLP endpoint is configured to accept logs at the `/v1/logs` path with `application/x-protobuf` content type.
|
||||
|
||||
</Admonition>
|
||||
|
||||
<Accordion
|
||||
type="default"
|
||||
openBehaviour="multiple"
|
||||
>
|
||||
<AccordionItem
|
||||
header="OpenTelemetry Collector Example"
|
||||
id="otel-collector"
|
||||
>
|
||||
|
||||
To receive Supabase logs with the OpenTelemetry Collector, configure an OTLP HTTP receiver:
|
||||
|
||||
```yaml
|
||||
receivers:
|
||||
otlp:
|
||||
protocols:
|
||||
http:
|
||||
endpoint: 0.0.0.0:4318
|
||||
|
||||
processors:
|
||||
batch:
|
||||
|
||||
exporters:
|
||||
logging:
|
||||
loglevel: debug
|
||||
|
||||
service:
|
||||
pipelines:
|
||||
logs:
|
||||
receivers: [otlp]
|
||||
processors: [batch]
|
||||
exporters: [logging]
|
||||
```
|
||||
|
||||
Then create a log drain in [Supabase dashboard](/dashboard/project/_/settings/log-drains) with:
|
||||
|
||||
<ul>
|
||||
<li>Endpoint: `https://your-collector:4318/v1/logs`</li>
|
||||
<li>Add authentication headers as needed for your setup</li>
|
||||
</ul>
|
||||
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem
|
||||
header="Authentication Examples"
|
||||
id="auth"
|
||||
|
||||
>
|
||||
|
||||
Different OTLP platforms use different authentication methods. Add headers accordingly:
|
||||
|
||||
**API Key Authentication:**
|
||||
|
||||
```
|
||||
X-API-Key: your-api-key
|
||||
```
|
||||
|
||||
**Bearer Token:**
|
||||
|
||||
```
|
||||
Authorization: Bearer your-token
|
||||
```
|
||||
|
||||
**Basic Authentication:**
|
||||
|
||||
```
|
||||
Authorization: Basic base64(username:password)
|
||||
```
|
||||
|
||||
Refer to your observability platform's documentation for specific authentication requirements.
|
||||
|
||||
</AccordionItem>
|
||||
|
||||
</Accordion>
|
||||
|
||||
## Pricing
|
||||
|
||||
For a detailed breakdown of how charges are calculated, refer to [Manage Log Drain usage](/docs/guides/platform/manage-your-usage/log-drains).
|
||||
+1
@@ -2,6 +2,7 @@
|
||||
title = "App Store Rejection: 'TLS error' in IPv6-only environments"
|
||||
topics = [ "platform" ]
|
||||
keywords = []
|
||||
database_id = "ce8c04e4-d493-4e15-832a-e59bdcf2b093"
|
||||
---
|
||||
|
||||
If your App Store submission is rejected with a 'TLS error' when tested in an IPv6-only environment, often citing a lack of AAAA records, it typically indicates application-level issues rather than a Supabase configuration problem.
|
||||
|
||||
+2
-1
@@ -2,10 +2,11 @@
|
||||
title = "Auth Hooks: 'Invalid payload' when anonymous users attempt phone changes"
|
||||
topics = [ "auth", "cli" ]
|
||||
keywords = []
|
||||
database_id = "c1de4561-e95f-41e3-b298-fac9ae331a54"
|
||||
|
||||
[[errors]]
|
||||
http_status_code = 500
|
||||
message = "Invalid payload sent to hook"
|
||||
|
||||
---
|
||||
|
||||
An 'Invalid payload sent to hook' error (500) occurs in Auth hooks when the payload includes `new_phone` for an anonymous user.
|
||||
|
||||
+1
@@ -2,6 +2,7 @@
|
||||
title = "Autovacuum Stalled Due to Inactive Replication Slot"
|
||||
topics = [ "database" ]
|
||||
keywords = []
|
||||
database_id = "a931b8af-3210-4188-bb03-87452923a498"
|
||||
---
|
||||
|
||||
If you observe that `supabase inspect db vacuum-stats` reports "Expect autovacuum? yes" for your tables, but autovacuum activity has been inactive for an extended period, leading to increasing database RAM usage, this typically indicates a stalled autovacuum process. One of the reasons for autovacuum to get stalled is an inactive replication slot for which this guide talks about.
|
||||
|
||||
+2
@@ -2,6 +2,8 @@
|
||||
title = "'Cloudflare Origin Error 1016' on Custom Domain"
|
||||
topics = [ "platform" ]
|
||||
keywords = []
|
||||
database_id = "a60ee728-3add-438d-b8bf-433f1746cb3e"
|
||||
|
||||
[[errors]]
|
||||
code = "1016"
|
||||
message = "Cloudflare Origin Error"
|
||||
|
||||
@@ -38,7 +38,7 @@ It can be accessed in a project's [Email Templates](/dashboard/project/_/auth/te
|
||||
|
||||
If you need to update a user's meta-data, you can do so with the [`updateUser`](/docs/reference/javascript/auth-updateuser?example=update-the-users-metadata) function.
|
||||
|
||||
The meta-data can be used to store a users language preferences. You could then use "if statements" in the email template to set the response for a specific language:
|
||||
The meta-data can be used to store a user's language preferences. You could then use "if statements" in the email template to set the response for a specific language:
|
||||
|
||||
```html
|
||||
{{if eq .Data.language "en" }}
|
||||
|
||||
@@ -4,6 +4,7 @@ github_url = "https://github.com/orgs/supabase/discussions/16703"
|
||||
date_created = "2023-08-22T13:17:50+00:00"
|
||||
topics = ["database"]
|
||||
keywords = ["rls", "deprecated", "auth", "policy"]
|
||||
database_id = "58c0cb7c-50a0-4a96-9551-bc97c28b7393"
|
||||
---
|
||||
|
||||
## The `auth.role()` function is now deprecated
|
||||
|
||||
+1
@@ -4,6 +4,7 @@ github_url = "https://github.com/orgs/supabase/discussions/16784"
|
||||
date_created = "2023-08-24T13:45:01+00:00"
|
||||
topics = ["database"]
|
||||
keywords = ["security", "function", "schema", "policy", "definer"]
|
||||
database_id = "1e02e989-fc12-4838-b304-c7d1356f6d2c"
|
||||
---
|
||||
|
||||
PostgREST supports 2 config parameters:
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
title = "Edge Function 546 error response"
|
||||
topics = [ "functions" ]
|
||||
keywords = [ "546", "error", "resource", "memory", "cpu", "event loop", "edge function" ]
|
||||
database_id = "4e6ff2e4-2abb-4fba-8233-5883b3d56fb0"
|
||||
|
||||
[[errors]]
|
||||
http_status_code = 546
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
title = "Edge Function bundle size issues"
|
||||
topics = [ "functions" ]
|
||||
keywords = [ "bundle", "size", "limit", "dependencies", "edge function", "10MB" ]
|
||||
database_id = "aaf9e673-64ae-460a-88e0-b83ea4963382"
|
||||
|
||||
[api]
|
||||
cli = ["supabase-functions-deploy"]
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
title = "Understanding Edge Function CPU limits"
|
||||
topics = [ "functions" ]
|
||||
keywords = [ "CPU", "limit", "isolate", "soft limit", "hard limit", "edge function" ]
|
||||
database_id = "1765884f-81d6-415a-a78f-7085f7b7ddbf"
|
||||
---
|
||||
|
||||
Learn how Edge Functions manage CPU resources and what happens when limits are reached.
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
title = "Edge Function dependency analysis"
|
||||
topics = [ "functions" ]
|
||||
keywords = [ "dependencies", "npm", "deno", "imports", "bundle", "optimization", "edge function" ]
|
||||
database_id = "e079a9d0-419a-4e31-b7ec-1206d9012d0b"
|
||||
---
|
||||
|
||||
Optimize your Edge Function dependencies for better performance. Large or unnecessary dependencies can significantly impact bundle size, boot time, and memory usage.
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
title = "Monitoring Edge Function resource usage"
|
||||
topics = [ "functions" ]
|
||||
keywords = [ "monitoring", "metrics", "CPU", "memory", "performance", "edge function" ]
|
||||
database_id = "33e8e407-951b-4f7d-b8b4-8e9085cd4d10"
|
||||
---
|
||||
|
||||
Learn how to track your Edge Function's performance and identify potential resource issues.
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
title = "Edge Function shutdown reasons explained"
|
||||
topics = [ "functions" ]
|
||||
keywords = [ "shutdown", "termination", "event loop", "wall clock", "cpu time", "memory", "early drop" ]
|
||||
database_id = "109b964f-c28c-4554-b059-cd8b165c63f8"
|
||||
|
||||
[[errors]]
|
||||
http_status_code = 546
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
title = "Edge Function takes too long to respond"
|
||||
topics = [ "functions" ]
|
||||
keywords = [ "slow", "timeout", "performance", "boot", "response time", "edge function" ]
|
||||
database_id = "89b868a9-17fe-4c6d-86f6-0b04e5794678"
|
||||
---
|
||||
|
||||
Edge Functions have a 60-second execution limit. If your function is taking too long to respond, follow these steps to diagnose and optimize performance.
|
||||
|
||||
+1
@@ -2,6 +2,7 @@
|
||||
title = "Email/Password Login Disabled for Supabase Accounts Created via Vercel Marketplace"
|
||||
topics = [ "auth" ]
|
||||
keywords = [ "Vercel"]
|
||||
database_id = "f988e832-b0c3-4b30-8e20-8d5854943b32"
|
||||
---
|
||||
|
||||
If your Supabase account was initiated via the Vercel Marketplace, you will not be able to log in directly using email/password or reset your password. This is because such accounts are tightly coupled to Vercel's authentication system, restricting login to Vercel's integrated methods.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
title = "Enabling the IPv4 add-on FAQ"
|
||||
topics = [ "database", "platform", "supavisor" ]
|
||||
database_id = "02f5f200-3c1c-425e-945f-b80b3fce3ec0"
|
||||
---
|
||||
|
||||
Enabling the IPv4 add-on will attach an IPv4 address to your project's compute instance, while preserving the existing IPv6 address. Both DNS records `ref.supabase.co` and `db.ref.supabase.co` will be updated to point to the IPv4 address.
|
||||
@@ -9,7 +10,7 @@ Enabling the IPv4 add-on will attach an IPv4 address to your project's compute i
|
||||
|
||||
Enabling the IPv4 add-on will not cause any downtime since existing connections will not be dropped. New inbound connections (both direct connections on ports 5432 and 6543, as well as PostgREST requests) will use the newly allocated IPv4 address.
|
||||
|
||||
## Will the project instance will be restarted?
|
||||
## Will the project instance be restarted?
|
||||
|
||||
No, the IPv4 add-on will not require a restart of the instance.
|
||||
|
||||
|
||||
+1
@@ -2,6 +2,7 @@
|
||||
title = "Error: 'invalid byte sequence for encoding 'UTF8': 0x00' when accessing Triggers or Webhooks"
|
||||
topics = [ "cli", "database" ]
|
||||
keywords = []
|
||||
database_id = "12128731-0af6-4c57-9aa2-66e177f0c3f4"
|
||||
---
|
||||
|
||||
If you encounter the error: `'invalid byte sequence for encoding "UTF8": 0x00'` when attempting to access your project's [Triggers](/dashboard/project/_/database/triggers) or [Webhooks](/dashboard/project/_/database/webhooks) via the dashboard, it indicates that the `standard_conforming_strings` database setting is currently `off`.
|
||||
|
||||
+1
@@ -2,6 +2,7 @@
|
||||
title = "'Error: 'Target organization is not managed by Vercel Marketplace' during project transfer'"
|
||||
topics = [ "platform" ]
|
||||
keywords = []
|
||||
database_id = "6083c9eb-b9fc-4d19-bc64-bb4b7efeecdd"
|
||||
---
|
||||
|
||||
If you encounter the error "Target organization is not managed by Vercel Marketplace (currently unsupported)" when attempting a project transfer, it indicates an attempt to move a project from a Supabase-managed organization to a Vercel Marketplace-managed organization. This transfer direction is currently not supported due to existing Vercel Marketplace API limitations.
|
||||
|
||||
+1
@@ -2,6 +2,7 @@
|
||||
title = "'Get detailed Storage metrics with the AWS CLI'"
|
||||
topics = [ "cli", "storage", "studio" ]
|
||||
keywords = []
|
||||
database_id = "2c33968e-26c0-4613-aee1-5de36d068394"
|
||||
---
|
||||
|
||||
Supabase Studio primarily lists the current objects within your buckets. You can use standard S3 tooling such as the AWS CLI to review your Supabase project's Storage usage, or perform operations on the bucket contents.
|
||||
|
||||
+1
-1
@@ -51,7 +51,7 @@ Since the wraparound prevention autovacuum cannot be stopped, the best approach
|
||||
2. **Increase Disk Throughput/IOPS:**
|
||||
- **Action:** If disk I/O utilization is also consistently high (e.g., near 100%), consider temporarily increasing your disk's provisioned IOPS and throughput.
|
||||
- **Why it helps:** Autovacuum is an I/O-intensive operation, involving a lot of reading and writing. Higher disk performance can significantly speed up the process.
|
||||
- **Considerations:** Cloud providers often have limitations, such as a cooldown period (e.g., 6 hours) between disk modification operations.
|
||||
- **Considerations:** Cloud providers often have limitations, such as a cooldown period (e.g., 4 hours) between disk modification operations.
|
||||
|
||||
### **Monitoring progress and future prevention**
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ topics = [
|
||||
"platform"
|
||||
]
|
||||
keywords = ["cooldown", "disk resize"]
|
||||
database_id = "ab90c813-6152-4dad-86f7-937d799ca123"
|
||||
---
|
||||
|
||||
This cooldown period isn't a Supabase limitation. It's rooted in how Amazon EBS (the underlying storage instance for our databases) manages volume modifications. After modifying a volume (e.g. increasing size, changing type, or IOPS), AWS enforces a mandatory 6-hour cooldown before allowing another modification on the same volume. This is to ensure data integrity and stability of the volume under load.
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
title = "Issues serving Edge Functions locally"
|
||||
topics = [ "functions", "cli" ]
|
||||
keywords = [ "local", "serve", "development", "debug", "port", "edge function" ]
|
||||
database_id = "1cff12df-7ad6-48c5-b518-5ea468b54bab"
|
||||
|
||||
[api]
|
||||
cli = ["supabase-functions-serve"]
|
||||
|
||||
@@ -3,6 +3,7 @@ title = "Keeping your 2 Free projects after upgrading to Pro"
|
||||
date_created = "2026-01-23T00:00:00+00:00"
|
||||
topics = [ "platform" ]
|
||||
keywords = [ "free tier", "pro plan", "billing", "organizations", "project transfer" ]
|
||||
database_id = "449482fb-6e21-4a25-99ee-1ce6fffbc975"
|
||||
---
|
||||
|
||||
## Can you keep your 2 free projects after upgrading to pro?
|
||||
|
||||
+1
@@ -2,6 +2,7 @@
|
||||
title = "'Manually created databases are not visible in the Supabase Dashboard'"
|
||||
topics = [ "auth", "cli", "database", "functions", "platform", "storage" ]
|
||||
keywords = []
|
||||
database_id = "f6420e72-ea67-4825-b3f7-e722ea5c0d96"
|
||||
---
|
||||
|
||||
If you've manually created an additional database within your Supabase project, such as `example_database`, you might observe that it's accessible via external database tools but is not visible in the Supabase Dashboard. This guide explains the underlying reasons for this behavior and how Supabase is designed to handle databases.
|
||||
|
||||
+2
-1
@@ -2,6 +2,8 @@
|
||||
title = "'OTP Verification Failures: 'token has expired' or 'otp_expired' errors'"
|
||||
topics = [ "auth", "cli" ]
|
||||
keywords = []
|
||||
database_id = "25eddb73-3cca-485b-b87f-7279dd46b7a7"
|
||||
|
||||
[[errors]]
|
||||
http_status_code = 403
|
||||
message = "Forbidden"
|
||||
@@ -9,7 +11,6 @@ message = "Forbidden"
|
||||
[[errors]]
|
||||
code = "otp_expired"
|
||||
message = "OTP expired"
|
||||
|
||||
---
|
||||
|
||||
When users attempt to exchange One-Time Passwords (OTPs), they may encounter various errors indicating that the token is no longer valid. These include messages like "token has expired or is invalid," "Email link is invalid or has expired," or they might receive '403 Forbidden' HTTP responses on the verification endpoint. Authentication logs often show specific `otp_expired` error codes.
|
||||
|
||||
+1
@@ -2,6 +2,7 @@
|
||||
title = "`pg_cron launcher crashes with 'duplicate key value violates unique constraint'`"
|
||||
topics = [ "platform" ]
|
||||
keywords = []
|
||||
database_id = "c000b4ea-23e2-4e19-af54-63ccd00c4904"
|
||||
---
|
||||
|
||||
The `pg_cron` launcher process crashes approximately every minute, displaying the error `'duplicate key value violates unique constraint "job_run_details_pkey"'`.
|
||||
|
||||
+2
-1
@@ -2,6 +2,8 @@
|
||||
title = "PKCE Flow errors: 'cannot parse response' or '#ZgotmplZ' in magic link emails"
|
||||
topics = [ "auth", "cli" ]
|
||||
keywords = []
|
||||
database_id = "f9f9842b-e66c-4ea3-a45b-713895d8b7c6"
|
||||
|
||||
[[errors]]
|
||||
code = "#ZgotmplZ"
|
||||
message = "Go template sanitization of unsafe URL scheme"
|
||||
@@ -9,7 +11,6 @@ message = "Go template sanitization of unsafe URL scheme"
|
||||
[[errors]]
|
||||
code = "cannot parse response"
|
||||
message = "PKCE flow interrupted by email client or token consumed by link scanner"
|
||||
|
||||
---
|
||||
|
||||
When setting up authentication with magic links and mobile deep linking, you might encounter specific errors like `#ZgotmplZ` in your email templates or a 'cannot parse response' error during the login flow. This guide explains the underlying causes and provides a robust solution.
|
||||
|
||||
+1
@@ -2,6 +2,7 @@
|
||||
title = "PostgREST not recognizing new columns, tables, views or functions"
|
||||
topics = [ "cli", "platform", "database", "functions" ]
|
||||
keywords = []
|
||||
database_id = "c3481097-1a32-4e94-b4f6-7aeb88132a41"
|
||||
---
|
||||
|
||||
If PostgREST is returning errors by not recognizing new database columns, tables, views or functions, and logging errors similar to:
|
||||
|
||||
@@ -63,7 +63,7 @@ Add pgbouncer=true to the connection string.
|
||||
|
||||
## `Max client connections reached`
|
||||
|
||||
Checkout this [guide](https://github.com/orgs/supabase/discussions/22305) for managing this error
|
||||
Check out this [guide](https://github.com/orgs/supabase/discussions/22305) for managing this error
|
||||
|
||||
## `Server has closed the connection`
|
||||
|
||||
|
||||
+1
@@ -2,6 +2,7 @@
|
||||
title = "rclone error: 's3 protocol error: received listing v1 with IsTruncated set, no NextMarker and no Contents'"
|
||||
topics = [ "storage" ]
|
||||
keywords = []
|
||||
database_id = "0dab3e43-2ba9-4421-9af3-b97e45123069"
|
||||
---
|
||||
|
||||
When attempting to list objects from a Supabase bucket using `rclone lsf`, you might encounter the error: `'s3 protocol error: received listing v1 with IsTruncated set, no NextMarker and no Contents'`.
|
||||
|
||||
+1
@@ -2,6 +2,7 @@
|
||||
title = "Realtime: Handling Silent Disconnections in Background Applications"
|
||||
topics = [ "cli", "database", "realtime" ]
|
||||
keywords = []
|
||||
database_id = "b826b34a-f7c0-405e-a836-54c543198964"
|
||||
---
|
||||
|
||||
If your Supabase Realtime subscriptions stop receiving events after some time without any explicit error messages, you might be experiencing a silent disconnection. This guide explains why this occurs and provides robust solutions to maintain connection stability.
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
title = "Realtime: Project suspended for exceeding quotas"
|
||||
date_created = "2026-02-06T00:00:00+00:00"
|
||||
topics = [ "realtime" ]
|
||||
keywords = [ "suspended", "quota", "abuse", "limits", "connections", "messages", "ban", "RealtimeDisabledForTenant", "disabled" ]
|
||||
---
|
||||
|
||||
If your project has been suspended due to Realtime usage, it means your project exceeded the quotas for your plan and was flagged for unusually high consumption of Realtime resources.
|
||||
|
||||
## How to know if your project was suspended
|
||||
|
||||
When Realtime is disabled for your project, you will see the error code `RealtimeDisabledForTenant` in your [Realtime logs](/dashboard/project/_/database/realtime-logs). On the client side, connections will fail to establish and existing subscriptions will stop receiving events.
|
||||
|
||||
If you encounter this error, it means Realtime has been explicitly disabled for your project and you should [contact support](/dashboard/support/new) to understand why and resolve the issue.
|
||||
|
||||
## Why projects get suspended
|
||||
|
||||
Supabase monitors Realtime usage across all projects to ensure platform stability for everyone. When a project consistently exceeds its plan limits by a significant margin, it may be manually suspended. This can happen when:
|
||||
|
||||
- Your project far exceeds the [concurrent connections limit](/docs/guides/realtime/limits#limits-by-plan) for your plan
|
||||
- Your project sends or receives messages well beyond the [messages per second limit](/docs/guides/realtime/limits#limits-by-plan)
|
||||
- Usage patterns suggest unintended or runaway behavior, such as a client reconnection loop or uncontrolled channel creation
|
||||
|
||||
Suspension is not automatic and is applied after review. The goal is to protect shared infrastructure while giving you the opportunity to explain and resolve the situation.
|
||||
|
||||
## Common causes of excessive usage
|
||||
|
||||
In most cases, quota overages are accidental rather than intentional:
|
||||
|
||||
- **Reconnection loops**: A client that fails to authenticate or subscribe may retry rapidly, creating thousands of short-lived connections
|
||||
- **Uncontrolled channels**: Creating channels without cleaning them up leads to resource exhaustion (see [Fixing the TooManyChannels Error](/docs/troubleshooting/realtime-too-many-channels-error))
|
||||
- **Missing cleanup on component tear down**: Single-page applications that don't unsubscribe when components are removed can accumulate connections over time
|
||||
- **Unexpected traffic spikes**: A viral event or bot traffic can push usage well beyond normal levels
|
||||
- **Development or testing misconfiguration**: Load tests or staging environments accidentally pointed at a production project
|
||||
|
||||
## What to do if your project is suspended
|
||||
|
||||
1. **Open a support ticket**: [Contact support](/dashboard/support/new) and include:
|
||||
|
||||
- Your project reference ID
|
||||
- A description of your Realtime use case (what features use Broadcast, Presence, or Postgres Changes)
|
||||
- An estimate of your expected concurrent connections and message throughput
|
||||
- Any recent changes to your application that may have caused the spike
|
||||
|
||||
2. **Review your usage**: While waiting for a response, check the [Realtime reports](/dashboard/project/_/database/realtime-logs) in your project dashboard to understand what drove the elevated usage.
|
||||
|
||||
3. **Identify and fix the root cause**: Common fixes include:
|
||||
- Adding proper channel cleanup in your client code
|
||||
- Implementing exponential backoff for reconnection logic
|
||||
- Upgrading your plan to match your actual usage needs
|
||||
- Separating development and production environments
|
||||
|
||||
The Supabase team will review your case to understand whether the usage was accidental or expected. If the usage is legitimate, the team can work with you to find the right plan or adjust limits for your project. If it was accidental, once you've resolved the underlying issue, the suspension can be lifted.
|
||||
|
||||
## How to avoid suspension
|
||||
|
||||
- Monitor your usage regularly through the [Realtime reports](/dashboard/project/_/database/realtime-logs) page
|
||||
- Set up alerts if your connections or messages approach your plan limits
|
||||
- Follow the [channel management best practices](/docs/troubleshooting/realtime-too-many-channels-error#best-practices-for-channel-management) to avoid resource leaks
|
||||
- Review the [Realtime limits](/docs/guides/realtime/limits) for your plan and upgrade before you outgrow them
|
||||
- Use the [Realtime Inspector](https://realtime.supabase.com/inspector/new) to test and debug connections before deploying
|
||||
|
||||
## Related troubleshooting guides
|
||||
|
||||
- [Fixing the TooManyChannels Error](/docs/troubleshooting/realtime-too-many-channels-error) — channel lifecycle management and cleanup best practices
|
||||
- [Concurrent Peak Connections quota](/docs/troubleshooting/realtime-concurrent-peak-connections-quota-jdDqcp) — understanding the concurrent connections quota and how to adjust it
|
||||
- [Handling Silent Disconnections in Background Applications](/docs/troubleshooting/realtime-handling-silent-disconnections-in-backgrounded-applications-592794) — fixing WebSocket drops caused by browser or OS background mode
|
||||
- [TIMED_OUT connection errors](/docs/troubleshooting/realtime-connections-timed_out-status) — resolving Node.js version incompatibilities
|
||||
- [Debug Realtime with Logger and Log Levels](/docs/troubleshooting/realtime-debugging-with-logger) — enabling client-side logging to diagnose connection and message issues
|
||||
- [Realtime Heartbeat Messages](/docs/troubleshooting/realtime-heartbeat-messages) — monitoring connection health with heartbeat callbacks
|
||||
@@ -2,6 +2,7 @@
|
||||
title = "Running EXPLAIN ANALYZE on functions"
|
||||
topics = ["database", "functions"]
|
||||
keywords = [] # any strings (topics are automatically added so no need to duplicate)
|
||||
database_id = "1d62cace-c0f6-47a0-8690-002a797da33b"
|
||||
|
||||
[api]
|
||||
sdk = ["rpc"]
|
||||
|
||||
+3
-2
@@ -2,13 +2,14 @@
|
||||
title = "'Scan error on column confirmation_token: converting NULL to string is unsupported' during Auth login"
|
||||
topics = [ "auth" ]
|
||||
keywords = []
|
||||
database_id = "02a26b95-3029-49a1-9319-161cf1c4c21d"
|
||||
|
||||
[[errors]]
|
||||
http_status_code = 500
|
||||
message = "error finding user: sql: Scan error on column 'confirmation_token': converting NULL to string is unsupported"
|
||||
|
||||
---
|
||||
|
||||
If you encounter a HTTP 500 error during authentication with the message `error finding user: sql: Scan error on column "confirmation_token": converting NULL to string is unsupported`, this typically indicates that the GoTrue Auth service found a `NULL` value in the `auth.users.confirmation_token` column, where a non-nullable string is expected.
|
||||
If you encounter an HTTP 500 error during authentication with the message `error finding user: sql: Scan error on column "confirmation_token": converting NULL to string is unsupported`, this typically indicates that the GoTrue Auth service found a `NULL` value in the `auth.users.confirmation_token` column, where a non-nullable string is expected.
|
||||
|
||||
To resolve this, update the `confirmation_token` to an empty string where it is `NULL`. You can execute the following SQL query in the [SQL Editor](/dashboard/project/_/sql/new):
|
||||
|
||||
|
||||
+1
@@ -4,6 +4,7 @@ topics = [ "database" ]
|
||||
keywords = [
|
||||
"Bolt"
|
||||
]
|
||||
database_id = "d64f5f5d-ef80-4423-ba24-1a79b4e69ce6"
|
||||
---
|
||||
|
||||
If your Supabase project, provisioned through Bolt's Claude Agent, isn't appearing in your Supabase dashboard, it indicates an ownership difference.
|
||||
|
||||
+1
@@ -2,6 +2,7 @@
|
||||
title = "'Supabase Storage: Inefficient folder operations and hierarchical RLS challenges'"
|
||||
topics = [ "functions", "storage" ]
|
||||
keywords = []
|
||||
database_id = "3b52daf2-d78d-4630-8e9f-8bf5d90208bf"
|
||||
---
|
||||
|
||||
Supabase Storage lacks native folder concepts or APIs for batch folder operations, which can lead to inefficient folder operations (move, rename, delete) and difficulties in implementing hierarchical access controls for objects.
|
||||
|
||||
+1
@@ -2,6 +2,7 @@
|
||||
title = "Transfer edge functions from one project to another"
|
||||
topics = ["cli", "database", "functions"]
|
||||
keywords = ["functions", "typescript", "deno"]
|
||||
database_id = "99b787e0-9ec5-4268-a07d-4d6c377cb1ac"
|
||||
---
|
||||
|
||||
This guide shows how you can transfer your Edge Functions from one project to another using the Supabase CLI or the Supabase Dashboard.
|
||||
|
||||
+28
-5
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title = "Transferring from cloud to self-host in Supabase"
|
||||
title = "Transferring from platform to self-hosted Supabase"
|
||||
github_url = "https://github.com/orgs/supabase/discussions/22712"
|
||||
date_created = "2024-04-14T15:19:10+00:00"
|
||||
topics = [ "database", "self-hosting" ]
|
||||
@@ -7,10 +7,33 @@ keywords = [ "migrate", "pg_dump", "psql", "self-host" ]
|
||||
database_id = "c6b6ae3c-1b5b-4ba8-a40f-5f2ca1f007e2"
|
||||
---
|
||||
|
||||
To migrate from cloud to self-hosting, you can use the [pg_dump](https://www.postgresql.org/docs/9.6/app-pgdump.html) command to export your database to an SQL file, which then you can run on any database to load the same data in.
|
||||
For a detailed, step-by-step guide on restoring your database from the Supabase platform to a [self-hosted Supabase](/docs/guides/self-hosting) instance, see [Restore a Platform Project to Self-Hosted](/docs/guides/self-hosting/restore-from-platform).
|
||||
|
||||
You can then try to import your SQL files using psql from the terminal:
|
||||
### Quick reference
|
||||
|
||||
`psql -h 127.0.0.1 -p 5432 -d postgres -U postgres -f <dump-file-name>.sql`
|
||||
Back up your cloud database:
|
||||
|
||||
You can also find some useful information about self-hosting here: https://supabase.com/docs/guides/self-hosting.
|
||||
```bash
|
||||
supabase db dump --db-url "[CONNECTION_STRING]" -f roles.sql --role-only
|
||||
```
|
||||
|
||||
```bash
|
||||
supabase db dump --db-url "[CONNECTION_STRING]" -f schema.sql
|
||||
```
|
||||
|
||||
```bash
|
||||
supabase db dump --db-url "[CONNECTION_STRING]" -f data.sql --use-copy --data-only
|
||||
```
|
||||
|
||||
Restore to your self-hosted instance:
|
||||
|
||||
```bash
|
||||
psql \
|
||||
--single-transaction \
|
||||
--variable ON_ERROR_STOP=1 \
|
||||
--file roles.sql \
|
||||
--file schema.sql \
|
||||
--command 'SET session_replication_role = replica' \
|
||||
--file data.sql \
|
||||
--dbname "postgres://postgres.your-tenant-id:[POSTGRES_PASSWORD]@[your-domain]:5432/postgres"
|
||||
```
|
||||
@@ -2,6 +2,7 @@
|
||||
title = "Unable to call Edge Function"
|
||||
topics = [ "functions" ]
|
||||
keywords = [ "invoke", "call", "CORS", "authentication", "JWT", "edge function" ]
|
||||
database_id = "cd298838-6f13-4b70-a198-a8bd26b2c0bd"
|
||||
---
|
||||
|
||||
If you're having trouble invoking an Edge Function or experiencing CORS issues, follow these steps to diagnose and resolve the problem.
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
title = "Unable to deploy Edge Function"
|
||||
topics = [ "functions" ]
|
||||
keywords = [ "deploy", "deployment", "edge function", "deno", "syntax", "bundle" ]
|
||||
database_id = "68bb6df6-6a07-40fa-ad4d-dfa5e21d7e9c"
|
||||
|
||||
[api]
|
||||
cli = ["supabase-functions-deploy"]
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
title = "UNUSED_EXTERNAL_IMPORT build warning with Vite, Rollup, or Nuxt"
|
||||
topics = [ "platform" ]
|
||||
keywords = [ "UNUSED_EXTERNAL_IMPORT", "vite", "rollup", "nuxt", "build warning", "false positive", "bundler", "supabase-js" ]
|
||||
---
|
||||
|
||||
When bundling an application that uses `@supabase/supabase-js`, you may see warnings like:
|
||||
|
||||
```
|
||||
"PostgrestError" is imported from external module "@supabase/postgrest-js" but never used in "...supabase-js/dist/index.mjs".
|
||||
"FunctionRegion", "FunctionsError", "FunctionsFetchError", "FunctionsHttpError" and "FunctionsRelayError" are imported from external module "@supabase/functions-js" but never used in "...".
|
||||
```
|
||||
|
||||
**This is a false positive — your bundle is correct and no code is missing.**
|
||||
|
||||
## Why this happens
|
||||
|
||||
`@supabase/supabase-js` re-exports error types like `PostgrestError` and `FunctionsError` so you can import them directly from `@supabase/supabase-js`. The build tool merges all imports from the same package into a single statement in the output:
|
||||
|
||||
```js
|
||||
// dist/index.mjs (simplified)
|
||||
import { PostgrestClient, PostgrestError } from '@supabase/postgrest-js'
|
||||
// ^ used internally ^ re-exported for you
|
||||
```
|
||||
|
||||
Vite/Rollup checks which names from that import are referenced _in the code body_ and flags `PostgrestError` as unused, because it only appears in an `export` statement — not called or assigned. The export itself is the real usage, but this check doesn't account for re-exports. Tree-shaking and bundle size are unaffected.
|
||||
|
||||
## Suppress the warning
|
||||
|
||||
### Vite / Rollup (`vite.config.js` or `rollup.config.js`)
|
||||
|
||||
```js
|
||||
export default {
|
||||
build: {
|
||||
rollupOptions: {
|
||||
onwarn(warning, warn) {
|
||||
if (warning.code === 'UNUSED_EXTERNAL_IMPORT' && warning.exporter?.includes('@supabase/'))
|
||||
return
|
||||
warn(warning)
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Nuxt (`nuxt.config.ts`)
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
This issue has been resolved in `@nuxtjs/supabase` version 2.0.4. If you are on that version or later, you do not need to apply this workaround.
|
||||
|
||||
</Admonition>
|
||||
|
||||
```ts
|
||||
export default defineNuxtConfig({
|
||||
vite: {
|
||||
build: {
|
||||
rollupOptions: {
|
||||
onwarn(warning, warn) {
|
||||
if (warning.code === 'UNUSED_EXTERNAL_IMPORT' && warning.exporter?.includes('@supabase/'))
|
||||
return
|
||||
warn(warning)
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
+1
@@ -2,6 +2,7 @@
|
||||
title = "'Why Supabase Edge Functions cannot provide static egress IPs for allow listing'"
|
||||
topics = [ "auth", "functions", "platform", "self-hosting" ]
|
||||
keywords = []
|
||||
database_id = "99b56e92-62f2-4602-8e13-d38caf7aff4c"
|
||||
---
|
||||
|
||||
When trying to establish secure connections from Supabase Edge Functions to external services, you might encounter difficulties with traditional IP allow listing. This guide explains why this problem occurs and provides various solutions to address it.
|
||||
|
||||
@@ -353,7 +353,7 @@ await supabase.auth.signInWithOtp(
|
||||
phone: phone,
|
||||
);
|
||||
|
||||
// After receiving a SMS with a OTP.
|
||||
// After receiving an SMS with an OTP.
|
||||
await supabase.auth.verifyOTP(
|
||||
type: OtpType.sms,
|
||||
token: token,
|
||||
|
||||
@@ -6,7 +6,7 @@ description: 'Learn how to upgrade to supabase-js v2.'
|
||||
|
||||
supabase-js v2 focuses on "quality-of-life" improvements for developers and addresses some of the largest pain points in v1. v2 includes type support, a rebuilt Auth library with async methods, improved errors, and more.
|
||||
|
||||
No new features will be added to supabase-js v1 , but we'll continuing merging security fixes to v1, with maintenance patches for the next 3 months.
|
||||
No new features will be added to supabase-js v1, but we'll continue merging security fixes to v1, with maintenance patches for the next 3 months.
|
||||
|
||||
## Upgrade the client library
|
||||
|
||||
@@ -250,7 +250,7 @@ const { data, error } = await supabase
|
||||
.auth
|
||||
.signInWithOtp({ phone })
|
||||
|
||||
// After receiving a SMS with a OTP.
|
||||
// After receiving an SMS with an OTP.
|
||||
const { data, error } = await supabase
|
||||
.auth
|
||||
.verifyOtp({ phone, token })
|
||||
@@ -462,7 +462,7 @@ The cookie-related methods like `setAuthCookie` and `getUserByCookie` have been
|
||||
For Next.js you can use the [Auth Helpers](https://supabase.com/docs/guides/auth/auth-helpers/nextjs) to help you manage cookies.
|
||||
If you can't use the Auth Helpers, you can use [server-side rendering](https://supabase.com/docs/guides/auth/server-side-rendering).
|
||||
|
||||
Some the [PR](https://github.com/supabase/gotrue-js/pull/340) for additional background information.
|
||||
See the [PR](https://github.com/supabase/gotrue-js/pull/340) for additional background information.
|
||||
|
||||
### Data methods
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ custom_edit_url: https://github.com/supabase/supabase/edit/master/web/spec/supab
|
||||
- [**postgrest-kt**](https://github.com/supabase-community/supabase-kt/tree/master/Postgrest)
|
||||
- Other plugins also available [here](https://github.com/supabase-community/supabase-kt/tree/master/plugins)
|
||||
|
||||
Checkout the different READMEs for information about supported Kotlin targets.
|
||||
Check out the different READMEs for information about supported Kotlin targets.
|
||||
|
||||
*Note that the minimum Android SDK version is 26. For lower versions, you need to enable [core library desugaring](https://developer.android.com/studio/write/java8-support#library-desugaring).*
|
||||
|
||||
@@ -96,7 +96,7 @@ custom_edit_url: https://github.com/supabase/supabase/edit/master/web/spec/supab
|
||||
<RefSubLayout.Details>
|
||||
|
||||
You can find a list of engines [here](https://ktor.io/docs/http-client-engines.html)
|
||||
- Note that not all Ktor engines support Websockets. So if you plan to use the Realtime module, make sure to use an engine that supports Websockets. Checkout the [engine limitations](https://ktor.io/docs/client-engines.html#limitations) for more information.
|
||||
- Note that not all Ktor engines support WebSockets. So if you plan to use the Realtime module, make sure to use an engine that supports WebSockets. Check out the [engine limitations](https://ktor.io/docs/client-engines.html#limitations) for more information.
|
||||
- If using `supabase-kt` 3.0.0 and above, you need to use Ktor version 3.0.0-rc-1 or later.
|
||||
|
||||
</RefSubLayout.Details>
|
||||
|
||||
@@ -92,7 +92,7 @@ custom_edit_url: https://github.com/supabase/supabase/edit/master/web/spec/supab
|
||||
<RefSubLayout.Details>
|
||||
|
||||
You can find a list of engines [here](https://ktor.io/docs/http-client-engines.html)
|
||||
Note that not all Ktor engines support Websockets. So if you plan to use the Realtime module, make sure to use an engine that supports Websockets. Checkout the [engine limitations](https://ktor.io/docs/client-engines.html#limitations) for more information.
|
||||
Note that not all Ktor engines support WebSockets. So if you plan to use the Realtime module, make sure to use an engine that supports WebSockets. Check out the [engine limitations](https://ktor.io/docs/client-engines.html#limitations) for more information.
|
||||
|
||||
</RefSubLayout.Details>
|
||||
<RefSubLayout.Examples>
|
||||
|
||||
Loaded 100 of 394 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user