Files
supabase/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/ReplicationPipelineStatus.utils.tsx
T
Danny WhiteandJoshen Lim 8dd97d75aa refactor(studio): use v2 replication publication APIs (#49844)
## What kind of change does this PR introduce?

Studio data-layer migration.

## What is the current behavior?

Studio loads complete publication details through the original bulk
endpoint and creates publications by executing SQL against the source
database. Publication and source-table data use names where stable table
IDs are available.

## What is the new behavior?

Uses the v2 publication-name, publication-detail, publication mutation,
and source-table endpoints. The existing creation sheet continues to
behave the same, including publishing partition changes through the
parent table by default. Initial-sync selection and Analytics Bucket
associations now consume the selected publication detail. Generated
platform API types and their required nullability updates are included.

The generated Platform contract accounts for roughly 10,000 changed
lines in this PR.

## Dependency

Depends on the v2 source table, table column, and publication endpoints
from
[supabase/platform#37505](https://github.com/supabase/platform/pull/37505),
which are deployed to production.

## To test

1. Open the pipeline creation sheet and select an existing publication.
2. Create a publication with mixed-case schema and table names, then
confirm the table names are shown while stable IDs are submitted.
3. Exercise all four initial-sync policies, including selecting
individual tables.
4. Reopen the publication and table selectors and confirm they refresh
without replacing populated options.
5. Edit and delete a publication.
6. Open an Analytics Bucket associated with a pipeline and confirm its
publication tables resolve correctly.
7. Confirm unlimited WAL retention renders as Unlimited on pipeline
status.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Improved replication publication setup with on-demand table loading,
refresh controls, clearer table labels, and streamlined publication
selection.
- Publication creation and updates now use the latest replication API
and table-based configurations.
- Added clearer handling for tables removed from publications, including
stale-selection warnings.

- **Bug Fixes**
- Prevented table selections from carrying over when switching
publications.
- Improved replication status displays when lag or WAL metrics are
unavailable.
- Updated replication deletion and table management for the latest API
behavior.

- **Tests**
- Expanded coverage for publication creation, table selection, stale
tables, loading states, and replication metrics.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
2026-09-08 11:37:59 +10:00

257 lines
10 KiB
TypeScript

import dayjs from 'dayjs'
import { Badge } from 'ui'
import { getPipelineDisplayState, normalizePipelineStatusName } from '../Pipeline.utils'
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'
export const getStatusConfig = (state: TableState['state']) => {
switch (state.name) {
case 'queued':
return {
badge: <Badge variant="warning">Queued</Badge>,
description: 'Table is waiting for the pipeline to pick it up for replication.',
tooltip: 'Table is waiting for the pipeline to pick it up for replication.',
color: 'text-warning',
}
case 'copying_table':
return {
badge: <Badge variant="success">Copying</Badge>,
description: "Table's existing rows are being copied during the initial sync.",
tooltip: "Table's existing rows are being copied during the initial sync.",
color: 'text-brand-600',
}
case 'copied_table':
return {
badge: <Badge variant="success">Copied</Badge>,
description: 'Initial sync is complete and the table is preparing for ongoing replication.',
tooltip: 'Initial sync is complete and the table is preparing for ongoing replication.',
color: 'text-success-600',
}
case 'following_wal':
return {
badge: <Badge variant="success">Live</Badge>,
description: 'Table is receiving ongoing changes from the WAL.',
tooltip: 'Table is receiving ongoing changes from the WAL.',
color: 'text-success-600',
}
case 'error':
return {
badge: <Badge variant="destructive">Error</Badge>,
description: 'Replication is paused because the table encountered an error.',
tooltip: 'Replication is paused because the table encountered an error.',
color: 'text-destructive-600',
}
default:
return {
badge: <Badge variant="warning">Unknown</Badge>,
description: 'Table status is unavailable.',
tooltip: 'Table status is unavailable.',
color: 'text-warning',
}
}
}
export const getDisabledStateConfig = ({
requestStatus,
statusName,
}: {
requestStatus: PipelineStatusRequestStatus
statusName?: ReplicationPipelineStatusData['status']['name']
}) => {
const normalizedStatusName = normalizePipelineStatusName(statusName)
const displayState = getPipelineDisplayState(requestStatus, normalizedStatusName)
const { title, message } = displayState
return { title, message }
}
export const isValidRetryPolicy = (policy: any): policy is RetryPolicy => {
if (!policy || typeof policy !== 'object' || !policy.policy) return false
switch (policy.policy) {
case 'no_retry':
case 'manual_retry':
return true
case 'timed_retry':
return typeof policy.next_retry === 'string'
default:
return false
}
}
const formatLagBytesValue = (value?: number) => {
if (typeof value !== 'number' || Number.isNaN(value)) {
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
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: 'n/a', detail: undefined }
}
const sign = value < 0 ? '-' : ''
const absMilliseconds = Math.abs(value)
const duration = dayjs.duration(absMilliseconds, 'milliseconds')
if (absMilliseconds < 1000) {
return { display: `${value} ms`, detail: undefined }
}
const seconds = duration.asSeconds()
if (seconds < 60) {
return { display: `${sign}${seconds.toFixed(seconds >= 10 ? 1 : 2)} s`, detail: undefined }
}
const minutes = duration.asMinutes()
if (minutes < 60) {
return { display: `${sign}${minutes.toFixed(minutes >= 10 ? 1 : 2)} min`, detail: undefined }
}
const hours = duration.asHours()
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<SlotWalStatus, WalStatusMeta> = {
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 | null): 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 | null): 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<LagSeverity, number> = { 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)
)
}