feat(studio): wire warehouse UI to real API hooks

Replace the prototype's local valtio demo store (warehouseDemoStore) with
React Query hooks in data/warehouse/ that call new /platform/warehouse/{ref}/...
endpoints (designed in data/warehouse/WAREHOUSE_API_PLAN.md; they 404 until the
backend ships):

- useWarehouseTablesQuery / useWarehouseTableState(s): per-table storage state
- useWarehouseLinkTableMutation: "Copy to Warehouse"
- useWarehouseDetachTableMutation: "Detach Warehouse copy"
- useWarehouseCatalogQuery / useUpdateWarehouseCatalogMutation: catalog access + creds

Rewires the storage panel (now keyed by schema/name), table list, table-detail
layout/overview/settings, the Warehouse Catalog integration cards, and the
Connect-sheet catalog panel. Deletes warehouseDemoStore and the unused
WarehouseModeChip / WarehouseStorageDetailsDialog. Also fixes latent type errors
carried in by the prototype (dangling imports; adds 'table_settings' to the
index-advisor telemetry origin union).

Warehouse Catalog credentials/snippets remain Iceberg-REST-shaped pending the
DuckLake external-access design (see WAREHOUSE_API_PLAN.md).
This commit is contained in:
Benjamin authored and Claude committed 2026-07-20 09:23:15 +00:00
1 parent 462829dd42
commit 94bfd454be
26 files changed
+821 -360

No files matched your search

@@ -2,7 +2,7 @@ import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useParams } from 'common'
import { parseAsBoolean, useQueryState } from 'nuqs'
import { useEffect, useMemo, useRef } from 'react'
import { cn, Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from 'ui'
import { Sheet, SheetContent, SheetHeader, SheetTitle } from 'ui'
import type { ConnectMode, ProjectKeys } from './Connect.types'
import { ConnectConfigSection, ModeSelector } from './ConnectConfigSection'
@@ -2,11 +2,10 @@ import { useParams } from 'common'
import Link from 'next/link'
import { Button } from 'ui'
import { Admonition } from 'ui-patterns'
import { useSnapshot } from 'valtio'
import type { WarehouseCatalogEngine } from '@/components/interfaces/Integrations/WarehouseCatalog/warehouseCatalog.constants'
import { WarehouseCatalogCredentials } from '@/components/interfaces/Integrations/WarehouseCatalog/WarehouseCatalogCredentials'
import { warehouseDemoStore } from '@/components/interfaces/Database/Warehouse/warehouseDemoStore'
import { useWarehouseCatalogQuery } from '@/data/warehouse/warehouse-catalog-query'
interface WarehouseCatalogPanelProps {
queryEngine?: WarehouseCatalogEngine
@@ -14,7 +13,8 @@ interface WarehouseCatalogPanelProps {
export function WarehouseCatalogPanel({ queryEngine = 'env' }: WarehouseCatalogPanelProps) {
const { ref: projectRef } = useParams()
const { catalogEnabled } = useSnapshot(warehouseDemoStore)
const { data: catalog } = useWarehouseCatalogQuery({ projectRef })
const catalogEnabled = catalog?.enabled ?? false
return (
<div className="flex flex-col divide-y">
@@ -34,7 +34,10 @@ export function WarehouseCatalogPanel({ queryEngine = 'env' }: WarehouseCatalogP
]}
/>
) : (
<WarehouseCatalogCredentials queryEngine={queryEngine} />
<WarehouseCatalogCredentials
queryEngine={queryEngine}
credentials={catalog?.credentials}
/>
)}
</div>
</div>
@@ -3,7 +3,6 @@ import { useMemo } from 'react'
import type { ChartConfig } from 'ui'
import { Card, CardContent } from 'ui'
import { Chart, ChartContent, ChartLine, ChartMetric, type ChartLineTick } from 'ui-patterns/Chart'
import { useSnapshot } from 'valtio'
import {
buildTableOverviewSparklineData,
@@ -12,11 +11,9 @@ import {
TABLE_ROW_COUNT_CHART_CONFIG,
TABLE_SIZE_CHART_CONFIG,
} from './TableDetailOverview.utils'
import {
getWarehouseStorageSummaryLabel,
warehouseDemoStore,
} from '@/components/interfaces/Database/Warehouse/warehouseDemoStore'
import type { TableLike } from '@/data/table-editor/table-editor-types'
import { useWarehouseTableStates } from '@/data/warehouse/warehouse-tables-query'
import { getWarehouseStorageSummaryLabel } from '@/data/warehouse/warehouse-types'
import { formatBytes } from '@/lib/helpers'
const DATE_TIME_FORMAT = 'MMM D'
@@ -69,9 +66,9 @@ interface TableDetailOverviewMetricsProps {
}
export function TableDetailOverviewMetrics({ table }: TableDetailOverviewMetricsProps) {
const warehouseSnap = useSnapshot(warehouseDemoStore)
const warehouseStates = useWarehouseTableStates()
const tableKey = `${table.schema}.${table.name}`
const warehouseState = warehouseSnap.tables[tableKey]
const warehouseState = warehouseStates.get(tableKey)
const rowCount = table.live_rows_estimate ?? 0
const columnCount = table.columns?.length ?? 0
@@ -10,9 +10,9 @@ import {
PageSectionTitle,
} from 'ui-patterns/PageSection'
import { TableDetailGeneralSettingsCard } from '@/components/interfaces/Database/Tables/TableDetailGeneralSettingsCard'
import { TableDetailDataApiSection } from '@/components/interfaces/Database/Tables/TableDetailDataApiSection'
import { TableDetailDeleteTableSection } from '@/components/interfaces/Database/Tables/TableDetailDeleteTableSection'
import { TableDetailGeneralSettingsCard } from '@/components/interfaces/Database/Tables/TableDetailGeneralSettingsCard'
import { TableDetailIndexAdvisorSection } from '@/components/interfaces/Database/Tables/TableDetailIndexAdvisorSection'
import { WarehouseTableStoragePanel } from '@/components/interfaces/Database/Warehouse/WarehouseTableStoragePanel'
import { RealtimeToggleDialog } from '@/components/interfaces/TableGridEditor/RealtimeToggleDialog'
@@ -30,7 +30,6 @@ export function TableDetailSettingsTab({ table }: TableDetailSettingsTabProps) {
const { realtimeAll: isRealtimeFeatureEnabled } = useIsFeatureEnabled(['realtime:all'])
const isRealtimeEnabled = useIsTableRealtimeEnabled({ id: table.id })
const showStorage = table.entity_type === ENTITY_TYPE.TABLE
const tableKey = `${table.schema}.${table.name}`
return (
<>
@@ -54,7 +53,11 @@ export function TableDetailSettingsTab({ table }: TableDetailSettingsTabProps) {
</PageSectionSummary>
</PageSectionMeta>
<PageSectionContent>
<WarehouseTableStoragePanel tableKey={tableKey} postgresSize={table.size} />
<WarehouseTableStoragePanel
schema={table.schema}
name={table.name}
postgresSize={table.size}
/>
</PageSectionContent>
</PageSection>
)}
@@ -77,7 +80,9 @@ export function TableDetailSettingsTab({ table }: TableDetailSettingsTabProps) {
>
<Button
variant="default"
icon={<Realtime size={14} className={isRealtimeEnabled ? 'text-brand' : ''} />}
icon={
<Realtime size={14} className={isRealtimeEnabled ? 'text-brand' : ''} />
}
onClick={() => setRealtimeDialogOpen(true)}
>
{isRealtimeEnabled ? 'Disable' : 'Enable'} realtime
@@ -47,14 +47,8 @@ import {
} from 'ui'
import { Input } from 'ui-patterns/DataInputs/Input'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import { useSnapshot } from 'valtio'
import { ProtectedSchemaWarning } from '../ProtectedSchemaWarning'
import {
getWarehouseStorageSummaryLabel,
warehouseDemoStore,
type WarehouseMode,
} from '../Warehouse/warehouseDemoStore'
import { WarehouseSyncChip } from '../Warehouse/WarehouseSyncChip'
import { formatAllEntities } from './Tables.utils'
import { buildTableEditorUrl } from '@/components/grid/SupabaseGrid.utils'
@@ -71,6 +65,11 @@ import { useMaterializedViewsQuery } from '@/data/materialized-views/materialize
import { usePrefetchEditorTablePage } from '@/data/prefetchers/project.$ref.editor.$id'
import { useInfiniteTablesQuery } from '@/data/tables/tables-query'
import { useViewsQuery } from '@/data/views/views-query'
import { useWarehouseTableStates } from '@/data/warehouse/warehouse-tables-query'
import {
getWarehouseStorageSummaryLabel,
type WarehouseMode,
} from '@/data/warehouse/warehouse-types'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
import { useQuerySchemaState } from '@/hooks/misc/useSchemaQueryState'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
@@ -112,7 +111,7 @@ export const TableList = ({
const [sort, setSort] = useState<TableListSort>('name:asc')
const searchInputRef = useRef<HTMLInputElement>(null)
const warehouseSnap = useSnapshot(warehouseDemoStore)
const warehouseStates = useWarehouseTableStates()
const { can: canUpdateTables } = useAsyncCheckPermissions(
PermissionAction.TENANT_SQL_ADMIN_WRITE,
'tables'
@@ -622,8 +621,8 @@ export const TableList = ({
{x.type === ENTITY_TYPE.TABLE ? (
(() => {
const tableKey = `${selectedSchema}.${x.name}`
const wState = warehouseSnap.tables[tableKey] ?? {
mode: 'postgres',
const wState = warehouseStates.get(tableKey) ?? {
mode: 'postgres' as const,
}
const mode = wState.mode as WarehouseMode
const storageSummary = getWarehouseStorageSummaryLabel(
@@ -735,7 +734,10 @@ export const TableList = ({
<Edit size={12} />
<p>Edit definitions</p>
</DropdownMenuItemTooltip>
<DropdownMenuItem className="flex items-center space-x-2" asChild>
<DropdownMenuItem
className="flex items-center space-x-2"
asChild
>
<Link href={`${tableDetailUrl}/settings`}>
<Settings size={12} />
<p>Edit settings</p>
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'
import { useParams } from 'common'
import { useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import {
Dialog,
@@ -9,46 +10,61 @@ import {
DialogTitle,
} from 'ui'
import { clearTableMode } from './warehouseDemoStore'
import { WarehouseProgressSteps } from './WarehouseProgressSteps'
import { useWarehouseDetachTableMutation } from '@/data/warehouse/detach-table-mutation'
const DETACH_PROGRESS = ['Stopping sync', 'Deleting copy']
const STEP_INTERVAL_MS = 1200
const COMPLETION_HOLD_MS = 650
interface WarehouseDetachModalProps {
open: boolean
tableKey: string
schema: string
name: string
copyName: string
onOpenChange: (open: boolean) => void
}
export function WarehouseDetachModal({
open,
tableKey,
schema,
name,
copyName,
onOpenChange,
}: WarehouseDetachModalProps) {
const { ref: projectRef } = useParams()
const [progressIndex, setProgressIndex] = useState(0)
const hasStartedRef = useRef(false)
const { mutate: detachTable, isPending } = useWarehouseDetachTableMutation({
onSuccess: () => {
toast.success('Warehouse copy detached')
onOpenChange(false)
},
onError: (error) => {
toast.error(`Failed to detach Warehouse copy: ${error.message}`)
onOpenChange(false)
},
})
// Kick off the detach exactly once when the dialog opens.
useEffect(() => {
if (!open) {
hasStartedRef.current = false
setProgressIndex(0)
return
}
if (progressIndex >= DETACH_PROGRESS.length) {
const timeout = setTimeout(() => {
clearTableMode(tableKey)
toast.success('Warehouse copy detached')
onOpenChange(false)
}, COMPLETION_HOLD_MS)
return () => clearTimeout(timeout)
if (!hasStartedRef.current && projectRef) {
hasStartedRef.current = true
detachTable({ projectRef, schema, name })
}
}, [open, projectRef, schema, name, detachTable])
// Cosmetic checklist progression while the request is in flight.
useEffect(() => {
if (!isPending || progressIndex >= DETACH_PROGRESS.length - 1) return
const timeout = setTimeout(() => setProgressIndex((index) => index + 1), STEP_INTERVAL_MS)
return () => clearTimeout(timeout)
}, [open, progressIndex, tableKey, onOpenChange])
}, [isPending, progressIndex])
return (
<Dialog open={open} onOpenChange={() => {}}>
@@ -1,3 +1,4 @@
import { useParams } from 'common'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import {
@@ -12,55 +13,49 @@ import {
DialogTitle,
} from 'ui'
import { setTableMode } from './warehouseDemoStore'
import { WarehouseProgressSteps } from './WarehouseProgressSteps'
import { useWarehouseLinkTableMutation } from '@/data/warehouse/link-table-mutation'
interface WarehouseEnablementModalProps {
open: boolean
tableKey: string
tableName: string
schema: string
name: string
onOpenChange: (open: boolean) => void
}
const ATTACH_PROGRESS = ['Creating copy', 'Running initial sync']
const STEP_INTERVAL_MS = 1300
// Beat after the last step checks off, so the completed state is visible.
const COMPLETION_HOLD_MS = 650
export function WarehouseEnablementModal({
open,
tableKey,
tableName,
schema,
name,
onOpenChange,
}: WarehouseEnablementModalProps) {
const [isRunning, setIsRunning] = useState(false)
const { ref: projectRef } = useParams()
const [progressIndex, setProgressIndex] = useState(0)
const warehouseCopyName = `warehouse.${tableName}`
const tableKey = `${schema}.${name}`
const warehouseCopyName = `warehouse.${name}`
const { mutate: linkTable, isPending: isRunning } = useWarehouseLinkTableMutation({
onSuccess: () => {
toast.success('Warehouse copy is live')
onOpenChange(false)
},
})
// Reset the cosmetic checklist whenever the dialog closes.
useEffect(() => {
if (!open) {
setIsRunning(false)
setProgressIndex(0)
}
if (!open) setProgressIndex(0)
}, [open])
// Advance the checklist while the request is in flight; hold on the last step until it resolves.
useEffect(() => {
if (!isRunning) return
if (progressIndex >= ATTACH_PROGRESS.length) {
const timeout = setTimeout(() => {
setTableMode(tableKey, 'has_warehouse_copy')
toast.success('Warehouse copy is live')
onOpenChange(false)
}, COMPLETION_HOLD_MS)
return () => clearTimeout(timeout)
}
if (!isRunning || progressIndex >= ATTACH_PROGRESS.length - 1) return
const timeout = setTimeout(() => setProgressIndex((index) => index + 1), STEP_INTERVAL_MS)
return () => clearTimeout(timeout)
}, [isRunning, progressIndex, tableKey, onOpenChange])
}, [isRunning, progressIndex])
return (
<Dialog
@@ -107,9 +102,11 @@ export function WarehouseEnablementModal({
</Button>
<Button
variant="primary"
disabled={!projectRef}
onClick={() => {
if (!projectRef) return
setProgressIndex(0)
setIsRunning(true)
linkTable({ projectRef, schema, name })
}}
>
Create copy
@@ -1,30 +0,0 @@
import { Badge } from 'ui'
import type { WarehouseMode } from './warehouseDemoStore'
const MODE_LABELS: Record<WarehouseMode, string> = {
postgres: 'Postgres',
has_warehouse_copy: 'Postgres + Warehouse',
}
const MODE_VARIANTS: Record<WarehouseMode, 'default' | 'warning'> = {
postgres: 'default',
has_warehouse_copy: 'warning',
}
interface WarehouseModeChipProps {
mode: WarehouseMode
onClick?: () => void
}
export function WarehouseModeChip({ mode, onClick }: WarehouseModeChipProps) {
return (
<Badge
variant={MODE_VARIANTS[mode]}
className={onClick && mode !== 'postgres' ? 'cursor-pointer hover:opacity-80' : undefined}
onClick={onClick}
>
{MODE_LABELS[mode]}
</Badge>
)
}
@@ -8,6 +8,8 @@ interface WarehouseProgressStepsProps {
* steps after it are pending. Pass `steps.length` to mark everything done.
*/
activeIndex: number
/** Render a small "Progress" header with an N/total counter above the list. */
showProgressHeader?: boolean
}
/**
@@ -15,9 +17,21 @@ interface WarehouseProgressStepsProps {
* a check on completed steps, a spinner on the active one, and a quiet dot on
* what's still to come. More discoverable than a single anonymous spinner.
*/
export function WarehouseProgressSteps({ steps, activeIndex }: WarehouseProgressStepsProps) {
export function WarehouseProgressSteps({
steps,
activeIndex,
showProgressHeader = false,
}: WarehouseProgressStepsProps) {
return (
<div className="flex flex-col gap-3">
{showProgressHeader && (
<div className="flex items-center justify-between text-xs text-foreground-lighter">
<span className="font-medium uppercase tracking-wide">Progress</span>
<span>
{Math.min(activeIndex, steps.length)} / {steps.length}
</span>
</div>
)}
<ul className="flex flex-col overflow-hidden rounded-lg border bg-surface-75">
{steps.map((label, index) => {
const status = index < activeIndex ? 'done' : index === activeIndex ? 'active' : 'pending'
@@ -1,128 +0,0 @@
import { type ReactNode } from 'react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogSection,
DialogTitle,
} from 'ui'
import { TimestampInfo } from 'ui-patterns'
import { InfoTooltip } from 'ui-patterns/info-tooltip'
import {
formatWarehouseSize,
useWarehouseTableState,
type WarehouseMode,
} from './warehouseDemoStore'
import { WarehouseSyncChip } from './WarehouseSyncChip'
interface WarehouseStorageDetailsDialogProps {
open: boolean
tableKey: string
postgresSize?: string
onOpenChange: (open: boolean) => void
}
const MODE_LABELS: Record<WarehouseMode, string> = {
postgres: 'Postgres',
has_warehouse_copy: 'Postgres + Warehouse',
}
const MODE_TOOLTIPS: Partial<Record<WarehouseMode, ReactNode>> = {
has_warehouse_copy: (
<>
Kept in the Postgres heap with a synced columnar copy in Warehouse. Changes in Postgres
propagate to the copy; sync is one-way.
</>
),
}
function MetaRow({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="flex items-center justify-between gap-4 px-4 py-2.5">
<span className="shrink-0 text-foreground-lighter">{label}</span>
<div className="min-w-0 text-right text-foreground">{children}</div>
</div>
)
}
/**
* Read-only details for a warehouse-enabled table — sync status, lag, routing,
* sizes. Lives behind a menu action rather than the Edit Table sheet, since
* storage is an infrastructure concern, not part of the table's schema.
*/
export function WarehouseStorageDetailsDialog({
open,
tableKey,
postgresSize,
onOpenChange,
}: WarehouseStorageDetailsDialogProps) {
const state = useWarehouseTableState(tableKey)
const { mode } = state
const warehouseSize = formatWarehouseSize(state.warehouseSizeBytes)
const copyName = state.copyName ?? tableKey
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent size="medium">
<DialogHeader>
<DialogTitle>Storage details</DialogTitle>
<DialogDescription>
<code className="text-code-inline">{tableKey}</code>
</DialogDescription>
</DialogHeader>
<DialogSection>
<div className="divide-y rounded-md border bg-surface-75 text-sm">
<MetaRow label="Mode">
<div className="flex items-center justify-end gap-1.5">
<span>{MODE_LABELS[mode]}</span>
{MODE_TOOLTIPS[mode] && (
<InfoTooltip side="top" className="max-w-80">
{MODE_TOOLTIPS[mode]}
</InfoTooltip>
)}
</div>
</MetaRow>
{mode === 'has_warehouse_copy' && (
<>
{state.syncState && (
<MetaRow label="Status">
<WarehouseSyncChip syncState={state.syncState} />
</MetaRow>
)}
{state.lastSyncedAt !== undefined && (
<MetaRow label="Last synced">
<TimestampInfo
className="text-sm text-foreground-light"
utcTimestamp={state.lastSyncedAt}
displayAs="local"
/>
</MetaRow>
)}
{state.lagSeconds !== undefined && (
<MetaRow label="Lag">
<span className="text-foreground-light">{state.lagSeconds}s</span>
</MetaRow>
)}
<MetaRow label="Copy name">
<code className="text-code-inline break-all">{copyName}</code>
</MetaRow>
<MetaRow label="Postgres size">{postgresSize ?? '—'}</MetaRow>
<MetaRow label="Warehouse size">{warehouseSize}</MetaRow>
<MetaRow label="Routing">
<span className="text-foreground-light">
Reads → Warehouse · Writes → Postgres
</span>
</MetaRow>
</>
)}
{mode === 'postgres' && <MetaRow label="Size">{postgresSize ?? '—'}</MetaRow>}
</div>
</DialogSection>
</DialogContent>
</Dialog>
)
}
@@ -1,6 +1,6 @@
import { Badge } from 'ui'
import type { SyncState } from './warehouseDemoStore'
import type { WarehouseSyncState as SyncState } from '@/data/warehouse/warehouse-types'
const SYNC_LABELS: Record<SyncState, string> = {
syncing: 'Syncing',
@@ -3,15 +3,12 @@ import { useState, type ReactNode } from 'react'
import { Button } from 'ui'
import { InfoTooltip } from 'ui-patterns/info-tooltip'
import {
formatWarehouseSize,
useWarehouseTableState,
type WarehouseMode,
} from './warehouseDemoStore'
import { WarehouseDetachModal } from './WarehouseDetachModal'
import { WarehouseEnablementModal } from './WarehouseEnablementModal'
import { WarehouseSyncChip } from './WarehouseSyncChip'
import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog'
import { useWarehouseTableState } from '@/data/warehouse/warehouse-tables-query'
import { formatWarehouseSize, type WarehouseMode } from '@/data/warehouse/warehouse-types'
const MODE_LABELS: Record<WarehouseMode, string> = {
postgres: 'Postgres heap',
@@ -50,18 +47,21 @@ function ModeLabel({ mode }: { mode: WarehouseMode }) {
}
interface WarehouseTableStoragePanelProps {
tableKey: string
schema: string
name: string
postgresSize?: string
}
export function WarehouseTableStoragePanel({
tableKey,
schema,
name,
postgresSize,
}: WarehouseTableStoragePanelProps) {
const tableKey = `${schema}.${name}`
const state = useWarehouseTableState(tableKey)
const { mode } = state
const warehouseSize = formatWarehouseSize(state.warehouseSizeBytes)
const copyName = state.copyName ?? `warehouse.${tableKey.split('.').pop() ?? tableKey}`
const copyName = state.copyName ?? `warehouse.${name}`
const [enablementModalOpen, setEnablementModalOpen] = useState(false)
const [detachConfirm, setDetachConfirm] = useState(false)
@@ -70,8 +70,6 @@ export function WarehouseTableStoragePanel({
const [, setShowConnect] = useQueryState('showConnect', parseAsBoolean.withDefault(false))
const [, setConnectTab] = useQueryState('connectTab', parseAsString)
const tableName = tableKey.split('.').pop() ?? tableKey
const openCatalogConnect = () => {
setConnectTab('catalog')
setShowConnect(true)
@@ -134,8 +132,8 @@ export function WarehouseTableStoragePanel({
{enablementModalOpen && (
<WarehouseEnablementModal
open={true}
tableKey={tableKey}
tableName={tableName}
schema={schema}
name={name}
onOpenChange={(open) => {
if (!open) setEnablementModalOpen(false)
}}
@@ -164,7 +162,8 @@ export function WarehouseTableStoragePanel({
{detachProgress && (
<WarehouseDetachModal
open={true}
tableKey={tableKey}
schema={schema}
name={name}
copyName={copyName}
onOpenChange={(open) => {
if (!open) setDetachProgress(false)
@@ -1,71 +0,0 @@
import { proxy, useSnapshot } from 'valtio'
export type WarehouseMode = 'postgres' | 'has_warehouse_copy'
export type SyncState = 'syncing' | 'live' | 'error'
export interface WarehouseTableState {
mode: WarehouseMode
syncState?: SyncState
lagSeconds?: number
lastSyncedAt?: string
copyName?: string
warehouseSizeBytes?: number
}
export const warehouseDemoStore = proxy<{
tables: Record<string, WarehouseTableState>
catalogEnabled: boolean
}>({
tables: {},
catalogEnabled: false,
})
const DEMO_WAREHOUSE_SIZE_BYTES = 197_912_092_672 // ~184 GB
function tableNameFromKey(key: string): string {
return key.split('.').pop() ?? key
}
export function setTableMode(key: string, mode: 'has_warehouse_copy'): void {
const now = new Date().toISOString()
const tableName = tableNameFromKey(key)
warehouseDemoStore.tables[key] = {
mode,
warehouseSizeBytes: DEMO_WAREHOUSE_SIZE_BYTES,
syncState: 'live',
lagSeconds: 12,
lastSyncedAt: now,
copyName: `warehouse.${tableName}`,
}
}
export function clearTableMode(key: string): void {
delete warehouseDemoStore.tables[key]
}
export function setCatalogEnabled(enabled: boolean): void {
warehouseDemoStore.catalogEnabled = enabled
}
export function useWarehouseTableState(tableKey: string): WarehouseTableState {
const snap = useSnapshot(warehouseDemoStore)
return (snap.tables[tableKey] as WarehouseTableState | undefined) ?? { mode: 'postgres' }
}
export function formatWarehouseSize(bytes: number | undefined): string {
if (bytes === undefined) return '184 GB'
const gb = bytes / 1024 ** 3
return `${Math.round(gb)} GB`
}
export function getWarehouseStorageSummaryLabel(
state: Pick<WarehouseTableState, 'mode' | 'warehouseSizeBytes'> | undefined,
postgresSize?: string
): string | null {
const mode = state?.mode ?? 'postgres'
const warehouseSize = formatWarehouseSize(state?.warehouseSizeBytes)
if (mode === 'postgres') return null
return postgresSize ? `${postgresSize} · Copy · ${warehouseSize}` : `Copy · ${warehouseSize}`
}
@@ -1,25 +1,30 @@
import { useState } from 'react'
import { useParams } from 'common'
import { useEffect, useState } from 'react'
import { Button, Card, CardContent, CardFooter, Switch } from 'ui'
import { useSnapshot } from 'valtio'
import {
WAREHOUSE_CATALOG_ENGINES,
type WarehouseCatalogEngine,
} from './warehouseCatalog.constants'
import { WarehouseCatalogCredentials } from './WarehouseCatalogCredentials'
import {
setCatalogEnabled,
warehouseDemoStore,
} from '@/components/interfaces/Database/Warehouse/warehouseDemoStore'
import { useUpdateWarehouseCatalogMutation } from '@/data/warehouse/warehouse-catalog-mutation'
import { useWarehouseCatalogQuery } from '@/data/warehouse/warehouse-catalog-query'
/**
* Enable/disable card for the Warehouse Catalog integration. Mirrors the Data API
* enable card: switch + Save/Cancel committing to the demo store.
* Enable/disable card for the Warehouse Catalog integration. Mirrors the Data API enable card:
* switch + Save/Cancel committing to the catalog endpoint.
*/
export const WarehouseCatalogEnableCard = () => {
const { catalogEnabled } = useSnapshot(warehouseDemoStore)
const [draft, setDraft] = useState(catalogEnabled)
const isDirty = draft !== catalogEnabled
const { ref: projectRef } = useParams()
const { data: catalog } = useWarehouseCatalogQuery({ projectRef })
const enabled = catalog?.enabled ?? false
const [draft, setDraft] = useState(enabled)
// Keep the draft in sync once the current value loads or changes elsewhere.
useEffect(() => setDraft(enabled), [enabled])
const { mutate: updateCatalog, isPending } = useUpdateWarehouseCatalogMutation()
const isDirty = draft !== enabled
return (
<Card>
@@ -32,23 +37,27 @@ export const WarehouseCatalogEnableCard = () => {
credentials. Access is managed independently from your database connection settings.
</span>
</div>
<Switch size="large" checked={draft} onCheckedChange={setDraft} />
<Switch size="large" checked={draft} onCheckedChange={setDraft} disabled={isPending} />
</div>
</CardContent>
<CardFooter className="flex justify-end gap-2">
<Button
type="button"
variant="default"
disabled={!isDirty}
onClick={() => setDraft(catalogEnabled)}
disabled={!isDirty || isPending}
onClick={() => setDraft(enabled)}
>
Cancel
</Button>
<Button
type="button"
variant="primary"
disabled={!isDirty}
onClick={() => setCatalogEnabled(draft)}
loading={isPending}
disabled={!isDirty || !projectRef}
onClick={() => {
if (!projectRef) return
updateCatalog({ projectRef, enabled: draft })
}}
>
Save
</Button>
@@ -61,12 +70,13 @@ export const WarehouseCatalogEnableCard = () => {
* Catalog credentials card. Only rendered once the catalog is enabled.
*/
export const WarehouseCatalogCredentialsCard = () => {
const { catalogEnabled } = useSnapshot(warehouseDemoStore)
const { ref: projectRef } = useParams()
const { data: catalog } = useWarehouseCatalogQuery({ projectRef })
const [queryEngine, setQueryEngine] = useState<WarehouseCatalogEngine>(
WAREHOUSE_CATALOG_ENGINES[0]?.key ?? 'env'
)
if (!catalogEnabled) return null
if (!catalog?.enabled) return null
return (
<Card>
@@ -80,6 +90,7 @@ export const WarehouseCatalogCredentialsCard = () => {
<WarehouseCatalogCredentials
queryEngine={queryEngine}
onQueryEngineChange={setQueryEngine}
credentials={catalog.credentials}
/>
</CardContent>
</Card>
@@ -2,29 +2,42 @@ import { useCallback } from 'react'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from 'ui'
import { CodeBlock } from 'ui-patterns/CodeBlock'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
import {
buildWarehouseCatalogEnv,
getWarehouseCatalogEngineContent,
WAREHOUSE_CATALOG_CREDENTIALS,
WAREHOUSE_CATALOG_ENGINES,
WAREHOUSE_CATALOG_ENV_VARS,
type WarehouseCatalogEngine,
} from './warehouseCatalog.constants'
import { EnvRow } from '@/components/interfaces/ConnectSheet/content/server/common/EnvRow'
import CopyButton from '@/components/ui/CopyButton'
import type { WarehouseCatalogCredentials as CatalogCredentials } from '@/data/warehouse/warehouse-catalog-query'
interface WarehouseCatalogCredentialsProps {
queryEngine: WarehouseCatalogEngine
onQueryEngineChange?: (engine: WarehouseCatalogEngine) => void
credentials?: CatalogCredentials
}
function maskToken(token: string): string {
return `${token.slice(0, 4)}${'•'.repeat(16)}`
}
export function WarehouseCatalogCredentials({
queryEngine,
onQueryEngineChange,
credentials,
}: WarehouseCatalogCredentialsProps) {
const buildCatalogEnv = useCallback(() => buildWarehouseCatalogEnv(), [])
const engineContent = getWarehouseCatalogEngineContent(queryEngine)
const buildCatalogEnv = useCallback(
() => (credentials ? buildWarehouseCatalogEnv(credentials) : ''),
[credentials]
)
const engineContent = credentials
? getWarehouseCatalogEngineContent(queryEngine, credentials)
: undefined
return (
<div className="flex flex-col gap-y-4">
@@ -51,7 +64,12 @@ export function WarehouseCatalogCredentials({
</FormItemLayout>
)}
{queryEngine === 'env' ? (
{!credentials || !engineContent ? (
<div className="space-y-2">
<ShimmeringLoader />
<ShimmeringLoader className="w-3/4" delayIndex={1} />
</div>
) : queryEngine === 'env' ? (
<div className="overflow-hidden rounded-lg border bg-surface-100">
<div className="flex items-center justify-between border-b bg-surface-200 px-4 py-2">
<span className="font-mono text-xs text-foreground-light">catalog.env</span>
@@ -63,40 +81,37 @@ export function WarehouseCatalogCredentials({
/>
</div>
<div className="divide-y">
<EnvRow
name={WAREHOUSE_CATALOG_ENV_VARS.catalogUri}
value={WAREHOUSE_CATALOG_CREDENTIALS.catalogUri}
>
<EnvRow name={WAREHOUSE_CATALOG_ENV_VARS.catalogUri} value={credentials.catalogUri}>
<CopyButton
variant="default"
size="tiny"
iconOnly
aria-label="Copy catalog URI"
text={WAREHOUSE_CATALOG_CREDENTIALS.catalogUri}
text={credentials.catalogUri}
/>
</EnvRow>
<EnvRow
name={WAREHOUSE_CATALOG_ENV_VARS.accessToken}
value={WAREHOUSE_CATALOG_CREDENTIALS.accessTokenMasked}
value={maskToken(credentials.accessToken)}
>
<CopyButton
variant="default"
size="tiny"
iconOnly
aria-label="Copy access token"
text={WAREHOUSE_CATALOG_CREDENTIALS.accessToken}
text={credentials.accessToken}
/>
</EnvRow>
<EnvRow
name={WAREHOUSE_CATALOG_ENV_VARS.warehouseIdentifier}
value={WAREHOUSE_CATALOG_CREDENTIALS.warehouseId}
value={credentials.warehouseId}
>
<CopyButton
variant="default"
size="tiny"
iconOnly
aria-label="Copy warehouse identifier"
text={WAREHOUSE_CATALOG_CREDENTIALS.warehouseId}
text={credentials.warehouseId}
/>
</EnvRow>
</div>
@@ -1,3 +1,5 @@
import type { WarehouseCatalogCredentials } from '@/data/warehouse/warehouse-catalog-query'
export type WarehouseCatalogEngine = 'env' | 'duckdb' | 'spark' | 'trino' | 'pyiceberg'
export const WAREHOUSE_CATALOG_ENGINES: { key: WarehouseCatalogEngine; label: string }[] = [
@@ -8,20 +10,13 @@ export const WAREHOUSE_CATALOG_ENGINES: { key: WarehouseCatalogEngine; label: st
{ key: 'pyiceberg', label: 'PyIceberg' },
]
export const WAREHOUSE_CATALOG_CREDENTIALS = {
catalogUri: 'https://catalog.supabase.example',
accessToken: 'sbw_demo_token_abc123xyz',
accessTokenMasked: 'sbw_••••••••••••••••',
warehouseId: 'wh_demo123',
} as const
export const WAREHOUSE_CATALOG_ENV_VARS = {
catalogUri: 'CATALOG_URI',
accessToken: 'ACCESS_TOKEN',
warehouseIdentifier: 'WAREHOUSE_IDENTIFIER',
} as const
export function buildWarehouseCatalogEnv(creds = WAREHOUSE_CATALOG_CREDENTIALS): string {
export function buildWarehouseCatalogEnv(creds: WarehouseCatalogCredentials): string {
return [
`${WAREHOUSE_CATALOG_ENV_VARS.catalogUri}=${creds.catalogUri}`,
`${WAREHOUSE_CATALOG_ENV_VARS.accessToken}=${creds.accessToken}`,
@@ -29,9 +24,18 @@ export function buildWarehouseCatalogEnv(creds = WAREHOUSE_CATALOG_CREDENTIALS):
].join('\n')
}
/**
* Per-engine connection snippets for the Warehouse catalog.
*
* NOTE: these templates are currently written against an Iceberg REST catalog shape
* (`uri` / `token` / `warehouse`). The Warehouse product is backed by DuckLake, whose external
* access model differs (a Postgres catalog connection + object storage). The credential triple
* and these snippets should be revisited with the DuckLake catalog design before GA — see the
* warehouse endpoints plan.
*/
export function getWarehouseCatalogEngineContent(
engine: WarehouseCatalogEngine,
creds = WAREHOUSE_CATALOG_CREDENTIALS
creds: WarehouseCatalogCredentials
): { headerLabel: string; language: 'sql' | 'toml' | 'python'; value: string } {
const { catalogUri, accessToken, warehouseId } = creds
@@ -4,7 +4,7 @@ import { TableEditor } from 'icons'
import { Check, Edit, X } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import React, { type PropsWithChildren } from 'react'
import { type PropsWithChildren } from 'react'
import {
BreadcrumbItem,
BreadcrumbLink,
@@ -26,14 +26,9 @@ import {
PageHeaderTitle,
} from 'ui-patterns/PageHeader'
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
import { useSnapshot } from 'valtio'
import DatabaseLayout from './DatabaseLayout'
import { buildTableEditorUrl } from '@/components/grid/SupabaseGrid.utils'
import {
getWarehouseStorageSummaryLabel,
warehouseDemoStore,
} from '@/components/interfaces/Database/Warehouse/warehouseDemoStore'
import { WarehouseSyncChip } from '@/components/interfaces/Database/Warehouse/WarehouseSyncChip'
import DeleteConfirmationDialogs from '@/components/interfaces/TableGridEditor/DeleteConfirmationDialogs'
import { SidePanelEditor } from '@/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor'
@@ -41,6 +36,8 @@ import { EntityTypeIcon } from '@/components/ui/EntityTypeIcon'
import { useDatabasePublicationsQuery } from '@/data/database-publications/database-publications-query'
import { useTableEditorQuery } from '@/data/table-editor/table-editor-query'
import { isTableLike } from '@/data/table-editor/table-editor-types'
import { useWarehouseTableStates } from '@/data/warehouse/warehouse-tables-query'
import { getWarehouseStorageSummaryLabel } from '@/data/warehouse/warehouse-types'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { useTableEditorStateSnapshot } from '@/state/table-editor'
@@ -79,12 +76,12 @@ export function TableDetailLayout({
selectedTable?.id !== undefined &&
(realtimePublication?.tables ?? []).some((table) => table.id === selectedTable.id)
const warehouseSnap = useSnapshot(warehouseDemoStore)
const warehouseStates = useWarehouseTableStates()
const tableKey =
selectedTable?.schema !== undefined && selectedTable?.name !== undefined
? `${selectedTable.schema}.${selectedTable.name}`
: undefined
const warehouseState = tableKey ? warehouseSnap.tables[tableKey] : undefined
const warehouseState = tableKey ? warehouseStates.get(tableKey) : undefined
const { can: canUpdateTables } = useAsyncCheckPermissions(
PermissionAction.TENANT_SQL_ADMIN_WRITE,
@@ -0,0 +1,208 @@
# Warehouse — required new platform endpoints
This document specifies the **new platform API endpoints** the Studio Warehouse feature calls. None
of them exist yet — the Studio hooks in this folder (`data/warehouse/*`) call them and will 404
until the backend ships. The goal here is to design the payloads and enumerate the work for the
platform team. **No platform code has been written.**
The endpoints live in the existing platform API repo (NestJS) alongside replication, e.g.
`/Users/bnj/supabase/platform/api/apps/mgmt-api/src/routes/platform/`.
## Architecture: Warehouse is a thin layer over Replication + DuckLake
The Warehouse product reuses the existing replication/ETL machinery. Conceptually:
- Each project has **at most one** "warehouse pipeline": a DuckLake destination plugged into the
project itself, plus a pipeline and a publication. Naming convention:
- pipeline/destination name: `supabase_warehouse_pipeline`
- publication name: `supabase_warehouse_publication`
- **"Copy to Warehouse"** for a table = ensure that pipeline exists (create it lazily on first use),
add the table to its publication, and ensure the pipeline is running.
- **"Detach Warehouse copy"** = remove the table from the publication (and drop its DuckLake copy).
- **Catalog access** = a project-level toggle that exposes catalog credentials so external query
engines can read the DuckLake tables without touching Postgres.
The warehouse endpoints are a **convenience facade** so Studio never has to orchestrate
sources/destinations/pipelines/publications directly. Internally they call the existing replication
service (see `replication.client.ts`) and the DuckLake config resolver
(`ducklake-supabase-config.ts`).
### Existing building blocks reused (already in the platform API)
| Concern | Existing endpoint / module |
| ----------------------------- | ------------------------------------------------------------------------------------- |
| Create tenant + source | `POST /platform/replication/{ref}/tenants-sources` |
| Create destination + pipeline | `POST /platform/replication/{ref}/destinations-pipelines` |
| Start / stop pipeline | `POST /platform/replication/{ref}/pipelines/{id}/start` \| `/stop` |
| Pipeline status | `GET /platform/replication/{ref}/pipelines/{id}/status` |
| Per-table replication status | `GET /platform/replication/{ref}/pipelines/{id}/replication-status` |
| Create / update publication | `POST /platform/replication/{ref}/sources/{source_id}/publications[/{name}]` |
| DuckLake destination config | `ReplicationDucklakeConfigSchema` in `destinations.dto.ts` (Supabase-managed variant) |
The DuckLake destination for the warehouse pipeline uses the **Supabase-managed** variant, pointed
at the project itself:
```jsonc
{
"ducklake": {
"catalog": { "type": "supabase_project", "project_ref": "<ref>" },
"storage": { "type": "supabase_storage", "project_ref": "<ref>", "bucket": "warehouse" },
},
}
```
---
## New endpoints
Base path: `/platform/warehouse/{ref}` (mirrors `/platform/replication/{ref}`).
Auth: `@AuthWithProjectRef(PermissionAction.REPLICATION_ADMIN_READ | WRITE, '*')` (or a dedicated
`WAREHOUSE_ADMIN_*` action). Gate writes behind the same entitlement as ETL
(`@ApiEntitlementRequired('replication.etl')` or a new `warehouse` entitlement).
### 1. List linked tables — `GET /platform/warehouse/{ref}/tables`
Returns every table that currently has a Warehouse copy, with live sync status. Drives the Tables
list chips, the table-detail header badge, and the Storage panel.
Response `200`:
```jsonc
{
"tables": [
{
"schema": "public",
"name": "orders",
"state": "live", // 'syncing' | 'live' | 'error' (derived from replication-status)
"lag_ms": 12000, // optional
"last_synced_at": "2026-06-23T17:48:00Z", // optional ISO-8601
"copy_name": "warehouse.orders",
"warehouse_size_bytes": 197912092672, // optional
},
],
}
```
Server behavior: if no warehouse pipeline exists, return `{ "tables": [] }`. Otherwise read the
publication's table list and join with `GET .../pipelines/{id}/replication-status` to populate
`state` / `lag_ms` / `last_synced_at`. Tables not in the publication are simply absent (Studio
treats absent = Postgres-only).
Studio hook: `useWarehouseTablesQuery` (`warehouse-tables-query.ts`).
### 2. Link a table ("Copy to Warehouse") — `POST /platform/warehouse/{ref}/tables`
Request:
```jsonc
{ "schema": "public", "name": "orders" }
```
Response `202`: the new linked-table row (same shape as an item in endpoint #1). `state` will
usually be `"syncing"` initially.
Server behavior (idempotent):
1. Ensure the warehouse tenant + source exist (`tenants-sources`).
2. Ensure the `supabase_warehouse_pipeline` destination + pipeline exist. If not, create the
DuckLake destination (Supabase-managed, pointed at this project) + pipeline +
`supabase_warehouse_publication` via `destinations-pipelines`.
3. Add `{schema, name}` to `supabase_warehouse_publication` (create-or-update publication).
4. Ensure the pipeline is running (`/start` if stopped).
Errors: `409` if the table can't be replicated (e.g. no primary key / replica identity), `402` if
the entitlement is missing.
Studio hook: `useWarehouseLinkTableMutation` (`link-table-mutation.ts`).
### 3. Detach a table ("Detach Warehouse copy") — `DELETE /platform/warehouse/{ref}/tables/{schema}/{name}`
Response `204`.
Server behavior: remove `{schema, name}` from `supabase_warehouse_publication` (update publication)
and drop the table's DuckLake copy. Leave the pipeline running for the remaining tables; if it was
the last table, the pipeline may be stopped (implementation detail). The Postgres source table is
untouched.
Studio hook: `useWarehouseDetachTableMutation` (`detach-table-mutation.ts`).
### 4. Get catalog access — `GET /platform/warehouse/{ref}/catalog`
Returns whether external catalog access is enabled and, if so, the credentials external query
engines use.
Response `200`:
```jsonc
{
"enabled": true,
"credentials": {
// present only when enabled
"catalog_uri": "https://catalog.<ref>.supabase.co",
"access_token": "sbw_...", // secret; only returned to project admins
"warehouse_id": "wh_...",
},
}
```
Studio hook: `useWarehouseCatalogQuery` (`warehouse-catalog-query.ts`).
### 5. Enable / disable catalog access — `POST /platform/warehouse/{ref}/catalog`
Request:
```jsonc
{ "enabled": true }
```
Response `200`: same shape as endpoint #4 (on enable, includes freshly provisioned `credentials`).
Server behavior: on enable, provision the catalog endpoint + access token for external readers; on
disable, revoke them. Access is managed independently from the project's database connection
settings.
Studio hook: `useUpdateWarehouseCatalogMutation` (`warehouse-catalog-mutation.ts`).
### (Optional, not yet called by Studio)
- `POST /platform/warehouse/{ref}/catalog/token/rotate` → rotate the access token.
- `GET /platform/warehouse/{ref}` → project-level overview (`{ provisioned, pipeline_id,
pipeline_status, catalog: { enabled } }`) if a single status call is preferred over #1 + #4.
---
## ⚠️ Open question: catalog credentials shape (DuckLake vs Iceberg REST)
The credentials triple above (`catalog_uri` / `access_token` / `warehouse_id`) and the per-engine
snippets in `warehouseCatalog.constants.ts` are currently written for an **Iceberg REST catalog**
(the prototype mocked it that way). The Warehouse product is backed by **DuckLake**, whose external
access model is different — typically a **Postgres catalog connection** plus **object-storage
credentials** rather than a REST endpoint + token.
Before GA, decide one of:
1. Expose DuckLake directly → credentials become `{ catalog_postgres_url, storage_endpoint,
storage_access_key_id, storage_secret_access_key, ... }`, and the DuckDB/Spark/Trino/PyIceberg
snippets change to `ATTACH 'ducklake:postgres:...'`-style configs.
2. Front DuckLake with an Iceberg-REST-compatible catalog → keep the current triple + snippets.
The Studio data layer isolates this: only `WarehouseCatalogCredentials` (`warehouse-catalog-query.ts`)
and `warehouseCatalog.constants.ts` need to change when the shape is finalized.
---
## Hook → endpoint → underlying replication mapping
| Studio hook | New warehouse endpoint | Underlying replication work |
| ----------------------------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| `useWarehouseTablesQuery` | `GET .../warehouse/{ref}/tables` | read publication tables + `replication-status` |
| `useWarehouseLinkTableMutation` | `POST .../warehouse/{ref}/tables` | ensure pipeline (`tenants-sources`, `destinations-pipelines`) + update publication + start |
| `useWarehouseDetachTableMutation` | `DELETE .../warehouse/{ref}/tables/{schema}/{name}` | update publication (remove table) |
| `useWarehouseCatalogQuery` | `GET .../warehouse/{ref}/catalog` | read catalog access + credentials |
| `useUpdateWarehouseCatalogMutation` | `POST .../warehouse/{ref}/catalog` | provision/revoke catalog credentials |
## Excluded (deferred upstream)
- **Move to Warehouse** (fully migrating a table off the Postgres heap) — already deferred to a
separate branch; no endpoint designed here.
- **Snapshots / time-travel** — only surfaced after a Move in the prototype; out of scope.
@@ -0,0 +1,72 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { warehouseKeys } from './keys'
import { constructHeaders, fetchHandler } from '@/data/fetchers'
import { API_URL } from '@/lib/constants'
import { ResponseError, type UseCustomMutationOptions } from '@/types'
export type WarehouseDetachTableVariables = {
projectRef: string
schema: string
name: string
}
/**
* Detaches a table's Warehouse copy ("Detach Warehouse copy"). Server-side this removes the table
* from the `supabase_warehouse_pipeline` publication and drops its DuckLake copy. The source table
* in Postgres is unaffected.
*/
export async function detachWarehouseTable({
projectRef,
schema,
name,
}: WarehouseDetachTableVariables) {
if (!projectRef) throw new Error('projectRef is required')
const headers = await constructHeaders({ 'Content-Type': 'application/json' })
const response = await fetchHandler(
`${API_URL}/platform/warehouse/${projectRef}/tables/${encodeURIComponent(schema)}/${encodeURIComponent(name)}`,
{ method: 'DELETE', headers, credentials: 'include' }
)
if (!response.ok) {
let message = `Failed to detach Warehouse copy (${response.status})`
try {
const body = await response.json()
message = body?.message ?? body?.msg ?? message
} catch {
// response had no JSON body; keep the default message
}
throw new ResponseError(message, response.status)
}
}
type WarehouseDetachTableData = Awaited<ReturnType<typeof detachWarehouseTable>>
export const useWarehouseDetachTableMutation = ({
onSuccess,
onError,
...options
}: Omit<
UseCustomMutationOptions<WarehouseDetachTableData, ResponseError, WarehouseDetachTableVariables>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<WarehouseDetachTableData, ResponseError, WarehouseDetachTableVariables>({
mutationFn: detachWarehouseTable,
async onSuccess(data, variables, context) {
await queryClient.invalidateQueries({ queryKey: warehouseKeys.tables(variables.projectRef) })
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
if (onError === undefined) {
toast.error(`Failed to detach Warehouse copy: ${data.message}`)
} else {
onError(data, variables, context)
}
},
...options,
})
}
+6
View File
@@ -0,0 +1,6 @@
export const warehouseKeys = {
tables: (projectRef: string | undefined) =>
['projects', projectRef, 'warehouse', 'tables'] as const,
catalog: (projectRef: string | undefined) =>
['projects', projectRef, 'warehouse', 'catalog'] as const,
}
@@ -0,0 +1,64 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { warehouseKeys } from './keys'
import type { WarehouseLinkedTable } from './warehouse-types'
import { fetchPost } from '@/data/fetchers'
import { API_URL } from '@/lib/constants'
import { ResponseError, type UseCustomMutationOptions } from '@/types'
export type WarehouseLinkTableVariables = {
projectRef: string
schema: string
name: string
}
/**
* Links a table into Warehouse ("Copy to Warehouse"). Server-side this ensures the project's
* `supabase_warehouse_pipeline` (DuckLake destination) exists, adds the table to its publication,
* and starts the pipeline. Returns the new linked-table status.
*/
export async function linkWarehouseTable({
projectRef,
schema,
name,
}: WarehouseLinkTableVariables) {
if (!projectRef) throw new Error('projectRef is required')
const result = await fetchPost<WarehouseLinkedTable>(
`${API_URL}/platform/warehouse/${projectRef}/tables`,
{ schema, name },
{ credentials: 'include' }
)
if (result instanceof ResponseError) throw result
return result
}
type WarehouseLinkTableData = Awaited<ReturnType<typeof linkWarehouseTable>>
export const useWarehouseLinkTableMutation = ({
onSuccess,
onError,
...options
}: Omit<
UseCustomMutationOptions<WarehouseLinkTableData, ResponseError, WarehouseLinkTableVariables>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<WarehouseLinkTableData, ResponseError, WarehouseLinkTableVariables>({
mutationFn: linkWarehouseTable,
async onSuccess(data, variables, context) {
await queryClient.invalidateQueries({ queryKey: warehouseKeys.tables(variables.projectRef) })
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
if (onError === undefined) {
toast.error(`Failed to create Warehouse copy: ${data.message}`)
} else {
onError(data, variables, context)
}
},
...options,
})
}
@@ -0,0 +1,65 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { warehouseKeys } from './keys'
import { fetchPost } from '@/data/fetchers'
import { API_URL } from '@/lib/constants'
import { ResponseError, type UseCustomMutationOptions } from '@/types'
export type UpdateWarehouseCatalogVariables = {
projectRef: string
enabled: boolean
}
/**
* Enables or disables external Warehouse Catalog access for the project. Enabling provisions the
* catalog credentials that external query engines (DuckDB, Spark, Trino, PyIceberg) use to read
* Warehouse tables without touching the primary database.
*/
export async function updateWarehouseCatalog({
projectRef,
enabled,
}: UpdateWarehouseCatalogVariables) {
if (!projectRef) throw new Error('projectRef is required')
const result = await fetchPost(
`${API_URL}/platform/warehouse/${projectRef}/catalog`,
{ enabled },
{ credentials: 'include' }
)
if (result instanceof ResponseError) throw result
return result
}
type UpdateWarehouseCatalogData = Awaited<ReturnType<typeof updateWarehouseCatalog>>
export const useUpdateWarehouseCatalogMutation = ({
onSuccess,
onError,
...options
}: Omit<
UseCustomMutationOptions<
UpdateWarehouseCatalogData,
ResponseError,
UpdateWarehouseCatalogVariables
>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<UpdateWarehouseCatalogData, ResponseError, UpdateWarehouseCatalogVariables>({
mutationFn: updateWarehouseCatalog,
async onSuccess(data, variables, context) {
await queryClient.invalidateQueries({ queryKey: warehouseKeys.catalog(variables.projectRef) })
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
if (onError === undefined) {
toast.error(`Failed to update Warehouse Catalog: ${data.message}`)
} else {
onError(data, variables, context)
}
},
...options,
})
}
@@ -0,0 +1,68 @@
import { useQuery, type UseQueryOptions } from '@tanstack/react-query'
import { warehouseKeys } from './keys'
import { fetchGet } from '@/data/fetchers'
import { API_URL } from '@/lib/constants'
import { ResponseError } from '@/types'
export interface WarehouseCatalogCredentials {
catalogUri: string
accessToken: string
warehouseId: string
}
export interface WarehouseCatalog {
enabled: boolean
credentials?: WarehouseCatalogCredentials
}
type WarehouseCatalogApiResponse = {
enabled: boolean
credentials?: {
catalog_uri: string
access_token: string
warehouse_id: string
}
}
export type WarehouseCatalogVariables = { projectRef?: string }
export type WarehouseCatalogData = WarehouseCatalog
export type WarehouseCatalogError = ResponseError
export async function getWarehouseCatalog(
{ projectRef }: WarehouseCatalogVariables,
signal?: AbortSignal
): Promise<WarehouseCatalogData> {
if (!projectRef) throw new Error('projectRef is required')
const result = await fetchGet<WarehouseCatalogApiResponse>(
`${API_URL}/platform/warehouse/${projectRef}/catalog`,
{ abortSignal: signal, credentials: 'include' }
)
if (result instanceof ResponseError) throw result
return {
enabled: result.enabled,
credentials: result.credentials
? {
catalogUri: result.credentials.catalog_uri,
accessToken: result.credentials.access_token,
warehouseId: result.credentials.warehouse_id,
}
: undefined,
}
}
export const useWarehouseCatalogQuery = <TData = WarehouseCatalogData>(
{ projectRef }: WarehouseCatalogVariables,
{
enabled = true,
...options
}: Omit<UseQueryOptions<WarehouseCatalogData, WarehouseCatalogError, TData>, 'queryKey'> = {}
) =>
useQuery<WarehouseCatalogData, WarehouseCatalogError, TData>({
queryKey: warehouseKeys.catalog(projectRef),
queryFn: ({ signal }) => getWarehouseCatalog({ projectRef }, signal),
enabled: enabled && typeof projectRef !== 'undefined',
...options,
})
@@ -0,0 +1,73 @@
import { useQuery, type UseQueryOptions } from '@tanstack/react-query'
import { useMemo } from 'react'
import { warehouseKeys } from './keys'
import {
POSTGRES_TABLE_STATE,
tableKeyOf,
tableStateFromLinkedTable,
type WarehouseLinkedTable,
type WarehouseTableState,
} from './warehouse-types'
import { fetchGet } from '@/data/fetchers'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { API_URL } from '@/lib/constants'
import { ResponseError } from '@/types'
export type WarehouseTablesVariables = { projectRef?: string }
type WarehouseTablesResponse = { tables: WarehouseLinkedTable[] }
export type WarehouseTablesData = WarehouseLinkedTable[]
export type WarehouseTablesError = ResponseError
export async function getWarehouseTables(
{ projectRef }: WarehouseTablesVariables,
signal?: AbortSignal
): Promise<WarehouseTablesData> {
if (!projectRef) throw new Error('projectRef is required')
const result = await fetchGet<WarehouseTablesResponse>(
`${API_URL}/platform/warehouse/${projectRef}/tables`,
{ abortSignal: signal, credentials: 'include' }
)
if (result instanceof ResponseError) throw result
return result.tables ?? []
}
export const useWarehouseTablesQuery = <TData = WarehouseTablesData>(
{ projectRef }: WarehouseTablesVariables,
{
enabled = true,
...options
}: Omit<UseQueryOptions<WarehouseTablesData, WarehouseTablesError, TData>, 'queryKey'> = {}
) =>
useQuery<WarehouseTablesData, WarehouseTablesError, TData>({
queryKey: warehouseKeys.tables(projectRef),
queryFn: ({ signal }) => getWarehouseTables({ projectRef }, signal),
enabled: enabled && typeof projectRef !== 'undefined',
...options,
})
/** Per-table storage state for the selected project. Falls back to Postgres-only when not linked. */
export function useWarehouseTableState(tableKey: string): WarehouseTableState {
const { data: project } = useSelectedProjectQuery()
const { data } = useWarehouseTablesQuery({ projectRef: project?.ref })
return useMemo(() => {
const match = (data ?? []).find((table) => tableKeyOf(table.schema, table.name) === tableKey)
return match ? tableStateFromLinkedTable(match) : POSTGRES_TABLE_STATE
}, [data, tableKey])
}
/** Lookup of every linked table for the selected project, keyed by `schema.name`. */
export function useWarehouseTableStates(): Map<string, WarehouseTableState> {
const { data: project } = useSelectedProjectQuery()
const { data } = useWarehouseTablesQuery({ projectRef: project?.ref })
return useMemo(() => {
const map = new Map<string, WarehouseTableState>()
for (const table of data ?? []) {
map.set(tableKeyOf(table.schema, table.name), tableStateFromLinkedTable(table))
}
return map
}, [data])
}
@@ -0,0 +1,74 @@
/**
* Studio-facing model for the Warehouse product.
*
* Under the hood a "Warehouse copy" is a table linked into the project's DuckLake replication
* pipeline (`supabase_warehouse_pipeline`) and added to its publication. These types describe what
* the table surfaces need, decoupled from the lower-level replication/destination payloads.
*/
export type WarehouseMode = 'postgres' | 'has_warehouse_copy'
export type WarehouseSyncState = 'syncing' | 'live' | 'error'
/** Row returned by `GET /platform/warehouse/{ref}/tables` (snake_case, mirrors the API). */
export interface WarehouseLinkedTable {
schema: string
name: string
state: WarehouseSyncState
lag_ms?: number
last_synced_at?: string
copy_name: string
warehouse_size_bytes?: number
}
/** UI-facing per-table storage state consumed by the table surfaces. */
export interface WarehouseTableState {
mode: WarehouseMode
syncState?: WarehouseSyncState
lagSeconds?: number
lastSyncedAt?: string
copyName?: string
warehouseSizeBytes?: number
}
/** Default state for a table that has not been linked to Warehouse. */
export const POSTGRES_TABLE_STATE: WarehouseTableState = { mode: 'postgres' }
export const tableKeyOf = (schema: string, name: string) => `${schema}.${name}`
/** Maps an API linked-table row onto the UI-facing state. A listed table always has a copy. */
export function tableStateFromLinkedTable(table: WarehouseLinkedTable): WarehouseTableState {
return {
mode: 'has_warehouse_copy',
syncState: table.state,
lagSeconds: table.lag_ms !== undefined ? Math.round(table.lag_ms / 1000) : undefined,
lastSyncedAt: table.last_synced_at,
copyName: table.copy_name,
warehouseSizeBytes: table.warehouse_size_bytes,
}
}
export function formatWarehouseSize(bytes: number | undefined): string {
if (bytes === undefined) return '—'
const gb = bytes / 1024 ** 3
return `${Math.round(gb)} GB`
}
/**
* Inline storage summary shown next to a table (e.g. "112 kB · Copy · 184 GB"). Returns null when
* the table lives only in the Postgres heap so callers can fall back to the plain Postgres size.
*/
export function getWarehouseStorageSummaryLabel(
state: Pick<WarehouseTableState, 'mode' | 'warehouseSizeBytes'> | undefined,
postgresSize?: string
): string | null {
const mode = state?.mode ?? 'postgres'
if (mode === 'postgres') return null
const hasSize = state?.warehouseSizeBytes !== undefined
const warehouseSize = formatWarehouseSize(state?.warehouseSizeBytes)
if (postgresSize && hasSize) return `${postgresSize} · Copy · ${warehouseSize}`
if (postgresSize) return `${postgresSize} · Copy`
if (hasSize) return `Copy · ${warehouseSize}`
return 'Copy'
}
+1 -1
View File
@@ -1404,7 +1404,7 @@ export interface UnifiedLogsBannerDismissButtonClickedEvent {
export interface IndexAdvisorEnableButtonClickedEvent {
action: 'index_advisor_enable_button_clicked'
properties: {
origin: 'banner' | 'dialog'
origin: 'banner' | 'dialog' | 'table_settings'
}
groups: TelemetryGroups
}