mirror of
https://github.com/supabase/supabase.git
synced 2026-10-08 10:55:06 +03:00
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:
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'
|
||||
|
||||
-128
@@ -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',
|
||||
|
||||
+12
-13
@@ -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}`
|
||||
}
|
||||
+29
-18
@@ -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>
|
||||
|
||||
+28
-13
@@ -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>
|
||||
|
||||
+13
-9
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user