diff --git a/apps/docs/content/guides/database/replication/bigquery.mdx b/apps/docs/content/guides/database/replication/bigquery.mdx
index c583f3a095ec9..b224b2185cbcc 100644
--- a/apps/docs/content/guides/database/replication/bigquery.mdx
+++ b/apps/docs/content/guides/database/replication/bigquery.mdx
@@ -29,17 +29,22 @@ Before configuring BigQuery as a destination, set up the following in Google Clo
3. **GCP service account key**: Create a [service account](https://cloud.google.com/iam/docs/keys-create-delete) with appropriate permissions
- Go to **IAM & Admin > Service Accounts**
- Click **Create Service Account**
- - Grant the "BigQuery Data Editor" role
+ - Grant the "BigQuery Data Editor" and "BigQuery Job User" roles
- Create and download the JSON key file
Required permissions:
- `bigquery.datasets.get`
+- `bigquery.jobs.create`
- `bigquery.tables.create`
+- `bigquery.tables.delete`
- `bigquery.tables.get`
- `bigquery.tables.getData`
+- `bigquery.tables.list`
- `bigquery.tables.update`
- `bigquery.tables.updateData`
+- `bigquery.routines.get`
+- `bigquery.routines.list`
## Configure BigQuery as a destination
@@ -89,15 +94,21 @@ BigQuery replication requires each source table to have a primary key, and the p
BigQuery primary keys are `NOT ENFORCED`, and BigQuery CDC supports composite primary keys with up to 16 columns. Your source primary key must stay unique and non-null because BigQuery uses it to match CDC rows.
-Source tables must also use a BigQuery-compatible Postgres `REPLICA IDENTITY` setting:
+Source tables must also use a BigQuery-compatible Postgres `REPLICA IDENTITY` setting. Most tables can keep the Postgres default, as long as they have a primary key and all primary-key columns are included in the publication.
-- `DEFAULT` with a primary key is supported and works for most tables.
-- `FULL` is supported and is recommended for tables with large `text`, `jsonb`, `bytea`, or other values that Postgres may store out-of-line using TOAST.
-- `USING INDEX`, `NOTHING`, or `DEFAULT` without a primary key are not supported for BigQuery replication.
+| Source table setting | BigQuery support | Guidance |
+| ------------------------------------------------ | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `REPLICA IDENTITY DEFAULT` with a primary key | Supported | Recommended for most tables. BigQuery uses the replicated source primary key to apply upserts and deletes. |
+| `REPLICA IDENTITY FULL` | Supported | Recommended for tables with large `text`, `jsonb`, `bytea`, or other values that Postgres may store out-of-line using TOAST, especially when those rows update. |
+| `REPLICA IDENTITY USING INDEX` | Not supported | BigQuery CDC rows are keyed by the source primary key, not by an alternative unique index. |
+| `REPLICA IDENTITY NOTHING` | Not supported | Updates and deletes do not include enough row identity for BigQuery to apply them safely. |
+| `REPLICA IDENTITY DEFAULT` without a primary key | Not supported | BigQuery requires a source primary key. |
For a general explanation of how replica identity affects update and delete events, see [How does replica identity affect updates and deletes?](/docs/guides/database/replication/external-replication-faq#how-does-replica-identity-affect-updates-and-deletes).
-For updates, Postgres does not always send a complete old row through logical replication. It can also mark unchanged toasted values as `unchanged toast` instead of resending the value. The replication pipeline can reconstruct a complete update when the old row image contains the missing value, which is reliable with `REPLICA IDENTITY FULL`. BigQuery CDC upserts require a complete new row, so updates can fail for tables with toasted columns if the pipeline receives only a partial update row.
+For updates, Postgres does not always send a complete old row through logical replication. It can also mark unchanged toasted values as `unchanged toast` instead of resending the value. BigQuery CDC upserts require a complete new row because omitted columns are not preserved in the destination. The replication pipeline can reconstruct a complete update when the old row image contains the missing value, which is reliable with `REPLICA IDENTITY FULL`.
+
+If a BigQuery pipeline fails with an error about a partial update row, set `REPLICA IDENTITY FULL` on the affected source table and restart the pipeline. Changing replica identity only affects new WAL records, so a retained update that was written before the change may still need to be skipped by recreating the pipeline or re-copying the affected table.
Check a table's current replica identity:
@@ -138,16 +149,22 @@ Schema change support for BigQuery is currently in beta. External replication su
Supported schema changes:
-- Adding a column
+- Adding a nullable column
- Removing a column
- Renaming a column
+- Dropping a `NOT NULL` constraint
+- Setting or dropping supported column default metadata
-Unsupported schema changes:
+Unsupported or limited schema changes:
- Changing a column's data type
-- Replicating column default values
+- Adding `NOT NULL` with `SET NOT NULL`
+- Filling existing rows for `ADD COLUMN ... DEFAULT`
+- Unsupported default expressions
+
+BigQuery requires added columns to be nullable. When a replicated `ADD COLUMN` includes a default, external replication can apply supported default metadata for future rows, but BigQuery does not backfill existing rows through that DDL. Existing destination rows remain `NULL` unless you run a separate backfill.
-We plan to expand schema change support over time as the feature evolves.
+Supported defaults are best-effort translations to BigQuery SQL. Unsupported defaults are skipped with a warning instead of failing replication.
## Limitations
diff --git a/apps/docs/content/guides/database/replication/external-replication-faq.mdx b/apps/docs/content/guides/database/replication/external-replication-faq.mdx
index cc09c6336bf59..b511cb07198f2 100644
--- a/apps/docs/content/guides/database/replication/external-replication-faq.mdx
+++ b/apps/docs/content/guides/database/replication/external-replication-faq.mdx
@@ -45,11 +45,13 @@ Schema change support is currently in beta and limited to the BigQuery destinati
Supported schema changes:
-- Adding a column
+- Adding a nullable column
- Removing a column
- Renaming a column
+- Dropping a `NOT NULL` constraint
+- Setting or dropping supported column default metadata
-External replication does not currently support changing column data types or replicating column default values. See [BigQuery schema change support](/docs/guides/database/replication/bigquery#schema-change-support) for details.
+External replication does not currently support changing column data types, adding `NOT NULL` with `SET NOT NULL`, or filling existing destination rows for `ADD COLUMN ... DEFAULT`. Supported defaults are applied as destination metadata for future rows where BigQuery can represent them. See [BigQuery schema change support](/docs/guides/database/replication/bigquery#schema-change-support) for details.
## What happens when you disable external replication?
@@ -69,6 +71,14 @@ Common reasons:
Check your publication settings and verify your table meets the requirements.
+## Why are partitioned tables replicated as separate tables?
+
+Postgres controls this with the publication's `publish_via_partition_root` setting. If the setting is `false`, or if you created the publication manually with SQL and did not set it, Postgres publishes changes from the leaf partitions. External replication then creates destination tables for those leaf partitions. If `publish_via_partition_root = true`, Postgres publishes changes as the partition root, so external replication treats the partition hierarchy as the published partition root.
+
+Publications created from the Dashboard replication flow use `publish_via_partition_root = true`.
+
+See [Partitioned tables](/docs/guides/database/replication/external-replication-setup#partitioned-tables) for examples and the full behavior.
+
## How does replica identity affect updates and deletes?
If inserts replicate but updates or deletes fail, check the table's `REPLICA IDENTITY` setting.
@@ -132,6 +142,20 @@ Pipeline failures occur during the streaming phase when an error happens while r
See [Handling errors](/docs/guides/database/replication/external-replication-monitoring#handling-errors) for more details.
+## Why is replication lag increasing?
+
+Lag increases when Postgres produces WAL faster than the pipeline can confirm it has processed. Common causes include a slow or rate-limited destination, a pipeline issue, heavy source database activity, long transactions, network latency between the pipeline and source database, or a stopped/disconnected pipeline.
+
+Open [**Database > Replication**](/dashboard/project/_/database/replication), click **View status**, and check **Waiting to sync**, **Room before pausing**, **Last check-in**, **Connected**, and **Slot status**. See [Dealing with replication lag](/docs/guides/database/replication/external-replication-monitoring#dealing-with-replication-lag) for the full investigation and response flow.
+
+## What does a `Lost` slot status mean?
+
+`Lost` means Postgres has already removed WAL files that the pipeline's replication slot needed. The pipeline cannot continue from that slot.
+
+You can recreate the pipeline, or open the pipeline's **Advanced settings**, set **Invalidated slot behavior** to **Recreate**, and restart the pipeline. On restart, the pipeline creates a new replication slot and starts replication from scratch for all tables. This is required for consistency because the old slot can no longer provide every change the pipeline missed.
+
+See [Slot statuses](/docs/guides/database/replication/external-replication-monitoring#slot-statuses) for all slot states and what to do next.
+
## Why is a table in error state?
Table errors occur during the copy phase. To recover, click **View status**, find the affected table, and reset the table state. This will restart the table copy from the beginning.
diff --git a/apps/docs/content/guides/database/replication/external-replication-monitoring.mdx b/apps/docs/content/guides/database/replication/external-replication-monitoring.mdx
index 12e460f88cff8..eb22ca4c65c85 100644
--- a/apps/docs/content/guides/database/replication/external-replication-monitoring.mdx
+++ b/apps/docs/content/guides/database/replication/external-replication-monitoring.mdx
@@ -55,7 +55,35 @@ height={2146}
#### Replication lag metrics
-The status page shows replication lag metrics that help you determine how fast your pipeline is replicating data. These metrics are loaded directly from Postgres itself.
+The status page shows replication lag metrics that help you determine how far the pipeline is behind Postgres. These metrics are loaded directly from Postgres replication slot state.
+
+The destinations list also shows a compact lag value. This value is byte-based: it shows how much WAL the pipeline has not confirmed as flushed yet. A value of **Caught up** means the pipeline has confirmed every change currently available for its slot.
+
+The detailed status page shows:
+
+| Metric | What it means | What to watch for |
+| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| **Waiting to sync** | Bytes of WAL between the pipeline's confirmed flush position and the current Postgres WAL position. This is the main byte-based replication lag. | A value that keeps growing means the pipeline is receiving changes more slowly than Postgres produces them. |
+| **Room before pausing** | How much WAL can still accumulate before Postgres can no longer safely keep all WAL needed by the replication slot. This is controlled by `max_slot_wal_keep_size`. | A small or shrinking value means the slot is getting closer to being invalidated. `Unlimited` means Postgres is not reporting a slot WAL retention limit. |
+| **Last check-in** | How long it has been since the pipeline last sent replication feedback to Postgres. | An old value can mean the pipeline is stopped, disconnected, overloaded, or unable to make progress. |
+| **Connected** | Whether the pipeline's replication slot is active and currently being used. | `Not connected` while the pipeline should be running usually means you should check pipeline status and logs. |
+| **Slot status** | How safely Postgres is keeping the WAL files the pipeline still needs. | `Unreserved` and `Lost` require action. See [Slot statuses](#slot-statuses). |
+
+External replication uses one main pipeline replication slot for ongoing changes. During the initial copy phase, it can also create temporary table-sync replication slots. These temporary slots let multiple tables copy in parallel, make large table copies faster, and allow individual tables to be retried or copied again without restarting the whole pipeline.
+
+Temporary table-sync slots show the same kind of lag and slot health metrics while they are active. After a table finishes copying and catches up, its temporary slot is removed and ongoing changes continue through the main pipeline slot. For overall replication health, focus first on the main pipeline slot.
+
+#### Slot statuses
+
+Replication slot status tells you whether Postgres is still retaining the WAL that the pipeline needs to continue from its current position.
+
+| Status | Meaning |
+| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| **Reserved** | Healthy. Postgres is keeping the WAL files this pipeline's replication slot needs, and they are within the normal WAL size limit. |
+| **Extended** | Healthy, but growing. The slot is holding on to more WAL than usual, but Postgres is still keeping everything the pipeline needs. |
+| **Unreserved** | At risk. Postgres is no longer reserving all WAL files this pipeline's replication slot needs. If the pipeline does not catch up soon, those files may be removed. |
+| **Lost** | Broken. Some WAL files this pipeline's replication slot needs have already been removed. The pipeline can no longer continue from this slot. Recreate the pipeline, or set **Invalidated slot behavior** to **Recreate** in the pipeline's advanced settings and restart it. |
+| **Unknown** | Postgres reported an unknown or unavailable state for this pipeline's replication slot. |
#### Table states
@@ -69,6 +97,57 @@ The pipeline status page also shows the state of individual tables being replica
| **Live** | Table is now replicating data in near real-time |
| **Error** | Table has experienced an error during replication |
+### Dealing with replication lag
+
+Replication lag means the pipeline is behind the source database. Some lag is expected during the initial copy phase, after a burst of writes, or after restarting a stopped pipeline. Lag becomes a problem when it keeps increasing, when **Room before pausing** is running low, or when the slot status moves to **Unreserved** or **Lost**.
+
+Lag can come from several places:
+
+- **Destination throughput**: The destination is slow, rate-limited, unavailable, or rejecting writes.
+- **Pipeline throughput**: The pipeline is overloaded, processing a very large transaction, or not performing as expected for the project workload.
+- **Source database activity**: Postgres is producing WAL faster than the pipeline can consume it, often during bulk writes, migrations, or backfill jobs.
+- **Network latency**: Latency or instability between the pipeline and source database can slow down WAL streaming.
+- **Stopped or disconnected pipeline**: When a pipeline is stopped, disconnected, or failed, Postgres keeps WAL for the slot until the retention limit is reached.
+- **Slow initial table copy**: A temporary table-sync slot can fall behind if a table is copied more slowly than new changes are written to that table.
+
+#### Initial copy and table-sync slots
+
+A common initial sync issue happens when a large or busy table is still in **Copying** while new rows keep being inserted or updated. The temporary table-sync slot needs to keep the changes that happen during the copy. If the copy is too slow compared to the table's write rate, the slot can move to **Unreserved** and then **Lost** if Postgres removes changes the copy still needs.
+
+When a table-sync slot is lost, the affected table needs to be copied again. Tune the copy settings, then retry the table copy:
+
+- Increase **Copy connections per table** when one large table is the bottleneck. This lets the pipeline copy chunks of that table in parallel, up to the point where the source database or network becomes the limit.
+- Increase **Table sync workers** when several tables need to copy at the same time. Each worker can copy one table, and each worker uses an additional temporary replication slot during initial sync.
+- If possible, run the initial copy during a quieter write period or reduce bulk writes until the table reaches **Live**.
+
+After the affected table finishes copying and catches up, the temporary slot is deleted. The table then continues through the main pipeline replication slot.
+
+#### Investigate the lag
+
+1. Open [**Database > Replication**](/dashboard/project/_/database/replication) and check the destination's lag column.
+2. Click **View status** and check **Waiting to sync**, **Room before pausing**, **Last check-in**, **Connected**, and **Slot status**.
+3. Check table states. Tables in **Copying** can create temporary lag while the initial snapshot catches up to live changes. If a table-sync slot is **Unreserved** or **Lost**, tune copy parallelism and retry the affected table copy.
+4. Open [**Logs > Replication**](/dashboard/project/_/logs/explorer) and look for destination errors, retries, rate limits, schema errors, or repeated restarts.
+5. Compare the lag trend with recent database activity, such as imports, migrations, bulk updates, or long transactions.
+
+#### Respond based on the slot status
+
+| Slot status | What to do |
+| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| **Reserved** | If **Waiting to sync** is stable or decreasing, continue monitoring. If it keeps increasing, check destination write performance, logs, and whether the publication includes more tables or write volume than expected. |
+| **Extended** | Treat it as an early warning. Confirm the pipeline is connected, check logs for retries or destination slowness, and reduce avoidable write bursts if possible until the pipeline catches up. |
+| **Unreserved** | Act quickly. The slot is at risk of losing required WAL. Check whether the pipeline is connected and making progress, fix destination or pipeline errors, and contact support if the lag continues to grow. |
+| **Lost** | The pipeline cannot continue from the existing slot because required WAL has been removed. Recreate the pipeline, or set **Invalidated slot behavior** to **Recreate** in the pipeline's advanced settings and restart the pipeline. This creates a new slot and starts replication from scratch for all tables. |
+| **Unknown** | Check replication logs for errors or missing slot details. If the status remains unknown while the pipeline should be running, contact support with the pipeline ID and recent log details. |
+
+#### Reduce future lag risk
+
+- Keep publications focused on the tables and operations you need at the destination.
+- Avoid leaving pipelines stopped for long periods while the source database is still receiving writes.
+- Schedule bulk updates, imports, and migrations during lower-traffic windows when possible.
+- For BigQuery, verify that service account permissions, table requirements, and replica identity settings match the [BigQuery destination guide](/docs/guides/database/replication/bigquery).
+- If the initial copy is the bottleneck, review **Table sync workers** and **Copy connections per table** in the pipeline's advanced settings. Increasing them can speed up copying, but it uses more database connections and replication slots.
+
### Handling errors
Errors can occur at two levels: per table or per pipeline.
diff --git a/apps/docs/content/guides/database/replication/external-replication-setup.mdx b/apps/docs/content/guides/database/replication/external-replication-setup.mdx
index f1c58b4303d85..f82edd4f4579f 100644
--- a/apps/docs/content/guides/database/replication/external-replication-setup.mdx
+++ b/apps/docs/content/guides/database/replication/external-replication-setup.mdx
@@ -88,6 +88,44 @@ create publication pub_recent_orders
for table orders where (created_at > '2024-01-01');
```
+##### Partitioned tables
+
+External replication follows Postgres publication semantics for partitioned tables. The `publish_via_partition_root` publication setting controls whether changes from partitions are emitted as the partition root or as the leaf partitions.
+
+| Publication setting | What gets replicated | Destination shape |
+| ------------------------------------------ | ---------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
+| `publish_via_partition_root = true` | Rows from the published partition root, including rows stored in its leaf partitions | One table matching the published partition root |
+| `publish_via_partition_root = false` | Rows from the leaf partitions under the published partition root | One table per replicated leaf partition |
+| Not set in SQL | Same as `false`, because Postgres defaults `publish_via_partition_root` to `false` | One table per replicated leaf partition |
+| Publishing an individual leaf partition | The leaf partition itself, regardless of `publish_via_partition_root` | One table for that leaf partition |
+| `FOR ALL TABLES` or `FOR TABLES IN SCHEMA` | Partition roots plus regular tables when `true`; leaf partitions plus regular tables when `false` or unset | Destination tables follow the effective Postgres publication table list |
+
+For example, if `orders` is partitioned by month:
+
+```sql
+-- Replicate the whole partition hierarchy as the parent table.
+create publication pub_orders_root
+for table orders
+with (publish_via_partition_root = true);
+
+-- Replicate each leaf partition as its own table.
+create publication pub_orders_leaves
+for table orders
+with (publish_via_partition_root = false);
+```
+
+Use `publish_via_partition_root = true` when you want analytics queries to read from a single destination table that has the parent table's schema. Use `false` when each partition should remain a separate destination table.
+
+Publications created from the Dashboard replication flow use `publish_via_partition_root = true`. If you create or alter a publication manually with SQL, set this option explicitly so the destination shape matches what you expect.
+
+On Postgres 15 and newer, row filters on partition publications apply during both the initial copy and streaming phases. External replication uses the row filter attached to the effective publication table entry: the published partition root when `publish_via_partition_root = true`, and the published leaf relation when `publish_via_partition_root = false`.
+
+
+
+With `publish_via_partition_root = true`, truncating an individual leaf partition is not replicated as a truncate event for the published parent. If you need truncate replication, run `TRUNCATE` on the published partition root.
+
+
+
#### Viewing publications in the Dashboard
After creating a publication via SQL, you can view it in the Dashboard:
@@ -132,15 +170,17 @@ Follow these steps to configure your destination. Each destination has its own s
5. Optionally expand **Advanced settings** to tune pipeline behavior. These settings apply to the pipeline rather than the destination:
- | Setting | Default | Description |
- | ------------------------------ | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
- | **Batch wait time** | `10000` milliseconds | Maximum time the pipeline waits to collect additional changes before flushing them. Lower values reduce replication latency. Higher values can improve batching efficiency. |
- | **Table sync workers** | `4` workers | Number of tables copied in parallel during the initial snapshot phase. Each worker uses one replication slot, up to `N + 1` total replication slots while syncing. |
- | **Copy connections per table** | `2` connections | Number of parallel database connections each table copy can use during the initial sync. Increasing this can speed up large table copies, but uses more database connections. |
- | **Invalidated slot behavior** | `Error` | What the pipeline does when its replication slot is invalidated. **Error** blocks startup so you can recover manually. **Recreate** rebuilds the slot and starts replication from scratch. |
+ | Setting | Default | Description |
+ | ------------------------------ | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+ | **Batch wait time** | `10000` milliseconds | Maximum time the pipeline waits to collect additional changes before flushing them. Lower values reduce replication latency. Higher values can improve batching efficiency. |
+ | **Table sync workers** | `4` workers | Number of tables copied in parallel during the initial snapshot phase. Each worker uses one replication slot, up to `N + 1` total replication slots while syncing. |
+ | **Copy connections per table** | `2` connections | Number of parallel database connections each table copy can use during the initial sync. Increasing this can speed up large table copies, but uses more database connections. |
+ | **Invalidated slot behavior** | `Error` | What the pipeline does when its replication slot is invalidated. **Error** blocks startup so you can recover manually. **Recreate** rebuilds the slot on the next pipeline restart and starts replication from scratch for all tables. |
Leave these settings at their defaults unless you need to tune initial copy speed, latency, or recovery behavior.
+ Use **Invalidated slot behavior** carefully. If **Recreate** is selected and the pipeline restarts after Postgres has invalidated the main replication slot, the pipeline creates a new slot and copies all replicated tables again. This full restart is required for consistency because the old slot can no longer provide every change the pipeline missed.
+
6. Click **Create and start** to begin replication
Your replication pipeline now starts copying data from your database to your destination.
diff --git a/apps/docs/content/guides/getting-started.mdx b/apps/docs/content/guides/getting-started.mdx
index 7559d93015f22..45a0a7892cc69 100644
--- a/apps/docs/content/guides/getting-started.mdx
+++ b/apps/docs/content/guides/getting-started.mdx
@@ -10,7 +10,7 @@ hideToc: true
-
+
-
+
Use the Supabase CLI to develop locally and collaborate between teams.
diff --git a/apps/docs/content/guides/realtime/limits.mdx b/apps/docs/content/guides/realtime/limits.mdx
index edea5d6dc2d1b..5c45b111e4d7c 100644
--- a/apps/docs/content/guides/realtime/limits.mdx
+++ b/apps/docs/content/guides/realtime/limits.mdx
@@ -23,6 +23,7 @@ Upgrade your plan to increase your limits. Without a spend cap, or on an Enterpr
| **Channels per connection** | 100 | 100 | 100 | 100 | 100+ |
| **Presence keys per object** | 10 | 10 | 10 | 10 | 10+ |
| **Presence messages per second** | 20 | 50 | 1,000 | 1,000 | 1,000+ |
+| **Presence calls per client, per 30 seconds** | 5 | 5 | 5 | 5 | 5 |
| **Broadcast payload size** | 256 KB | 3,000 KB | 3,000 KB | 3,000 KB | 3,000+ KB |
| **Postgres change payload size ([**read more**](#postgres-changes-payload-limit))** | 1,024 KB | 1,024 KB | 1,024 KB | 1,024 KB | 1,024+ KB |
diff --git a/apps/docs/content/troubleshooting/relation-objects-does-not-exist-error-during-storage-uploads-8f21f0.mdx b/apps/docs/content/troubleshooting/relation-objects-does-not-exist-error-during-storage-uploads-8f21f0.mdx
new file mode 100644
index 0000000000000..69de7d3fbb2e5
--- /dev/null
+++ b/apps/docs/content/troubleshooting/relation-objects-does-not-exist-error-during-storage-uploads-8f21f0.mdx
@@ -0,0 +1,30 @@
+---
+title = "Postgres error 'relation objects does not exist' during Storage uploads"
+date_created = "2026-06-10T15:05:10+00:00"
+topics = [ "auth", "database", "storage" ]
+keywords = []
+[[errors]]
+code = "42P01"
+message = "relation 'objects' does not exist"
+
+[[errors]]
+http_status_code = 500
+message = "Internal Server Error"
+---
+
+If authenticated users receive a `500 Internal Server Error` during file uploads and your database logs report `error: relation "objects" does not exist`, it typically indicates the database cannot locate the required internal storage tables.
+
+**Why Does This Happen?**
+This error occurs when the `authenticated` role's `search_path` does not include the `storage` schema. Because the schema is missing from the path, the database fails to find the `objects` table (which resides in `storage.objects`) during the upload process.
+
+**How to Fix:**
+You can resolve this by updating the role configuration via the [SQL Editor](/dashboard/project/_/sql/new):
+
+1. Execute the following command to append the storage schema to the role's search path:
+ `ALTER ROLE authenticated SET search_path = public, storage;`
+2. Verify that uploads from authenticated users now succeed.
+
+**Best Practices:**
+
+- To maintain compatibility with default schema permissions, avoid creating custom Postgres roles for application users.
+- Instead, use custom claims within `auth.users.raw_app_meta_data` to manage user-specific logic or permissions.
diff --git a/apps/studio/components/interfaces/Database/Replication/DeleteDestination.tsx b/apps/studio/components/interfaces/Database/Replication/DeleteDestination.tsx
index 2489f40535598..0fddb5a6b1890 100644
--- a/apps/studio/components/interfaces/Database/Replication/DeleteDestination.tsx
+++ b/apps/studio/components/interfaces/Database/Replication/DeleteDestination.tsx
@@ -24,7 +24,7 @@ export const DeleteDestination = ({
confirmLabel={isLoading ? 'Deleting...' : `Delete destination`}
confirmPlaceholder="Type in name of destination"
confirmString={name ?? 'Unknown'}
- text={`This will delete the destination "${name}"`}
+ text={`This will delete the destination "${name}".`}
alert={{ title: 'You cannot recover this destination once deleted.' }}
onCancel={() => setVisible(!visible)}
onConfirm={onDelete}
diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/AdvancedSettings.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/AdvancedSettings.tsx
index 3f909475df8bd..cba27702b7abc 100644
--- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/AdvancedSettings.tsx
+++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/AdvancedSettings.tsx
@@ -43,7 +43,7 @@ export const AdvancedSettings = ({
Advanced settings
- Optional performance tuning
+ Optional settings to control the pipeline in more depth
Creating this replication pipeline will immediately start syncing data from your
- publication into the destination. Make sure you understand the limitations of the system
- before proceeding.
+ publication into an external destination. Review the current limitations before
+ proceeding.
@@ -53,10 +53,6 @@ export const ReplicationDisclaimerDialog = ({
With FULL replica identity deletes and updates include the payload that is needed to
correctly apply those changes.
-
- Schema changes aren’t supported yet.{' '}
- Plan for manual adjustments if you need to alter replicated tables.
-
diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationPanel.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationPanel.tsx
index adef6160f4d13..eee3e2f9b0e04 100644
--- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationPanel.tsx
+++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationPanel.tsx
@@ -104,7 +104,7 @@ export const DestinationPanel = ({ onSuccessCreateReadReplica }: DestinationPane
{editMode
? 'Update the configuration for this destination'
- : 'A destination can be a read replica or an external platform that receives your database changes in real time.'}
+ : 'A destination can be a read replica or an external destination that receives your database changes in real time.'}
@@ -118,11 +118,11 @@ export const DestinationPanel = ({ onSuccessCreateReadReplica }: DestinationPane
-
Replicate data to external destinations in real-time
+
Replicate data to external destinations in real time
- We are currently in private alpha and
- slowly onboarding new customers to ensure stable data pipelines. Request
- access below to join the waitlist. Read replicas are available now.
+ External destinations are in alpha{' '}
+ and are being rolled out gradually. Request access below to join the waitlist.
+ Read replicas are available now.
diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationTypeSelection.test.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationTypeSelection.test.tsx
new file mode 100644
index 0000000000000..358bda15eacc8
--- /dev/null
+++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationTypeSelection.test.tsx
@@ -0,0 +1,160 @@
+import { fireEvent, screen } from '@testing-library/react'
+import { platformComponents as components } from 'api-types'
+import { mockAnimationsApi } from 'jsdom-testing-mocks'
+import { HttpResponse } from 'msw'
+import { describe, expect, test, vi } from 'vitest'
+
+import { DestinationTypeSelection } from './DestinationTypeSelection'
+import { customRender } from '@/tests/lib/custom-render'
+import { addAPIMock } from '@/tests/lib/msw'
+
+type ReplicationSourcesResponse = components['schemas']['ReplicationSourcesResponse']
+type ReplicationPipelinesResponse = components['schemas']['ReplicationPipelinesResponse']
+type ReplicationDestinationResponse = components['schemas']['ReplicationDestinationResponse']
+
+mockAnimationsApi()
+
+// Feature flags are not API calls — mock at the module level so tests can
+// control per-destination-type visibility without hitting PostHog.
+const mockBigQueryEnabled = vi.fn()
+const mockIcebergEnabled = vi.fn()
+const mockDucklakeEnabled = vi.fn()
+const mockSnowflakeEnabled = vi.fn()
+
+vi.mock('../useIsETLPrivateAlpha', () => ({
+ useIsETLBigQueryPrivateAlpha: () => mockBigQueryEnabled(),
+ useIsETLIcebergPrivateAlpha: () => mockIcebergEnabled(),
+ useIsETLDucklakePrivateAlpha: () => mockDucklakeEnabled(),
+ useIsETLSnowflakePrivateAlpha: () => mockSnowflakeEnabled(),
+}))
+
+vi.mock('@/hooks/misc/useIsFeatureEnabled', () => ({
+ useIsFeatureEnabled: () => ({ infrastructureReadReplicas: true }),
+}))
+
+// Background queries from useDestinationInformation (sources + pipelines fire
+// even in create mode). Prevent retries so unmatched handlers fail fast.
+vi.mock('@/data/replication/utils', () => ({
+ checkReplicationFeatureFlagRetry: () => false,
+}))
+
+const addBackgroundMocks = () => {
+ addAPIMock({
+ method: 'get',
+ path: '/platform/replication/:ref/sources',
+ response: () => HttpResponse.json({ sources: [] }),
+ })
+ addAPIMock({
+ method: 'get',
+ path: '/platform/replication/:ref/pipelines',
+ response: () => HttpResponse.json({ pipelines: [] }),
+ })
+}
+
+describe('DestinationTypeSelection', () => {
+ test('shows placeholder when no type is selected', async () => {
+ mockBigQueryEnabled.mockReturnValue(false)
+ mockIcebergEnabled.mockReturnValue(false)
+ mockDucklakeEnabled.mockReturnValue(false)
+ mockSnowflakeEnabled.mockReturnValue(false)
+ addBackgroundMocks()
+
+ customRender()
+
+ expect(await screen.findByText('Select a destination type')).toBeInTheDocument()
+ })
+
+ test('renders the Within Supabase group with Read Replica when dropdown is opened', async () => {
+ mockBigQueryEnabled.mockReturnValue(false)
+ mockIcebergEnabled.mockReturnValue(false)
+ mockDucklakeEnabled.mockReturnValue(false)
+ mockSnowflakeEnabled.mockReturnValue(false)
+ addBackgroundMocks()
+
+ customRender()
+
+ fireEvent.click(await screen.findByRole('combobox'))
+
+ expect(await screen.findByText('Within Supabase')).toBeInTheDocument()
+ expect(screen.getByText('Read Replica')).toBeInTheDocument()
+ })
+
+ test('renders the Outside Supabase group with BigQuery when the flag is enabled', async () => {
+ mockBigQueryEnabled.mockReturnValue(true)
+ mockIcebergEnabled.mockReturnValue(false)
+ mockDucklakeEnabled.mockReturnValue(false)
+ mockSnowflakeEnabled.mockReturnValue(false)
+ addBackgroundMocks()
+
+ customRender()
+
+ fireEvent.click(await screen.findByRole('combobox'))
+
+ expect(await screen.findByText('Outside Supabase')).toBeInTheDocument()
+ expect(screen.getByText('BigQuery')).toBeInTheDocument()
+ })
+
+ test('hides destinations behind disabled feature flags', async () => {
+ mockBigQueryEnabled.mockReturnValue(false)
+ mockIcebergEnabled.mockReturnValue(false)
+ mockDucklakeEnabled.mockReturnValue(false)
+ mockSnowflakeEnabled.mockReturnValue(false)
+ addBackgroundMocks()
+
+ customRender()
+
+ fireEvent.click(await screen.findByRole('combobox'))
+
+ await screen.findByText('Within Supabase')
+ expect(screen.queryByText('BigQuery')).not.toBeInTheDocument()
+ expect(screen.queryByText('DuckLake')).not.toBeInTheDocument()
+ expect(screen.queryByText('Analytics Bucket')).not.toBeInTheDocument()
+ expect(screen.queryByText('Outside Supabase')).not.toBeInTheDocument()
+ })
+
+ test('shows alpha warning when an alpha destination type is selected', async () => {
+ mockBigQueryEnabled.mockReturnValue(true)
+ mockIcebergEnabled.mockReturnValue(false)
+ mockDucklakeEnabled.mockReturnValue(false)
+ mockSnowflakeEnabled.mockReturnValue(false)
+ addBackgroundMocks()
+
+ customRender()
+
+ fireEvent.click(await screen.findByRole('combobox'))
+ fireEvent.click(await screen.findByText('BigQuery'))
+
+ expect(await screen.findByText(/This destination type is in alpha/)).toBeInTheDocument()
+ })
+
+ test('disables the selector in edit mode so the destination type cannot be changed', async () => {
+ mockBigQueryEnabled.mockReturnValue(true)
+ mockIcebergEnabled.mockReturnValue(false)
+ mockDucklakeEnabled.mockReturnValue(false)
+ mockSnowflakeEnabled.mockReturnValue(false)
+ addBackgroundMocks()
+ // Edit mode triggers useDestinationInformation({ id: 1 }) which fires destination-by-id
+ addAPIMock({
+ method: 'get',
+ path: '/platform/replication/:ref/destinations/:destination_id',
+ response: () =>
+ HttpResponse.json({
+ tenant_id: 't',
+ id: 1,
+ name: 'My BigQuery Destination',
+ config: {
+ big_query: {
+ project_id: 'gcp-proj',
+ dataset_id: 'analytics',
+ service_account_key: '{}',
+ },
+ },
+ }),
+ })
+
+ // ?edit=1 locks the type to the existing destination
+ customRender(, { nuqs: { searchParams: { edit: '1' } } })
+
+ expect(await screen.findByRole('combobox')).toBeDisabled()
+ })
+})
diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationTypeSelection.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationTypeSelection.tsx
index 38f15c5c954b2..01abedb6c6bef 100644
--- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationTypeSelection.tsx
+++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationTypeSelection.tsx
@@ -1,7 +1,15 @@
import { AnalyticsBucket, BigQuery, Database } from 'icons'
import { Snowflake } from 'lucide-react'
import { parseAsInteger, parseAsStringEnum, useQueryState } from 'nuqs'
-import { Badge, cn, RadioGroupStacked, RadioGroupStackedItem } from 'ui'
+import {
+ Badge,
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectTrigger,
+} from 'ui'
import { useDestinationInformation } from '../useDestinationInformation'
import {
@@ -14,6 +22,20 @@ import { DestinationType } from './DestinationPanel.types'
import { InlineLink } from '@/components/ui/InlineLink'
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
+interface DestinationTypeOption {
+ value: DestinationType
+ label: string
+ description: string
+ icon: typeof Database
+ isAlpha: boolean
+ enabled: boolean
+}
+
+interface DestinationTypeGroup {
+ label: string
+ options: DestinationTypeOption[]
+}
+
export const DestinationTypeSelection = () => {
const etlEnableBigQuery = useIsETLBigQueryPrivateAlpha()
const etlEnableIceberg = useIsETLIcebergPrivateAlpha()
@@ -21,14 +43,6 @@ export const DestinationTypeSelection = () => {
const etlEnableSnowflake = useIsETLSnowflakePrivateAlpha()
const { infrastructureReadReplicas } = useIsFeatureEnabled(['infrastructure:read_replicas'])
- const numberOfTypes = [
- infrastructureReadReplicas,
- etlEnableBigQuery,
- etlEnableIceberg,
- etlEnableDucklake,
- etlEnableSnowflake,
- ].filter(Boolean).length
-
const [urlDestinationType, setDestinationType] = useQueryState(
'destinationType',
parseAsStringEnum([
@@ -52,132 +66,127 @@ export const DestinationTypeSelection = () => {
const { type: existingDestinationType } = useDestinationInformation({ id: edit })
const destinationType = existingDestinationType ?? urlDestinationType
+ // In edit mode the type is locked, so only surface the option that matches the
+ // destination being edited. Otherwise show every type the project has access to.
+ const isOptionVisible = (value: DestinationType, hasAccess: boolean) =>
+ editMode ? destinationType === value : hasAccess
+
+ const groups: DestinationTypeGroup[] = [
+ {
+ label: 'Within Supabase',
+ options: [
+ {
+ value: 'Read Replica',
+ label: 'Read Replica',
+ description:
+ 'Deploy a read-only database in another region for lower latency and workload isolation',
+ icon: Database,
+ isAlpha: false,
+ enabled: isOptionVisible('Read Replica', infrastructureReadReplicas),
+ },
+ {
+ value: 'Analytics Bucket',
+ label: 'Analytics Bucket',
+ description: 'Write Apache Iceberg tables to Supabase Storage for analytics workflows',
+ icon: AnalyticsBucket,
+ isAlpha: true,
+ enabled: isOptionVisible('Analytics Bucket', etlEnableIceberg),
+ },
+ ],
+ },
+ {
+ label: 'Outside Supabase',
+ options: [
+ {
+ value: 'BigQuery',
+ label: 'BigQuery',
+ description: "Stream changes to Google Cloud's data warehouse for analytics and BI",
+ icon: BigQuery,
+ isAlpha: true,
+ enabled: isOptionVisible('BigQuery', etlEnableBigQuery),
+ },
+ {
+ value: 'DuckLake',
+ label: 'DuckLake',
+ description: 'Stream changes to a DuckLake catalog backed by S3-compatible storage',
+ icon: Database,
+ isAlpha: true,
+ enabled: isOptionVisible('DuckLake', etlEnableDucklake),
+ },
+ {
+ value: 'Snowflake',
+ label: 'Snowflake',
+ description:
+ 'Stream changes to Snowflake for warehouse analytics and downstream data workflows',
+ icon: Snowflake,
+ isAlpha: true,
+ enabled: isOptionVisible('Snowflake', etlEnableSnowflake),
+ },
+ ],
+ },
+ ]
+
+ const visibleGroups = groups
+ .map((group) => ({ ...group, options: group.options.filter((option) => option.enabled) }))
+ .filter((group) => group.options.length > 0)
+
+ const selectedOption = visibleGroups
+ .flatMap((group) => group.options)
+ .find((option) => option.value === destinationType)
+
return (
-
-
+
+
Type
-
- The destination type cannot be changed after creation
+
+ The destination type cannot be changed after creation.
- External replication is in alpha. Expect rapid changes and possible breaking updates.{' '}
+ {selectedOption?.isAlpha && (
+
+ This destination type is in alpha and may change while we iterate.{' '}
Leave feedback
diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/ReadReplicaForm/ReadReplicaEligibilityWarnings.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/ReadReplicaForm/ReadReplicaEligibilityWarnings.tsx
index ed01b16a504c1..370d235c8a84a 100644
--- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/ReadReplicaForm/ReadReplicaEligibilityWarnings.tsx
+++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/ReadReplicaForm/ReadReplicaEligibilityWarnings.tsx
@@ -65,7 +65,7 @@ export const ReadReplicaEligibilityWarnings = () => {
if (hasOverdueInvoices) {
return (
-
Please resolve all outstanding invoices first before deploying a new read replica
+
Please resolve all outstanding invoices first before deploying a new read replica.
Projects provisioned by other cloud providers currently will not be able to use read
- replicas
+ replicas.
{
)
}
@@ -108,7 +108,7 @@ export const ReadReplicaEligibilityWarnings = () => {
type="warning"
title="Read replicas can only be deployed with projects on Postgres version 15 and above"
>
-
If you'd like to use read replicas, please contact us via support
+
If you'd like to use read replicas, please contact us via support.
diff --git a/apps/studio/components/interfaces/Database/Replication/ReadReplicas/ReadReplicaDetails.tsx b/apps/studio/components/interfaces/Database/Replication/ReadReplicas/ReadReplicaDetails.tsx
index f6b43c32a524b..c40af9169b70a 100644
--- a/apps/studio/components/interfaces/Database/Replication/ReadReplicas/ReadReplicaDetails.tsx
+++ b/apps/studio/components/interfaces/Database/Replication/ReadReplicas/ReadReplicaDetails.tsx
@@ -137,7 +137,7 @@ export const ReadReplicaDetails = () => {
isReactForm={false}
layout="horizontal"
label="Load Balancer URL"
- description="RESTful endpoint for querying and managing your databases through your load balancer"
+ description="RESTful endpoint for querying and managing your databases through your load balancer."
>
@@ -164,7 +164,7 @@ export const ReadReplicaDetails = () => {
isReactForm={false}
layout="horizontal"
label="Compute Size"
- description="Size of replica will be identical to the primary database"
+ description="Size of replica will be identical to the primary database."
>
diff --git a/apps/studio/components/interfaces/Database/Replication/ReplicationDiagram/Edges.tsx b/apps/studio/components/interfaces/Database/Replication/ReplicationDiagram/Edges.tsx
index 299cced7593c1..84eebdfbc91eb 100644
--- a/apps/studio/components/interfaces/Database/Replication/ReplicationDiagram/Edges.tsx
+++ b/apps/studio/components/interfaces/Database/Replication/ReplicationDiagram/Edges.tsx
@@ -1,19 +1,85 @@
import { BaseEdge, EdgeLabelRenderer, getSmoothStepPath, type EdgeProps } from '@xyflow/react'
import { useParams } from 'common'
-import { Loader2, X } from 'lucide-react'
-import { cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
+import { ArrowRight, Loader2, Square, X, type LucideIcon } from 'lucide-react'
+import { useMemo } from 'react'
+import { cn } from 'ui'
-import { useReplicationLagQuery } from '@/data/read-replicas/replica-lag-query'
-import { formatDatabaseID } from '@/data/read-replicas/replicas.utils'
-import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
+import { getStatusName } from '../Pipeline.utils'
+import { STATUS_REFRESH_FREQUENCY_MS } from '../Replication.constants'
+import { REPLICA_STATUS } from '@/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.constants'
+import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query'
+import { useReplicationPipelineStatusQuery } from '@/data/replication/pipeline-status-query'
+import { useReplicationPipelinesQuery } from '@/data/replication/pipelines-query'
+import {
+ PipelineStatusRequestStatus,
+ usePipelineRequestStatus,
+} from '@/state/replication-pipeline-request-status'
type EdgeData = {
type: string
identifier: string
+ shiftEdgeEnd: boolean
+}
+
+interface ReplicationState {
isComingUp: boolean
isReplicating: boolean
isFailed: boolean
- shiftEdgeEnd: boolean
+}
+
+interface EdgeVisual {
+ Icon: LucideIcon
+ // CSS color shared by the icon and the connecting line so they always match.
+ color: string
+ opacity: number
+ dashArray: string
+ shouldAnimate: boolean
+ shouldSpin?: boolean
+ isFilled?: boolean
+ strokeWidth?: number
+}
+
+// Picks the icon + line appearance for a replication state. Both the icon and the line are derived
+// here from the same state so they always stay in sync. We deliberately don't surface lag: the line
+// just communicates whether data is moving, stopped, starting, or broken.
+const getEdgeVisual = ({ isComingUp, isReplicating, isFailed }: ReplicationState): EdgeVisual => {
+ if (isFailed) {
+ return {
+ Icon: X,
+ color: 'hsl(var(--destructive-default))',
+ opacity: 1,
+ dashArray: '5 5',
+ shouldAnimate: false,
+ strokeWidth: 4,
+ }
+ }
+ if (isComingUp) {
+ return {
+ Icon: Loader2,
+ color: 'hsl(var(--foreground-light))',
+ opacity: 1,
+ dashArray: '5',
+ shouldAnimate: true,
+ shouldSpin: true,
+ }
+ }
+ if (isReplicating) {
+ return {
+ Icon: ArrowRight,
+ color: 'hsl(var(--brand-default))',
+ opacity: 1,
+ dashArray: '5',
+ shouldAnimate: true,
+ }
+ }
+ return {
+ Icon: Square,
+ color: 'hsl(var(--foreground-lighter))',
+ opacity: 0.5,
+ dashArray: '5 5',
+ shouldAnimate: false,
+ isFilled: true,
+ }
}
export const SmoothstepEdge = ({
@@ -27,12 +93,58 @@ export const SmoothstepEdge = ({
markerEnd,
data,
}: EdgeProps) => {
- const { ref } = useParams()
- const { data: project } = useSelectedProjectQuery()
+ const { ref: projectRef = 'default' } = useParams()
+ const { type, identifier, shiftEdgeEnd } = (data || {}) as EdgeData
+ const isReplica = type === 'replica'
- const { type, identifier, isComingUp, isReplicating, isFailed, shiftEdgeEnd } = (data ||
- {}) as EdgeData
- const formattedId = type === 'replica' ? formatDatabaseID(identifier ?? '') : identifier
+ // Subscribe to the same live status the nodes use, so the line and the node update together.
+ const { data: databases = [] } = useReadReplicasQuery(
+ { projectRef },
+ { enabled: isReplica, refetchInterval: STATUS_REFRESH_FREQUENCY_MS }
+ )
+ const replica = databases.find((x) => x.identifier === identifier)
+
+ const { data: pipelinesData } = useReplicationPipelinesQuery(
+ { projectRef },
+ { enabled: !isReplica }
+ )
+ const pipeline = (pipelinesData?.pipelines ?? []).find(
+ (p) => p.destination_id.toString() === identifier
+ )
+ const { data: pipelineStatusData } = useReplicationPipelineStatusQuery(
+ { projectRef, pipelineId: pipeline?.id },
+ { enabled: !isReplica && !!pipeline?.id, refetchInterval: STATUS_REFRESH_FREQUENCY_MS }
+ )
+ const { getRequestStatus } = usePipelineRequestStatus()
+ const requestStatus = pipeline?.id
+ ? getRequestStatus(pipeline.id)
+ : PipelineStatusRequestStatus.None
+
+ const replicationState = useMemo(() => {
+ if (isReplica) {
+ const status = replica?.status
+ return {
+ isReplicating: status === 'ACTIVE_HEALTHY',
+ isComingUp:
+ status !== undefined &&
+ [
+ REPLICA_STATUS.COMING_UP,
+ REPLICA_STATUS.INIT_READ_REPLICA,
+ REPLICA_STATUS.UNKNOWN,
+ ].includes(status),
+ isFailed:
+ status !== undefined &&
+ [REPLICA_STATUS.ACTIVE_UNHEALTHY, REPLICA_STATUS.INIT_FAILED].includes(status),
+ }
+ }
+ const isTransitioning = requestStatus !== PipelineStatusRequestStatus.None
+ const statusName = getStatusName(pipelineStatusData?.status)
+ return {
+ isReplicating: statusName === 'started' && !isTransitioning,
+ isComingUp: isTransitioning || statusName === 'starting' || statusName === 'stopping',
+ isFailed: statusName === 'failed',
+ }
+ }, [isReplica, replica?.status, pipelineStatusData?.status, requestStatus])
const [edgePath, labelX, labelY] = getSmoothStepPath({
sourceX,
@@ -43,69 +155,39 @@ export const SmoothstepEdge = ({
targetPosition,
})
- const {
- data: lagDuration,
- isPending: isLoading,
- isError,
- } = useReplicationLagQuery(
- {
- id: identifier,
- projectRef: ref,
- connectionString: project?.connectionString,
- },
- { enabled: type === 'replica' && isReplicating, refetchInterval: 10000 }
- )
- const lagValue = Number(lagDuration?.toFixed(2) ?? 0).toLocaleString()
- const hasReplicationLagData = data !== undefined && !isError && isReplicating
+ const { Icon, color, opacity, dashArray, shouldAnimate, shouldSpin, isFilled, strokeWidth } =
+ getEdgeVisual(replicationState)
return (
<>
-
-
- {isFailed && (
-
-
- Snapshot of how far this pipeline is trailing behind right now.
+ Live metrics on how this pipeline is doing right now.
-
- Updates every {refreshIntervalLabel}
-
+
+
+
+
+
+
{isStatusError && (
diff --git a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/ReplicationPipelineStatus.types.ts b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/ReplicationPipelineStatus.types.ts
index 02aa0c694a9d3..568cadb6125fd 100644
--- a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/ReplicationPipelineStatus.types.ts
+++ b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/ReplicationPipelineStatus.types.ts
@@ -3,15 +3,29 @@ export type RetryPolicy =
| { policy: 'manual_retry' }
| { policy: 'timed_retry'; next_retry: string }
+// WAL availability status reported by Postgres for the replication slot. Unrecognized future
+// values from Postgres are surfaced as 'unknown'; the field is absent when not reported.
+export type SlotWalStatus = 'reserved' | 'extended' | 'unreserved' | 'lost' | 'unknown'
+
export type SlotLagMetrics = {
+ active: boolean
+ wal_status?: SlotWalStatus
restart_lsn_bytes: number
confirmed_flush_lsn_bytes: number
- safe_wal_size_bytes: number
+ // null means Postgres reports unlimited slot WAL retention.
+ safe_wal_size_bytes: number | null
write_lag?: number
flush_lag?: number
+ // Milliseconds since the destination last sent feedback. Present even when write/flush lag are not.
+ reply_time_lag?: number
}
-export type SlotLagMetricKey = keyof SlotLagMetrics
+// Numeric metrics rendered as value tiles (kept separate from the non-numeric slot fields above).
+export type SlotLagMetricKey =
+ | 'confirmed_flush_lsn_bytes'
+ | 'safe_wal_size_bytes'
+ | 'flush_lag'
+ | 'reply_time_lag'
export type TableState = {
table_id: number
diff --git a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/ReplicationPipelineStatus.utils.tsx b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/ReplicationPipelineStatus.utils.tsx
index 525b86de9efd8..68ebf1d39e24a 100644
--- a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/ReplicationPipelineStatus.utils.tsx
+++ b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/ReplicationPipelineStatus.utils.tsx
@@ -2,13 +2,11 @@ import dayjs from 'dayjs'
import { Badge } from 'ui'
import { getPipelineDisplayState, normalizePipelineStatusName } from '../Pipeline.utils'
-import { RetryPolicy, TableState } from './ReplicationPipelineStatus.types'
+import { RetryPolicy, SlotWalStatus, TableState } from './ReplicationPipelineStatus.types'
import { ReplicationPipelineStatusData } from '@/data/replication/pipeline-status-query'
import { formatBytes } from '@/lib/helpers'
import { PipelineStatusRequestStatus } from '@/state/replication-pipeline-request-status'
-const numberFormatter = new Intl.NumberFormat()
-
export const getStatusConfig = (state: TableState['state']) => {
switch (state.name) {
case 'queued':
@@ -86,19 +84,19 @@ export const isValidRetryPolicy = (policy: any): policy is RetryPolicy => {
const formatLagBytesValue = (value?: number) => {
if (typeof value !== 'number' || Number.isNaN(value)) {
- return { display: '—', detail: undefined }
+ return { display: 'n/a', detail: undefined }
}
+ // Scale to the most readable unit (e.g. "4 GB"). We intentionally don't surface the raw byte
+ // count as a detail line, since it's unreadable at GB scale (e.g. "4,294,967,296 bytes").
const decimals = value < 1024 ? 0 : value < 1024 * 1024 ? 1 : 2
- const display = formatBytes(value, decimals)
- const detail = `${numberFormatter.format(value)} bytes`
-
- return { display, detail }
+ return { display: formatBytes(value, decimals), detail: undefined }
}
+// Scale to a single readable unit (ms, s, min, h) based on size, with no precise sub-line.
const formatLagDurationValue = (value?: number) => {
if (typeof value !== 'number' || Number.isNaN(value)) {
- return { display: '—', detail: undefined }
+ return { display: 'n/a', detail: undefined }
}
const sign = value < 0 ? '-' : ''
@@ -111,29 +109,148 @@ const formatLagDurationValue = (value?: number) => {
const seconds = duration.asSeconds()
if (seconds < 60) {
- const decimals = seconds >= 10 ? 1 : 2
- return {
- display: `${sign}${seconds.toFixed(decimals)} s`,
- detail: `${numberFormatter.format(value)} ms`,
- }
+ return { display: `${sign}${seconds.toFixed(seconds >= 10 ? 1 : 2)} s`, detail: undefined }
}
const minutes = duration.asMinutes()
if (minutes < 60) {
- const roundedSeconds = Math.round(seconds)
- return {
- display: `${sign}${minutes.toFixed(minutes >= 10 ? 1 : 2)} min`,
- detail: `${numberFormatter.format(roundedSeconds)} s`,
- }
+ return { display: `${sign}${minutes.toFixed(minutes >= 10 ? 1 : 2)} min`, detail: undefined }
}
const hours = duration.asHours()
- const roundedMinutes = Math.round(minutes)
- return {
- display: `${sign}${hours.toFixed(hours >= 10 ? 1 : 2)} h`,
- detail: `${numberFormatter.format(roundedMinutes)} min`,
- }
+ return { display: `${sign}${hours.toFixed(hours >= 10 ? 1 : 2)} h`, detail: undefined }
}
export const getFormattedLagValue = (type: 'bytes' | 'duration', value?: number) =>
type === 'bytes' ? formatLagBytesValue(value) : formatLagDurationValue(value)
+
+export type LagSeverity = 'normal' | 'warning' | 'critical'
+
+type SlotStatusBadgeVariant = 'success' | 'warning' | 'destructive' | 'default'
+
+interface WalStatusMeta {
+ label: string
+ variant: SlotStatusBadgeVariant
+ severity: LagSeverity
+ // Shown in the pipeline-level metrics panel.
+ description: string
+ // Shown in the per-table inline sync view where the slot belongs to a single table.
+ tableDescription: string
+}
+
+// Plain-language meaning, color, and severity for each WAL status Postgres can report for a slot.
+// `variant` drives the badge color; `severity` drives whether the list view raises a warning icon
+// (e.g. "extended" is shown amber as a heads-up but isn't alarming on its own).
+export const WAL_STATUS_META: Record = {
+ reserved: {
+ label: 'Reserved',
+ variant: 'success',
+ severity: 'normal',
+ description:
+ "Healthy. Your database is keeping the WAL files this pipeline's replication slot needs, and they are within the normal WAL size limit.",
+ tableDescription:
+ "Healthy. Your database is keeping the WAL files this table's replication slot needs, and they are within the normal WAL size limit.",
+ },
+ extended: {
+ label: 'Extended',
+ variant: 'warning',
+ severity: 'normal',
+ description:
+ "Healthy, but growing. This pipeline's replication slot is holding on to more WAL than usual, but your database is still keeping everything it needs.",
+ tableDescription:
+ "Healthy, but growing. This table's replication slot is holding on to more WAL than usual, but your database is still keeping everything it needs.",
+ },
+ unreserved: {
+ label: 'Unreserved',
+ variant: 'warning',
+ severity: 'warning',
+ description:
+ "At risk. Your database is no longer reserving all WAL files this pipeline's replication slot needs. If the pipeline does not catch up soon, those files may be removed.",
+ tableDescription:
+ "At risk. Your database is no longer reserving all WAL files this table's replication slot needs. If the pipeline does not catch up soon, those files may be removed.",
+ },
+ lost: {
+ label: 'Lost',
+ variant: 'destructive',
+ severity: 'critical',
+ description:
+ "Broken. Some WAL files this pipeline's replication slot needs have already been removed. The pipeline can no longer continue from this slot. You can recreate a new pipeline, or set the invalidation behavior to recreate and restart the pipeline.",
+ tableDescription:
+ "Broken. Some WAL files this table's replication slot needs have already been removed. The pipeline can no longer continue from this slot. You can recreate a new pipeline, or set the invalidation behavior to recreate and restart the pipeline.",
+ },
+ unknown: {
+ label: 'Unknown',
+ variant: 'default',
+ severity: 'normal',
+ description:
+ "Unknown. Your database reported an unknown state for this pipeline's replication slot.",
+ tableDescription:
+ "Unknown. Your database reported an unknown state for this table's replication slot.",
+ },
+}
+
+// Postgres reports no WAL status (restart_lsn is null) as "unknown" too, so fall back to it.
+export const getWalStatusMeta = (status?: SlotWalStatus): WalStatusMeta =>
+ WAL_STATUS_META[status ?? 'unknown']
+
+// Legend entries from healthiest to most severe, ending with the unknown/unavailable case.
+export const WAL_STATUS_LEGEND: WalStatusMeta[] = [
+ WAL_STATUS_META.reserved,
+ WAL_STATUS_META.extended,
+ WAL_STATUS_META.unreserved,
+ WAL_STATUS_META.lost,
+ WAL_STATUS_META.unknown,
+]
+
+export const getWalStatusSeverity = (status?: SlotWalStatus): LagSeverity =>
+ getWalStatusMeta(status).severity
+
+// Slot-loss risk from how much of the slot's WAL budget has been consumed, rather than fixed byte
+// thresholds: max_slot_wal_keep_size ≈ retained WAL (restart_lsn_bytes) + remaining headroom
+// (safe_wal_size_bytes), so the consumed fraction is how close the slot is to the "lost" state.
+// A null/absent safe_wal_size_bytes now means unlimited retention, so it carries no budget risk.
+export const SLOT_LOSS_WARNING_RATIO = 0.75
+export const SLOT_LOSS_CRITICAL_RATIO = 0.9
+
+export const getSlotBudgetSeverity = (
+ retainedBytes?: number,
+ safeWalSizeBytes?: number | null
+): LagSeverity => {
+ if (
+ typeof retainedBytes !== 'number' ||
+ typeof safeWalSizeBytes !== 'number' ||
+ !Number.isFinite(retainedBytes) ||
+ !Number.isFinite(safeWalSizeBytes)
+ ) {
+ return 'normal'
+ }
+
+ const total = retainedBytes + safeWalSizeBytes
+ // Nothing retained and no headroom left: nothing to flag (also avoids a 0/0 division). When the
+ // headroom is 0 but WAL is still retained, the ratio is 1 and the slot is correctly critical.
+ if (total <= 0) return 'normal'
+
+ const consumedRatio = retainedBytes / total
+ if (consumedRatio >= SLOT_LOSS_CRITICAL_RATIO) return 'critical'
+ if (consumedRatio >= SLOT_LOSS_WARNING_RATIO) return 'warning'
+ return 'normal'
+}
+
+const SEVERITY_RANK: Record = { normal: 0, warning: 1, critical: 2 }
+
+const maxSeverity = (a: LagSeverity, b: LagSeverity): LagSeverity =>
+ SEVERITY_RANK[a] >= SEVERITY_RANK[b] ? a : b
+
+// Overall slot health = the worse of the reported WAL status and how close the WAL budget is to
+// running out. Used to color/flag the lag value in the destinations list.
+export const getSlotHealthSeverity = (slot?: {
+ restart_lsn_bytes?: number
+ safe_wal_size_bytes?: number | null
+ wal_status?: SlotWalStatus
+}): LagSeverity => {
+ if (!slot) return 'normal'
+ return maxSeverity(
+ getWalStatusSeverity(slot.wal_status),
+ getSlotBudgetSeverity(slot.restart_lsn_bytes, slot.safe_wal_size_bytes)
+ )
+}
diff --git a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/SlotLagMetrics.tsx b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/SlotLagMetrics.tsx
index 526981129965a..cede6f71a1da9 100644
--- a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/SlotLagMetrics.tsx
+++ b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/SlotLagMetrics.tsx
@@ -1,38 +1,64 @@
+import dayjs from 'dayjs'
import { Info } from 'lucide-react'
+import { type ReactNode } from 'react'
import { Tooltip, TooltipContent, TooltipTrigger } from 'ui'
import { SlotLagMetricKey, SlotLagMetrics } from './ReplicationPipelineStatus.types'
import { getFormattedLagValue } from './ReplicationPipelineStatus.utils'
+import { SlotConnectionIndicator, SlotStatusBadge } from './SlotStatus'
-const SLOT_LAG_FIELDS: {
+interface SlotLagField {
key: SlotLagMetricKey
label: string
type: 'bytes' | 'duration'
- description: string
-}[] = [
+ description: ReactNode
+ // Friendly label to show in place of a literal "0 bytes" when there's nothing to report.
+ zeroLabel?: string
+ // Friendly label to show when the value is null/absent (e.g. unlimited WAL retention).
+ nullLabel?: string
+ // Optional hover text for the value, derived from the raw value (e.g. an absolute timestamp).
+ getValueTooltip?: (value: number) => string
+}
+
+const SLOT_LAG_FIELDS: SlotLagField[] = [
{
key: 'confirmed_flush_lsn_bytes',
- label: 'WAL Flush lag (size)',
+ label: 'Waiting to sync',
type: 'bytes',
- description:
- 'Bytes between the newest WAL record applied locally and the latest flushed WAL record acknowledged by ETL.',
- },
- {
- key: 'flush_lag',
- label: 'WAL Flush lag (time)',
- type: 'duration',
- description:
- 'Time between flushing recent WAL locally and receiving notification that ETL has written and flushed it.',
+ description: "Changes in your database the pipeline hasn't synced yet.",
+ zeroLabel: 'Caught up',
},
{
key: 'safe_wal_size_bytes',
- label: 'Remaining WAL size',
+ label: 'Room before pausing',
type: 'bytes',
- description:
- 'Bytes still available to write to WAL before this slot risks entering the "lost" state.',
+ description: (
+ <>
+ How much more can pile up before the pipeline has to be set up again. Controlled by the{' '}
+ max_slot_wal_keep_size setting.
+ >
+ ),
+ nullLabel: 'Unlimited',
+ },
+ {
+ key: 'reply_time_lag',
+ label: 'Last check-in',
+ type: 'duration',
+ description: 'Time since the pipeline last reported back to your database.',
+ zeroLabel: 'Just now',
+ // reply_time_lag is "milliseconds ago", so the absolute time is now minus that, in local time.
+ getValueTooltip: (ms) => dayjs().subtract(ms, 'millisecond').format('MMM D, YYYY, h:mm:ss A'),
},
]
+// Resolves a field's value into a display string (+ optional precise detail), honoring the
+// friendly zero/null labels before falling back to the formatted byte/duration value.
+const getFieldDisplay = (field: SlotLagField, value: number | null | undefined) => {
+ if (value == null) return { display: field.nullLabel ?? 'n/a', detail: undefined }
+ if (field.zeroLabel && value === 0) return { display: field.zeroLabel, detail: undefined }
+ return getFormattedLagValue(field.type, value)
+}
+
export const SlotLagMetricsInline = ({
tableName,
metrics,
@@ -45,14 +71,17 @@ export const SlotLagMetricsInline = ({
{tableName}
- •
+
+
+ {metrics.wal_status && }
+
+ )
+}
diff --git a/apps/studio/pages/_app.tsx b/apps/studio/pages/_app.tsx
index 33d4d6b42fe34..f55fde22c31ad 100644
--- a/apps/studio/pages/_app.tsx
+++ b/apps/studio/pages/_app.tsx
@@ -186,7 +186,7 @@ function CustomApp({ Component, pageProps }: AppPropsWithLayout) {
{appTitle ?? 'Supabase'}
-
+
{/* [Alaister]: This has to be an inline style tag here and not a separate component due to next/font */}