diff --git a/apps/studio/components/interfaces/Database/Tables/TableList.tsx b/apps/studio/components/interfaces/Database/Tables/TableList.tsx index b5bdb5286ca..543e37cd625 100644 --- a/apps/studio/components/interfaces/Database/Tables/TableList.tsx +++ b/apps/studio/components/interfaces/Database/Tables/TableList.tsx @@ -58,6 +58,7 @@ import { isWarehouseSchema, } from '../Warehouse/warehouseNaming.utils' import { getWarehouseTableStatusSortRank } from '../Warehouse/warehouseReplication.utils' +import { WarehouseStatusText } from '../Warehouse/WarehouseSyncChip' import { buildTableDetailUrl, getActiveWarehouseSchemas, @@ -88,7 +89,14 @@ import type { SafePostgresTable } from '@/lib/postgres-types' import { SHORTCUT_IDS } from '@/state/shortcuts/registry' import { useShortcut } from '@/state/shortcuts/useShortcut' -type TableListSortColumn = 'name' | 'columns' | 'rows' | 'storage' | 'realtime' | 'warehouse' +type TableListSortColumn = + | 'name' + | 'columns' + | 'rows' + | 'rls' + | 'storage' + | 'realtime' + | 'warehouse' type TableListSort = `${TableListSortColumn}:asc` | `${TableListSortColumn}:desc` interface TableListProps { @@ -145,6 +153,34 @@ export const TableList = ({ const tables = tablesData?.pages.flat() ?? [] + const sourceSchemaForSizes = isWarehouseSchema(selectedSchema) + ? getSourceSchemaName(selectedSchema) + : undefined + + const { data: sourceTablesForSizesData } = useInfiniteTablesQuery( + { + projectRef: project?.ref, + connectionString: project?.connectionString, + schema: sourceSchemaForSizes, + includeColumns: false, + pageSize: 50, + nameFilter: debouncedFilterString, + }, + { + enabled: Boolean(project?.ref && sourceSchemaForSizes), + } + ) + + const postgresSizeByTableName = useMemo(() => { + const sizes = new Map() + for (const table of sourceTablesForSizesData?.pages.flat() ?? []) { + if (table.size) { + sizes.set(table.name, table.size) + } + } + return sizes + }, [sourceTablesForSizesData?.pages]) + const [sentinelRef, sentinelEntry] = useIntersectionObserver({ threshold: 0, rootMargin: '200px 0px 200px 0px', @@ -265,6 +301,9 @@ export const TableList = ({ const getBytes = (entity: (typeof entities)[number]) => 'bytes' in entity && typeof entity.bytes === 'number' ? entity.bytes : undefined + const isRlsEnabled = (entity: (typeof entities)[number]) => + entity.type === ENTITY_TYPE.TABLE && 'rls_enabled' in entity && entity.rls_enabled === true + const getWarehouseSortRank = (entity: (typeof entities)[number]) => { if (entity.type !== ENTITY_TYPE.TABLE) return -1 @@ -295,6 +334,8 @@ export const TableList = ({ } else { comparison = a.rows - b.rows } + } else if (sortColumn === 'rls') { + comparison = Number(isRlsEnabled(a)) - Number(isRlsEnabled(b)) } else if (sortColumn === 'storage') { const bytesA = getBytes(a) const bytesB = getBytes(b) @@ -525,6 +566,11 @@ export const TableList = ({ Rows (Est) + + + RLS + + {entities.length === 0 && filterString.length === 0 && ( - + {visibleTypes.length === 0 ? ( <>

@@ -594,7 +640,7 @@ export const TableList = ({ )} {entities.length === 0 && filterString.length > 0 && ( - +

No results found

Your search for "{filterString}" did not return any results @@ -674,11 +720,36 @@ export const TableList = ({

—

)}
+ + {x.type === ENTITY_TYPE.TABLE && 'rls_enabled' in x ? ( + x.rls_enabled ? ( + + + + + ) : ( +

—

+ ) + ) : ( +

—

+ )} +
{x.type === ENTITY_TYPE.TABLE ? ( ) : ( @@ -830,7 +901,7 @@ export const TableList = ({ - + {isFetchingNextTablesPage ? 'Loading more tables…' : `${footerCount} ${footerCount === 1 ? 'table' : 'tables'}${ diff --git a/apps/studio/components/interfaces/Database/Warehouse/TableEditorDataSourceSuffix.tsx b/apps/studio/components/interfaces/Database/Warehouse/TableEditorDataSourceSuffix.tsx index 6ce707cf6f8..34d1f090a35 100644 --- a/apps/studio/components/interfaces/Database/Warehouse/TableEditorDataSourceSuffix.tsx +++ b/apps/studio/components/interfaces/Database/Warehouse/TableEditorDataSourceSuffix.tsx @@ -1,68 +1,43 @@ -import { cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui' import { useSnapshot } from 'valtio' import { + resolveWarehouseTableState, useProjectReplication, warehouseDemoStore, - type WarehouseProjectReplicationStatus, - type WarehouseTableState, } from './warehouseDemoStore' +import { WarehouseLinkedTableStatus } from './WarehouseLinkedTableStatus' +import { getSourceTableKey, isWarehouseSchema } from './warehouseNaming.utils' import { - getSourceTableKey, - getWarehouseCopyTooltip, - isWarehouseSchema, -} from './warehouseNaming.utils' -import { getReplicationLagDisplay } from './warehouseReplication.utils' + getReplicationLagDisplay, + getWarehouseLinkedTableStatus, +} from './warehouseReplication.utils' +import { WarehouseStatusText } from './WarehouseSyncChip' -function getTableEditorDataSourceSuffix( - schema: string, - table: string, - warehouseTables: Record, - projectReplication: WarehouseProjectReplicationStatus | null -): { - label: string - labelTooltip: string - lagSuffix?: string - lagTooltip?: string - lagTone?: 'warning' | 'destructive' -} | null { - if (isWarehouseSchema(schema)) { - const sourceTableKey = getSourceTableKey(schema, table) - const tableState = warehouseTables[sourceTableKey] - const labelTooltip = getWarehouseCopyTooltip(sourceTableKey) +export const WAREHOUSE_TABLE_EDITOR_TOOLTIP = + 'This is a linked Warehouse table. Rows replicate from Postgres. Recent writes may not appear here yet.' - if (!projectReplication) { - return { - label: 'Linked Warehouse table', - labelTooltip, - } - } +const footerTextSizeClassName = 'text-xs' +const footerLabelClassName = 'text-xs text-foreground-light' - const lagDisplay = getReplicationLagDisplay(projectReplication, tableState?.copyStatus) +function shouldShowWarehouseEditorStatus( + tableKey: string, + isWarehouseView: boolean, + projectReplication: ReturnType, + storedState: (typeof warehouseDemoStore.tables)[string] | undefined +): boolean { + const state = resolveWarehouseTableState(tableKey, storedState, { isWarehouseView }) - return { - label: 'Linked Warehouse table', - labelTooltip, - lagSuffix: lagDisplay.compactSuffix, - lagTooltip: lagDisplay.compactSuffix !== undefined ? lagDisplay.tooltip : undefined, - lagTone: - lagDisplay.tone === 'warning' - ? 'warning' - : lagDisplay.tone === 'destructive' - ? 'destructive' - : undefined, - } - } + if (state.mode !== 'has_warehouse_copy' || !state.copyStatus) return false - const tableKey = getSourceTableKey(schema, table) - if (warehouseTables[tableKey]?.mode === 'has_warehouse_copy') { - return { - label: 'Postgres (live)', - labelTooltip: 'Live Postgres rows', - } - } + const lagDisplay = projectReplication + ? getReplicationLagDisplay(projectReplication, state.copyStatus) + : null + const linkedStatus = getWarehouseLinkedTableStatus(lagDisplay, state.copyStatus) - return null + if (!linkedStatus) return false + if (linkedStatus.type === 'copy' && linkedStatus.copyStatus === 'live') return false + + return true } interface TableEditorDataSourceSuffixProps { @@ -73,42 +48,80 @@ interface TableEditorDataSourceSuffixProps { export function TableEditorDataSourceSuffix({ schema, table }: TableEditorDataSourceSuffixProps) { const warehouseSnap = useSnapshot(warehouseDemoStore) const projectReplication = useProjectReplication() - const suffix = getTableEditorDataSourceSuffix( - schema, - table, - warehouseSnap.tables, - projectReplication + + if (isWarehouseSchema(schema)) { + const tableKey = getSourceTableKey(schema, table) + const showStatus = shouldShowWarehouseEditorStatus( + tableKey, + true, + projectReplication, + warehouseSnap.tables[tableKey] + ) + + return ( + <> + · + + + {showStatus ? ( + <> + · + + + ) : null} + + + ) + } + + const tableKey = getSourceTableKey(schema, table) + const storedState = warehouseSnap.tables[tableKey] + const state = resolveWarehouseTableState(tableKey, storedState, { isWarehouseView: false }) + + if (state.mode !== 'has_warehouse_copy') { + return null + } + + const showStatus = shouldShowWarehouseEditorStatus( + tableKey, + false, + projectReplication, + storedState ) - if (!suffix) return null - - const lagClassName = - suffix.lagTone === 'destructive' - ? 'text-destructive' - : suffix.lagTone === 'warning' - ? 'text-warning' - : 'text-foreground-lighter' - return ( <> · - - - - {suffix.label} - - {suffix.labelTooltip} - - {suffix.lagSuffix !== undefined && suffix.lagTooltip !== undefined && ( - - - - ({suffix.lagSuffix}) - - - {suffix.lagTooltip} - - )} + + + {showStatus ? ( + <> + · + + + ) : null} ) diff --git a/apps/studio/components/interfaces/Database/Warehouse/TableListWarehouseStorageCell.tsx b/apps/studio/components/interfaces/Database/Warehouse/TableListWarehouseStorageCell.tsx index e101c28143d..ad7240b4abc 100644 --- a/apps/studio/components/interfaces/Database/Warehouse/TableListWarehouseStorageCell.tsx +++ b/apps/studio/components/interfaces/Database/Warehouse/TableListWarehouseStorageCell.tsx @@ -1,8 +1,7 @@ import { formatWarehouseSize, - getWarehouseLensSizeTooltip, + getWarehouseLinkedStorageTooltip, getWarehouseStorageDisplay, - getWarehouseStorageTooltip, resolveWarehouseTableState, useWarehouseTableState, } from './warehouseDemoStore' @@ -11,12 +10,15 @@ import { WarehouseStatusText } from './WarehouseSyncChip' interface TableListWarehouseStorageCellProps { tableKey: string tableSize?: string + /** Postgres heap size when viewing the warehouse schema lens. */ + linkedPostgresSize?: string isWarehouseSchemaView: boolean } export function TableListWarehouseStorageCell({ tableKey, tableSize, + linkedPostgresSize, isWarehouseSchemaView, }: TableListWarehouseStorageCellProps) { const storedState = useWarehouseTableState(tableKey) @@ -34,18 +36,15 @@ export function TableListWarehouseStorageCell({ const storageDisplay = getWarehouseStorageDisplay( state, - isWarehouseSchemaView ? undefined : tableSize + isWarehouseSchemaView ? linkedPostgresSize : tableSize, + isWarehouseSchemaView ? tableSize : undefined ) const sizeLabel = isWarehouseSchemaView ? (tableSize ?? formatWarehouseSize(state.warehouseSizeBytes)) : storageDisplay?.postgresSize - const sizeTooltip = isWarehouseSchemaView - ? sizeLabel - ? getWarehouseLensSizeTooltip(sizeLabel, tableKey) - : undefined - : storageDisplay - ? getWarehouseStorageTooltip(storageDisplay) - : undefined + const sizeTooltip = storageDisplay + ? getWarehouseLinkedStorageTooltip(storageDisplay, isWarehouseSchemaView) + : undefined if (!sizeLabel) { return — diff --git a/apps/studio/components/interfaces/Database/Warehouse/WAREHOUSE_API_CONTRACT.md b/apps/studio/components/interfaces/Database/Warehouse/WAREHOUSE_API_CONTRACT.md index e608b31523a..8c2bc9255a2 100644 --- a/apps/studio/components/interfaces/Database/Warehouse/WAREHOUSE_API_CONTRACT.md +++ b/apps/studio/components/interfaces/Database/Warehouse/WAREHOUSE_API_CONTRACT.md @@ -27,10 +27,61 @@ interface WarehouseProjectReplicationStatus { } ``` -Platform may also expose `lag_ms` on linked-table responses; prefer **bytes** for user-facing copy (time-based flush lag is often NULL for logical slots). Studio only surfaces lag amounts when `replication_health` is `behind` or `critical`. +Platform may also expose `lag_ms` on linked-table responses; prefer **bytes** for operator surfaces (Observability). Everyday Studio UI uses `replication_health` buckets only — do not show lag amounts in table list, detail, storage panel, or table editor. Consumed by **Observability → Warehouse** (mock sparklines in prototype; real monitor API later). +## Replication health and Studio display + +Studio derives a **single amalgamated status** per linked table from two inputs: + +| Input | Scope | API field | +| ------------------- | ----------- | --------------------------------------------------------------------------------------------------- | +| Table copy progress | Per table | `warehouse_copy_status` | +| Replication health | Per project | `replication_health` (derived from `replication_lag_bytes`, `pipeline_status`, `replication_phase`) | + +Implementation: `warehouseReplication.utils.ts` (`resolveReplicationHealth`, `getWarehouseLinkedTableStatus`). + +### Project `replication_health` buckets + +Derived from WAL backlog (`replication_lag_bytes`) and pipeline state. Thresholds are an implementation detail; users never see byte amounts outside Observability. + +| Bucket | Condition (prototype) | User-facing label | +| ---------- | ---------------------------------------------- | --------------------------------- | +| `healthy` | Lag < 50 MB, pipeline live, phase streaming | **Live** (when copy is also live) | +| `behind` | 50 MB ≤ lag < 500 MB | **Catching up** | +| `critical` | Lag ≥ 500 MB | **Degraded** | +| `error` | Pipeline or phase error | **Error** | + +`initial_sync` phase maps to **Catching up** at project scope. + +### Table `warehouse_copy_status` labels + +| Copy status | User-facing label | Leading indicator | +| ------------- | ----------------- | --------------------------------------- | +| `backfilling` | **Backfilling** | Spinner | +| `live` | **Live** | Pulsing dot | +| `error` | **Error** | Red dot (hidden in Table Editor footer) | + +### Amalgamation rules (one status shown) + +1. **Table copy wins** for `backfilling` or `error` on the table. +2. Otherwise, if project replication is not caught up, **project health supersedes Live** (`Catching up`, `Degraded`, `Error`). +3. Otherwise **Live** when copy is `live` and project is healthy. + +### Where labels appear + +| Surface | What users see | +| ------------------------------------------ | --------------------------------------------------------------------------- | +| Tables list → Warehouse column | Amalgamated status | +| Table detail header / Storage panel Status | Same | +| Table Editor footer | Expectation tooltip on label; amalgamated status only when not healthy Live | +| Observability | Byte lag, sparklines, operator detail | + +### Table Editor expectation copy + +Warehouse lens footer always explains that rows replicate from Postgres and recent writes may not appear yet. This sets expectations; it does not replace health status when replication is unhealthy. + ## Proposed fields on table metadata Returned by table editor / catalog queries (`getTableEditor`, table list, etc.): diff --git a/apps/studio/components/interfaces/Database/Warehouse/WarehouseLinkedTableStatus.tsx b/apps/studio/components/interfaces/Database/Warehouse/WarehouseLinkedTableStatus.tsx index e3ec6473188..097bb0fd171 100644 --- a/apps/studio/components/interfaces/Database/Warehouse/WarehouseLinkedTableStatus.tsx +++ b/apps/studio/components/interfaces/Database/Warehouse/WarehouseLinkedTableStatus.tsx @@ -13,6 +13,7 @@ interface WarehouseLinkedTableStatusProps { tableKey: string isWarehouseView?: boolean className?: string + showLeadingIndicator?: boolean } /** Single amalgamated Warehouse link status (table copy + project replication). */ @@ -20,6 +21,7 @@ export function WarehouseLinkedTableStatus({ tableKey, isWarehouseView = false, className, + showLeadingIndicator = true, }: WarehouseLinkedTableStatusProps) { const storedState = useWarehouseTableState(tableKey) const projectReplication = useProjectReplication() @@ -40,6 +42,7 @@ export function WarehouseLinkedTableStatus({ copyStatus={linkedTableStatus.copyStatus} appearance="inline" className={className} + showLeadingIndicator={showLeadingIndicator} /> ) } diff --git a/apps/studio/components/interfaces/Database/Warehouse/WarehouseSyncChip.tsx b/apps/studio/components/interfaces/Database/Warehouse/WarehouseSyncChip.tsx index 8a68829c301..3a5792721be 100644 --- a/apps/studio/components/interfaces/Database/Warehouse/WarehouseSyncChip.tsx +++ b/apps/studio/components/interfaces/Database/Warehouse/WarehouseSyncChip.tsx @@ -52,17 +52,22 @@ interface WarehouseCopyStatusProps { className?: string dotSize?: 'default' | 'lg' tooltip?: string + /** When false, omits the leading dot/spinner (e.g. compact Table Editor footer). */ + showLeadingIndicator?: boolean } function CopyStatusLeadingIndicator({ copyStatus, mode, dotSize = 'default', + showLeadingIndicator = true, }: { copyStatus: CopyStatus mode: WarehouseCopyStatusAppearance dotSize?: 'default' | 'lg' + showLeadingIndicator?: boolean }) { + if (!showLeadingIndicator) return null if (copyStatus === 'backfilling') { return (