mirror of
https://github.com/supabase/supabase.git
synced 2026-10-08 10:55:06 +03:00
cleanup replication and basic warehouse addition
This commit is contained in:
1 parent
72aa214b0c
commit
d8d312fee9
28 files changed
+1199
-48
No files matched your search
@@ -57,7 +57,7 @@ export type ConditionalValue<T> =
|
||||
// Schema Types - Modes
|
||||
// ============================================================================
|
||||
|
||||
export const CONNECT_MODES = ['framework', 'direct', 'orm', 'mcp', 'server'] as const
|
||||
export const CONNECT_MODES = ['framework', 'direct', 'orm', 'mcp', 'server', 'warehouse'] as const
|
||||
export type ConnectMode = (typeof CONNECT_MODES)[number]
|
||||
|
||||
export interface ModeDefinition {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Box, Cable, Database, Server, Sparkles } from 'lucide-react'
|
||||
import { Box, Cable, Database, Server, Sparkles, Warehouse } from 'lucide-react'
|
||||
import {
|
||||
cn,
|
||||
RadioGroupStacked,
|
||||
@@ -28,6 +28,7 @@ const MODE_ICONS: Record<string, React.ReactNode> = {
|
||||
orm: <Cable size={16} strokeWidth={1.5} />,
|
||||
mcp: <Sparkles size={16} strokeWidth={1.5} />,
|
||||
server: <Server size={16} strokeWidth={1.5} />,
|
||||
warehouse: <Warehouse size={16} strokeWidth={1.5} />,
|
||||
}
|
||||
|
||||
interface ConnectConfigSectionProps {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useAvailableConnectModes } from './useAvailableConnectModes'
|
||||
import { useConnectSheetParams } from './useConnectSheetParams'
|
||||
import { useConnectSheetShortcut } from './useConnectSheetShortcut'
|
||||
import { useConnectState } from './useConnectState'
|
||||
import { WarehouseConnectPanel } from './WarehouseConnectPanel'
|
||||
import { useAPIKeys } from '@/data/api-keys/api-keys-query'
|
||||
import { useProjectApiUrl } from '@/data/config/project-endpoint-query'
|
||||
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
|
||||
@@ -233,7 +234,11 @@ export const ConnectSheet = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConnectStepsSection steps={resolvedSteps} state={state} projectKeys={projectKeys} />
|
||||
{state.mode === 'warehouse' ? (
|
||||
<WarehouseConnectPanel />
|
||||
) : (
|
||||
<ConnectStepsSection steps={resolvedSteps} state={state} projectKeys={projectKeys} />
|
||||
)}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useParams } from 'common'
|
||||
import { useMemo } from 'react'
|
||||
import { CodeBlock } from 'ui-patterns/CodeBlock'
|
||||
|
||||
import { useWarehouseProjectState } from '@/components/interfaces/Database/Warehouse/warehouseDemoStore'
|
||||
import { buildWarehouseConnectionString } from '@/components/interfaces/Database/Warehouse/warehouseReplication.utils'
|
||||
|
||||
export function WarehouseConnectPanel() {
|
||||
const { ref: projectRef } = useParams()
|
||||
const warehouseState = useWarehouseProjectState(projectRef)
|
||||
|
||||
const connectionString = useMemo(
|
||||
() =>
|
||||
buildWarehouseConnectionString({
|
||||
projectRef: projectRef ?? '',
|
||||
databaseName: 'postgres',
|
||||
}),
|
||||
[projectRef]
|
||||
)
|
||||
|
||||
if (!warehouseState.enabled) {
|
||||
return (
|
||||
<div className="p-8">
|
||||
<p className="text-sm text-foreground-light">
|
||||
Enable Warehouse from Database → Replication to view connection credentials.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col divide-y">
|
||||
<div className="p-8 flex flex-col gap-y-4">
|
||||
<div>
|
||||
<p className="text-sm text-foreground">Warehouse endpoint</p>
|
||||
<p className="text-sm text-foreground-light mt-1">
|
||||
Connect directly to your Warehouse replica. Schema and table names match Postgres (for
|
||||
example, <code>public.events</code> on both endpoints).
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-foreground mb-2">Host</p>
|
||||
<CodeBlock language="bash" hideLineNumbers>
|
||||
{warehouseState.warehouseHost}
|
||||
</CodeBlock>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-foreground mb-2">Connection string</p>
|
||||
<CodeBlock language="bash" hideLineNumbers>
|
||||
{connectionString}
|
||||
</CodeBlock>
|
||||
</div>
|
||||
<p className="text-xs text-foreground-lighter">
|
||||
Replicated schemas: {warehouseState.includedSchemas.join(', ') || 'none'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -269,6 +269,12 @@ export const connectSchema: ConnectSchema = {
|
||||
description: 'Connect your agent',
|
||||
fields: ['mcpClient', 'mcpReadonly', 'mcpFeatures'],
|
||||
},
|
||||
{
|
||||
id: 'warehouse',
|
||||
label: 'Warehouse',
|
||||
description: 'Warehouse endpoint',
|
||||
fields: [],
|
||||
},
|
||||
],
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { useParams } from 'common'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import type { ConnectMode } from './Connect.types'
|
||||
import { useWarehouseProjectState } from '@/components/interfaces/Database/Warehouse/warehouseDemoStore'
|
||||
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
|
||||
import { useIsWarehouseEnabled } from '@/hooks/misc/useIsWarehouseEnabled'
|
||||
|
||||
export function useAvailableConnectModes(): ConnectMode[] {
|
||||
const { ref: projectRef } = useParams()
|
||||
const isWarehouseFeatureEnabled = useIsWarehouseEnabled()
|
||||
const warehouseState = useWarehouseProjectState(projectRef)
|
||||
const warehouseProjectEnabled = warehouseState.enabled
|
||||
const {
|
||||
projectConnectionShowAppFrameworks: showAppFrameworks,
|
||||
projectConnectionShowMobileFrameworks: showMobileFrameworks,
|
||||
@@ -21,7 +28,14 @@ export function useAvailableConnectModes(): ConnectMode[] {
|
||||
{ id: 'direct', enabled: true },
|
||||
{ id: 'orm', enabled: showOrms },
|
||||
{ id: 'mcp', enabled: true },
|
||||
{ id: 'warehouse', enabled: isWarehouseFeatureEnabled && warehouseProjectEnabled },
|
||||
]
|
||||
return allModes.filter((m) => m.enabled).map((m) => m.id)
|
||||
}, [showAppFrameworks, showMobileFrameworks, showOrms])
|
||||
}, [
|
||||
showAppFrameworks,
|
||||
showMobileFrameworks,
|
||||
showOrms,
|
||||
isWarehouseFeatureEnabled,
|
||||
warehouseProjectEnabled,
|
||||
])
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useParams } from 'common'
|
||||
import { Database } from 'icons'
|
||||
import { MoreVertical, Plus, Search, X } from 'lucide-react'
|
||||
import { parseAsStringEnum, useQueryState } from 'nuqs'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
@@ -7,7 +8,6 @@ import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
cn,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from 'ui'
|
||||
import { Admonition } from 'ui-patterns/admonition'
|
||||
import { Input } from 'ui-patterns/DataInputs/Input'
|
||||
import { EmptyStatePresentational } from 'ui-patterns/EmptyStatePresentational'
|
||||
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
import { REPLICA_STATUS } from '../../Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.constants'
|
||||
@@ -35,6 +36,8 @@ import {
|
||||
useIsETLIcebergPrivateAlpha,
|
||||
useIsETLSnowflakePrivateAlpha,
|
||||
} from './useIsETLPrivateAlpha'
|
||||
import { useWarehouseProjectState } from '@/components/interfaces/Database/Warehouse/warehouseDemoStore'
|
||||
import { WarehouseDestinationRow } from '@/components/interfaces/Database/Warehouse/WarehouseDestinationRow'
|
||||
import { AlertError } from '@/components/ui/AlertError'
|
||||
import { DocsButton } from '@/components/ui/DocsButton'
|
||||
import { Shortcut } from '@/components/ui/Shortcut'
|
||||
@@ -46,6 +49,7 @@ import { useReplicationPipelinesQuery } from '@/data/replication/pipelines-query
|
||||
import { useReplicationSourcesQuery } from '@/data/replication/sources-query'
|
||||
import { checkLocalETLNotSetUp } from '@/data/replication/utils'
|
||||
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
|
||||
import { useIsWarehouseEnabled } from '@/hooks/misc/useIsWarehouseEnabled'
|
||||
import { DOCS_URL } from '@/lib/constants'
|
||||
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
|
||||
import { useShortcut } from '@/state/shortcuts/useShortcut'
|
||||
@@ -58,6 +62,9 @@ export const Destinations = () => {
|
||||
const etlEnableIceberg = useIsETLIcebergPrivateAlpha()
|
||||
const etlEnableDucklake = useIsETLDucklakePrivateAlpha()
|
||||
const etlEnableSnowflake = useIsETLSnowflakePrivateAlpha()
|
||||
const isWarehouseFeatureEnabled = useIsWarehouseEnabled()
|
||||
const warehouseState = useWarehouseProjectState(projectRef)
|
||||
const hasManagedWarehouse = isWarehouseFeatureEnabled && warehouseState.enabled
|
||||
const { infrastructureReadReplicas } = useIsFeatureEnabled(['infrastructure:read_replicas'])
|
||||
|
||||
const newDestinationDefaultType = infrastructureReadReplicas
|
||||
@@ -285,14 +292,15 @@ export const Destinations = () => {
|
||||
|
||||
{isLocalETLNotSetUp && (
|
||||
<Admonition
|
||||
type="default"
|
||||
title="ETL API not set up locally — destinations cannot be managed"
|
||||
type="warning"
|
||||
title="Replication unavailable locally"
|
||||
description="Configure the replication API to manage destinations in local development."
|
||||
/>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<GenericSkeletonLoader />
|
||||
) : hasReplicas || hasDestinations ? (
|
||||
) : hasReplicas || hasDestinations || hasManagedWarehouse ? (
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
@@ -313,6 +321,8 @@ export const Destinations = () => {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{hasManagedWarehouse && <WarehouseDestinationRow />}
|
||||
|
||||
{filteredReplicas.map((replica) => {
|
||||
return (
|
||||
<ReadReplicaRow
|
||||
@@ -330,6 +340,7 @@ export const Destinations = () => {
|
||||
{!isLoading &&
|
||||
filteredDestinations.length === 0 &&
|
||||
filteredReplicas.length === 0 &&
|
||||
!hasManagedWarehouse &&
|
||||
(hasReplicas || hasDestinations) && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6}>
|
||||
@@ -347,27 +358,21 @@ export const Destinations = () => {
|
||||
) : (
|
||||
!isLoading &&
|
||||
!hasErrorsFetchingData && (
|
||||
<div
|
||||
className={cn(
|
||||
'w-full',
|
||||
'border border-dashed bg-surface-100 border-overlay',
|
||||
'flex flex-col px-16 rounded-lg justify-center items-center py-8 mt-4'
|
||||
)}
|
||||
<EmptyStatePresentational
|
||||
icon={Database}
|
||||
title="Add a replication destination"
|
||||
description="Deploy a Read Replica to reduce latency and isolate workloads, or connect a Pipelines destination for analytics."
|
||||
className="mt-4"
|
||||
>
|
||||
<h4>Replication keeps your data in sync across systems</h4>
|
||||
<p className="text-foreground-light text-sm text-balance text-center mt-1">
|
||||
Deploy Read Replicas for lower latency and workload isolation, or add a Pipelines
|
||||
destination for analytics workloads.
|
||||
</p>
|
||||
<Button
|
||||
variant="default"
|
||||
icon={<Plus />}
|
||||
disabled={!newDestinationDefaultType}
|
||||
onClick={openDestinationPanel}
|
||||
className="mt-4"
|
||||
>
|
||||
Add destination
|
||||
</Button>
|
||||
</div>
|
||||
</EmptyStatePresentational>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -37,6 +37,10 @@ import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
import { ProtectedSchemaWarning } from '../ProtectedSchemaWarning'
|
||||
import { formatAllEntities } from './Tables.utils'
|
||||
import { buildTableEditorUrl } from '@/components/grid/SupabaseGrid.utils'
|
||||
import {
|
||||
isTableReplicated,
|
||||
useWarehouseProjectState,
|
||||
} from '@/components/interfaces/Database/Warehouse/warehouseDemoStore'
|
||||
import { AlertError } from '@/components/ui/AlertError'
|
||||
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
|
||||
import { DropdownMenuItemTooltip } from '@/components/ui/DropdownMenuItemTooltip'
|
||||
@@ -51,6 +55,7 @@ import { usePrefetchEditorTablePage } from '@/data/prefetchers/project.$ref.edit
|
||||
import { useInfiniteTablesQuery } from '@/data/tables/tables-query'
|
||||
import { useViewsQuery } from '@/data/views/views-query'
|
||||
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
|
||||
import { useIsWarehouseEnabled } from '@/hooks/misc/useIsWarehouseEnabled'
|
||||
import { useQuerySchemaState } from '@/hooks/misc/useSchemaQueryState'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
import { useIsProtectedSchema } from '@/hooks/useProtectedSchemas'
|
||||
@@ -75,6 +80,9 @@ export const TableList = ({
|
||||
const router = useRouter()
|
||||
const { ref } = useParams()
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const isWarehouseFeatureEnabled = useIsWarehouseEnabled()
|
||||
const warehouseState = useWarehouseProjectState(ref)
|
||||
const showReplicatedColumn = isWarehouseFeatureEnabled && warehouseState.enabled
|
||||
|
||||
const prefetchEditorTablePage = usePrefetchEditorTablePage()
|
||||
|
||||
@@ -377,6 +385,7 @@ export const TableList = ({
|
||||
<TableHead key="columns">Columns</TableHead>
|
||||
<TableHead key="rows">Rows (Estimated)</TableHead>
|
||||
<TableHead key="size">Size (Estimated)</TableHead>
|
||||
{showReplicatedColumn && <TableHead key="replicated">Replicated</TableHead>}
|
||||
<TableHead key="realtime">Realtime</TableHead>
|
||||
<TableHead key="buttons"></TableHead>
|
||||
</TableRow>
|
||||
@@ -385,7 +394,7 @@ export const TableList = ({
|
||||
<>
|
||||
{entities.length === 0 && filterString.length === 0 && (
|
||||
<TableRow key={selectedSchema}>
|
||||
<TableCell colSpan={7}>
|
||||
<TableCell colSpan={showReplicatedColumn ? 8 : 7}>
|
||||
{visibleTypes.length === 0 ? (
|
||||
<>
|
||||
<p className="text-sm text-foreground">
|
||||
@@ -420,7 +429,7 @@ export const TableList = ({
|
||||
)}
|
||||
{entities.length === 0 && filterString.length > 0 && (
|
||||
<TableRow key={selectedSchema}>
|
||||
<TableCell colSpan={7}>
|
||||
<TableCell colSpan={showReplicatedColumn ? 8 : 7}>
|
||||
<p className="text-sm text-foreground">No results found</p>
|
||||
<p className="text-sm text-foreground-light">
|
||||
Your search for "{filterString}" did not return any results
|
||||
@@ -489,6 +498,21 @@ export const TableList = ({
|
||||
<p className="text-foreground-muted">–</p>
|
||||
)}
|
||||
</TableCell>
|
||||
{showReplicatedColumn && (
|
||||
<TableCell>
|
||||
{isTableReplicated(ref, x.schema, x.name) ? (
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Check size={16} strokeWidth={2} className="text-brand-link" />
|
||||
<p className="text-foreground-light">Yes</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-x-2">
|
||||
<X size={16} strokeWidth={2} className="text-foreground-muted" />
|
||||
<p className="text-foreground-lighter">No</p>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell>
|
||||
{(realtimePublication?.tables ?? []).find(
|
||||
(table) => table.id === x.id
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
# Warehouse API contract (planned)
|
||||
|
||||
> **Prototype note:** Studio wholesale branch uses `warehouseDemoStore` only. Endpoints below are the target platform contract; not implemented in this branch.
|
||||
|
||||
## Model
|
||||
|
||||
- **Project-wide** enable/disable (not per-table hero API)
|
||||
- Separate **`warehouse.*` host** with **1:1 schema.table names**
|
||||
- Managed publication: `supabase_warehouse`
|
||||
- Disable **deletes** all Warehouse replicas; re-enable triggers full backfill
|
||||
|
||||
## Endpoints (planned)
|
||||
|
||||
### `POST /platform/projects/{ref}/warehouse/enable`
|
||||
|
||||
Enable Warehouse for the project.
|
||||
|
||||
**Request body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"included_schemas": ["public", "storage"]
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"enabled": true,
|
||||
"replication_phase": "provisioning",
|
||||
"pipeline_id": "7022",
|
||||
"warehouse_host": "warehouse.<ref>.supabase.co",
|
||||
"publication_name": "supabase_warehouse",
|
||||
"included_schemas": ["public", "storage"]
|
||||
}
|
||||
```
|
||||
|
||||
### `PATCH /platform/projects/{ref}/warehouse/schemas`
|
||||
|
||||
Update which schemas are replicated.
|
||||
|
||||
**Request body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"included_schemas": ["public"]
|
||||
}
|
||||
```
|
||||
|
||||
Adding schemas triggers backfill for new tables. Removing schemas drops Warehouse replicas for those schemas.
|
||||
|
||||
### `DELETE /platform/projects/{ref}/warehouse`
|
||||
|
||||
Disable Warehouse and delete all replicas.
|
||||
|
||||
**Response:** `204 No Content`
|
||||
|
||||
### `GET /platform/projects/{ref}/warehouse`
|
||||
|
||||
Project Warehouse status (phase, lag, included schemas, pipeline metadata).
|
||||
|
||||
## Table replicated check (Studio)
|
||||
|
||||
A table is **replicated** when:
|
||||
|
||||
1. Project Warehouse is enabled
|
||||
2. Table's schema is in `included_schemas`
|
||||
3. Table is not on the system denylist (e.g. `supabase_migrations`)
|
||||
|
||||
Studio table list **Replicated** column is read-only metadata derived from this.
|
||||
|
||||
## SQL query target (Studio)
|
||||
|
||||
Studio SQL Editor target toggle (`postgres` | `warehouse`) is a **query routing hint** for the execution layer. Wholesale prototype does not route queries; it shows `Served by: Warehouse` in the results footer when Warehouse is selected.
|
||||
|
||||
## Config (out of Studio scope)
|
||||
|
||||
Notion simplification referenced `[warehouse] enabled = true` in `config.toml`. Studio wholesale MVP does not edit config; enable/disable is project-scoped in the Replication UI.
|
||||
@@ -0,0 +1,75 @@
|
||||
# Warehouse product naming
|
||||
|
||||
Studio copy for Warehouse uses **replicate** as the shared verb and **qualified nouns** for the destination. The goal is one replication mental model across destinations (Warehouse, read replicas, external pipelines) without teaching another product concept.
|
||||
|
||||
## Principles
|
||||
|
||||
1. **Verb stays lean** — `replicate`, `replicating`, `stop replicating`
|
||||
2. **Noun carries the destination** — always qualify: **Warehouse replica**, **read replica**
|
||||
3. **Never bare "replica"** in user-facing UI without a destination prefix
|
||||
4. **Avoid loaded terms** — do not use `link`, `linked`, `copy`, or `mirror` in product copy (platform API types may still use them)
|
||||
|
||||
## Endpoint model (wholesale MVP)
|
||||
|
||||
Warehouse uses a **separate host** with **identical schema and table names**:
|
||||
|
||||
| Postgres (transactional) | Warehouse (analytical) |
|
||||
| ---------------------------- | ----------------------------------- |
|
||||
| `public.events` | `public.events` |
|
||||
| Host: `db.<ref>.supabase.co` | Host: `warehouse.<ref>.supabase.co` |
|
||||
|
||||
Do **not** use `public_warehouse.events` or other renamed schemas in user-facing copy. That was a pre-pivot exploration.
|
||||
|
||||
## User-facing vocabulary
|
||||
|
||||
| Concept | Use | Avoid |
|
||||
| -------------------- | --------------------------------------------------------- | ------------------------------------- |
|
||||
| Project action | **Enable Warehouse** | Turn on Warehouse, Link project |
|
||||
| In progress | **Replicating** / **Backfilling** | Syncing, Linking |
|
||||
| Steady state | **Streaming** / **Active** | Replica live (unless emphasis needed) |
|
||||
| The analytical table | **Warehouse replica** (`public.events` on Warehouse host) | Linked table, Warehouse copy |
|
||||
| Ongoing process | **Warehouse replication** | Warehouse link, sync |
|
||||
| Remove | **Disable Warehouse** | Unlink, Detach, Delete copy |
|
||||
| Table list column | **Replicated** (generic; not Warehouse-branded) | Warehouse column, Linked |
|
||||
| Postgres product | **Read replica** | Replica (alone) |
|
||||
|
||||
## Status labels
|
||||
|
||||
| Internal phase | UI label (Destinations) | UI label (card chip) |
|
||||
| -------------- | ----------------------- | -------------------- |
|
||||
| `provisioning` | Starting | Starting |
|
||||
| `backfilling` | Backfilling | Backfilling |
|
||||
| `streaming` | Active | Active |
|
||||
| `failed` | Failed | Failed |
|
||||
|
||||
## Example copy
|
||||
|
||||
| Surface | Copy |
|
||||
| ---------------------- | --------------------------------------------------- |
|
||||
| Enable CTA | Enable Warehouse |
|
||||
| Enable dialog title | Enable Warehouse |
|
||||
| Disable confirm title | Disable Warehouse? |
|
||||
| Disable confirm body | This deletes all Warehouse replicas |
|
||||
| Replication card (off) | Analytical replica on a separate Warehouse endpoint |
|
||||
| SQL Editor toggle | Postgres \| Warehouse |
|
||||
| SQL Editor footer | Served by: Warehouse |
|
||||
| Connect tab | Warehouse |
|
||||
|
||||
## Internal code (not user-facing)
|
||||
|
||||
| Internal | Meaning |
|
||||
| -------------------- | ------------------------------- |
|
||||
| `includedSchemas` | Schemas replicated to Warehouse |
|
||||
| `replicationPhase` | Project pipeline phase |
|
||||
| `publicationName` | `supabase_warehouse` (managed) |
|
||||
| `warehouseDemoStore` | Prototype mock state |
|
||||
|
||||
See `WAREHOUSE_API_CONTRACT.md` for planned platform endpoints.
|
||||
|
||||
## Terms we rejected
|
||||
|
||||
| Term | Why |
|
||||
| --------------------------- | --------------------------------------------------- |
|
||||
| **Link / linked** | Reads like foreign keys or ETL pipeline config |
|
||||
| **Copy** | Implies a one-off snapshot, not ongoing replication |
|
||||
| **public_warehouse schema** | Pre-pivot; replaced by 1:1 endpoint mapping |
|
||||
@@ -0,0 +1,49 @@
|
||||
# Warehouse Wholesale MVP
|
||||
|
||||
Branch: `dnywh/prototype/warehouse-wholesale`
|
||||
|
||||
## Intent
|
||||
|
||||
Project-wide Warehouse prototype for Studio. Postgres remains the transactional database; Warehouse is an analytical replica on a **separate `warehouse.*` host** with **1:1 schema and table names** (`public.events` on both).
|
||||
|
||||
All platform behavior is mocked via `warehouseDemoStore` (localStorage). No real API endpoints.
|
||||
|
||||
## In scope
|
||||
|
||||
- **Replication page** — `WarehouseProjectCard` (enable/disable, schema scope, observability)
|
||||
- **Destinations table** — read-only managed row (`DuckLake` / `supabase_warehouse`)
|
||||
- **Table list** — read-only **Replicated** column
|
||||
- **SQL Editor** — Postgres / Warehouse target toggle + mock "Served by: Warehouse" footer
|
||||
- **Connect sheet** — Warehouse tab with connection credentials when enabled
|
||||
- **Feature flag** — `useIsWarehouseEnabled` (ConfigCat `warehouse` org slug allowlist)
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Per-table enablement hero UX
|
||||
- `?view=warehouse`, table-detail storage tab
|
||||
- `public_warehouse.*` FDW naming (pre-pivot model)
|
||||
- Catalog integration install gate
|
||||
- Real query routing to Warehouse
|
||||
- config.toml editing in Studio
|
||||
|
||||
## Demo store
|
||||
|
||||
State key: `supabase-warehouse-wholesale-demo` in localStorage, keyed by project ref.
|
||||
|
||||
```ts
|
||||
// apps/studio/components/interfaces/Database/Warehouse/warehouseDemoStore.ts
|
||||
enableWarehouseProject(projectRef, includedSchemas)
|
||||
disableWarehouseProject(projectRef)
|
||||
updateWarehouseIncludedSchemas(projectRef, schemas)
|
||||
isTableReplicated(projectRef, schema, table)
|
||||
```
|
||||
|
||||
Enable simulates `provisioning` → `backfilling` → `streaming` phase progression.
|
||||
|
||||
## QA checklist
|
||||
|
||||
1. Flag off → no Warehouse UI
|
||||
2. Flag on, disabled → Replication card with Enable CTA only
|
||||
3. Enable → observability metrics, schema checkboxes, Destinations row, Replicated column, SQL toggle, Connect Warehouse tab
|
||||
4. Uncheck schema → tables in that schema show Replicated = No
|
||||
5. Disable → confirm destructive modal, all Warehouse UI hidden
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useParams } from 'common'
|
||||
import { Database, Minus } from 'lucide-react'
|
||||
import { TableCell, TableRow, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
|
||||
|
||||
import { MANAGED_WAREHOUSE_PUBLICATION_TOOLTIP } from './managedWarehouse.resources'
|
||||
import { useWarehouseProjectState } from './warehouseDemoStore'
|
||||
import {
|
||||
formatReplicationLagSeconds,
|
||||
getWarehouseDestinationStatusLabel,
|
||||
} from './warehouseReplication.utils'
|
||||
import { WarehouseSyncChip } from './WarehouseSyncChip'
|
||||
|
||||
export function WarehouseDestinationRow() {
|
||||
const { ref: projectRef } = useParams()
|
||||
const state = useWarehouseProjectState(projectRef)
|
||||
|
||||
if (!state.enabled || !state.pipelineId) return null
|
||||
|
||||
const statusLabel = getWarehouseDestinationStatusLabel(state.replicationPhase)
|
||||
const lag = formatReplicationLagSeconds(state.lagSeconds)
|
||||
const isCaughtUp = state.lagSeconds === 0 || state.lagSeconds === 2
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Database size={18} className="text-foreground-light" />
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[180px]">
|
||||
<div>
|
||||
<p>DuckLake (Pipeline ID: {state.pipelineId})</p>
|
||||
<p className="text-foreground-lighter">{state.destinationName}</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<WarehouseSyncChip phase={state.replicationPhase} />
|
||||
{statusLabel === 'Failed' && (
|
||||
<p className="text-xs text-foreground-lighter mt-1">Managed Warehouse pipeline</p>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{state.replicationPhase === 'streaming' || state.replicationPhase === 'backfilling' ? (
|
||||
isCaughtUp ? (
|
||||
<p className="text-foreground-light">Caught up</p>
|
||||
) : (
|
||||
<p className="text-foreground-light">{lag}</p>
|
||||
)
|
||||
) : (
|
||||
<Minus size={18} className="text-foreground-lighter" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-foreground-light">{state.publicationName}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-xs">
|
||||
{MANAGED_WAREHOUSE_PUBLICATION_TOOLTIP}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
<TableCell />
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useParams } from 'common'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogSection,
|
||||
DialogSectionSeparator,
|
||||
DialogTitle,
|
||||
} from 'ui'
|
||||
import { Admonition } from 'ui-patterns/admonition'
|
||||
|
||||
import { disableWarehouseProject } from './warehouseDemoStore'
|
||||
|
||||
interface WarehouseDisableModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function WarehouseDisableModal({ open, onOpenChange }: WarehouseDisableModalProps) {
|
||||
const { ref: projectRef } = useParams()
|
||||
const [isDisabling, setIsDisabling] = useState(false)
|
||||
|
||||
const onDisable = async () => {
|
||||
if (!projectRef) return
|
||||
setIsDisabling(true)
|
||||
disableWarehouseProject(projectRef)
|
||||
setIsDisabling(false)
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent size="medium">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Disable Warehouse?</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogSection>
|
||||
<p className="text-sm text-foreground-light mb-4">
|
||||
This deletes all Warehouse replicas for this project.
|
||||
</p>
|
||||
<Admonition
|
||||
type="destructive"
|
||||
title="Warehouse data will be deleted"
|
||||
description="Disabling Warehouse removes all replicated tables from the Warehouse endpoint. Re-enabling requires a full backfill."
|
||||
/>
|
||||
</DialogSection>
|
||||
<DialogSectionSeparator />
|
||||
<DialogFooter>
|
||||
<Button variant="default" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="danger" loading={isDisabling} onClick={onDisable}>
|
||||
Disable Warehouse
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useParams } from 'common'
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogSection,
|
||||
DialogSectionSeparator,
|
||||
DialogTitle,
|
||||
Label,
|
||||
} from 'ui'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
|
||||
import { enableWarehouseProject } from './warehouseDemoStore'
|
||||
import { useDefaultWarehouseSchemas } from './WarehouseSchemaScope'
|
||||
|
||||
interface WarehouseEnableModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function WarehouseEnableModal({ open, onOpenChange }: WarehouseEnableModalProps) {
|
||||
const { ref: projectRef } = useParams()
|
||||
const defaultSchemas = useDefaultWarehouseSchemas()
|
||||
const [selectedSchemas, setSelectedSchemas] = useState<string[]>(defaultSchemas)
|
||||
const [isEnabling, setIsEnabling] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setSelectedSchemas(defaultSchemas)
|
||||
}, [open, defaultSchemas])
|
||||
|
||||
const selectedSet = new Set(selectedSchemas)
|
||||
|
||||
const onToggle = (schema: string, checked: boolean) => {
|
||||
setSelectedSchemas((prev) => (checked ? [...prev, schema] : prev.filter((s) => s !== schema)))
|
||||
}
|
||||
|
||||
const onEnable = async () => {
|
||||
if (!projectRef || selectedSchemas.length === 0) return
|
||||
setIsEnabling(true)
|
||||
enableWarehouseProject(projectRef, selectedSchemas)
|
||||
setIsEnabling(false)
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent size="medium">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Enable Warehouse</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogSection>
|
||||
<p className="text-sm text-foreground-light">
|
||||
Create an analytical replica of your Postgres data on a separate Warehouse endpoint.
|
||||
Tables use the same schema and table names (for example, <code>public.events</code>).
|
||||
</p>
|
||||
<p className="text-sm text-foreground-light mt-3">
|
||||
Select which schemas to replicate. You can change this later from the Warehouse card.
|
||||
</p>
|
||||
<div className="flex flex-col gap-y-2 max-h-64 overflow-y-auto mt-4">
|
||||
{defaultSchemas.map((schema) => (
|
||||
<FormItemLayout
|
||||
key={schema}
|
||||
isReactForm={false}
|
||||
layout="flex"
|
||||
label={schema}
|
||||
className="items-center"
|
||||
>
|
||||
<Checkbox
|
||||
id={`enable-warehouse-schema-${schema}`}
|
||||
checked={selectedSet.has(schema)}
|
||||
onCheckedChange={(checked) => onToggle(schema, checked === true)}
|
||||
/>
|
||||
<Label htmlFor={`enable-warehouse-schema-${schema}`} className="sr-only">
|
||||
Include schema {schema}
|
||||
</Label>
|
||||
</FormItemLayout>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => setSelectedSchemas(defaultSchemas)}
|
||||
className="mt-3"
|
||||
>
|
||||
Select all
|
||||
</Button>
|
||||
</DialogSection>
|
||||
<DialogSectionSeparator />
|
||||
<DialogFooter>
|
||||
<Button variant="default" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button loading={isEnabling} disabled={selectedSchemas.length === 0} onClick={onEnable}>
|
||||
Enable Warehouse
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useParams } from 'common'
|
||||
import { Card, CardContent } from 'ui'
|
||||
import { ChartMetric } from 'ui-patterns/Chart'
|
||||
|
||||
import { useWarehouseProjectState } from './warehouseDemoStore'
|
||||
import { formatReplicationLagSeconds, formatReplicationPhase } from './warehouseReplication.utils'
|
||||
import { WarehouseSyncChip } from './WarehouseSyncChip'
|
||||
|
||||
export function WarehouseObservabilityPanel() {
|
||||
const { ref: projectRef } = useParams()
|
||||
const state = useWarehouseProjectState(projectRef)
|
||||
|
||||
if (!state.enabled) return null
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 divide-y sm:divide-y-0 sm:divide-x">
|
||||
<div className="px-4 py-3">
|
||||
<ChartMetric
|
||||
label="Replication phase"
|
||||
value={formatReplicationPhase(state.replicationPhase)}
|
||||
tooltip="Project-wide Warehouse pipeline phase."
|
||||
className="[&_span]:text-sm"
|
||||
/>
|
||||
<div className="mt-2">
|
||||
<WarehouseSyncChip phase={state.replicationPhase} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-4 py-3">
|
||||
<ChartMetric
|
||||
label="Replication lag"
|
||||
value={formatReplicationLagSeconds(state.lagSeconds)}
|
||||
tooltip="Approximate lag between Postgres and Warehouse."
|
||||
className="[&_span]:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="px-4 py-3">
|
||||
<ChartMetric
|
||||
label="Replicated schemas"
|
||||
value={state.includedSchemas.length.toLocaleString()}
|
||||
tooltip="Schemas currently replicated to Warehouse."
|
||||
className="[&_span]:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useParams } from 'common'
|
||||
import { parseAsBoolean, useQueryState } from 'nuqs'
|
||||
import { useState } from 'react'
|
||||
import { Button, Card, CardContent } from 'ui'
|
||||
|
||||
import { useWarehouseProjectState } from './warehouseDemoStore'
|
||||
import { WarehouseDisableModal } from './WarehouseDisableModal'
|
||||
import { WarehouseEnableModal } from './WarehouseEnableModal'
|
||||
import { WarehouseObservabilityPanel } from './WarehouseObservabilityPanel'
|
||||
import { WarehouseSchemaScope } from './WarehouseSchemaScope'
|
||||
import { WarehouseSyncChip } from './WarehouseSyncChip'
|
||||
import { useIsWarehouseEnabled } from '@/hooks/misc/useIsWarehouseEnabled'
|
||||
|
||||
export function WarehouseProjectCard() {
|
||||
const { ref: projectRef } = useParams()
|
||||
const isWarehouseFeatureEnabled = useIsWarehouseEnabled()
|
||||
const warehouseState = useWarehouseProjectState(projectRef)
|
||||
|
||||
const [showEnableModal, setShowEnableModal] = useState(false)
|
||||
const [showDisableModal, setShowDisableModal] = useState(false)
|
||||
const [, setShowConnect] = useQueryState('showConnect', parseAsBoolean.withDefault(false))
|
||||
const [, setConnectTab] = useQueryState('connectTab')
|
||||
|
||||
if (!isWarehouseFeatureEnabled) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="mb-6">
|
||||
<CardContent className="p-6 flex flex-col gap-y-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
|
||||
<div className="flex flex-col gap-y-1">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<h4 className="text-foreground text-base">Warehouse</h4>
|
||||
{warehouseState.enabled && (
|
||||
<WarehouseSyncChip phase={warehouseState.replicationPhase} />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-foreground-light max-w-2xl">
|
||||
{warehouseState.enabled
|
||||
? 'Analytical replica of your Postgres data on a separate Warehouse endpoint. Query the same schema and table names against the Warehouse host.'
|
||||
: 'Enable an analytical replica of your Postgres data on a separate Warehouse endpoint for analytics workloads.'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-2 shrink-0">
|
||||
{warehouseState.enabled ? (
|
||||
<>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
setConnectTab('warehouse')
|
||||
setShowConnect(true)
|
||||
}}
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
<Button variant="danger" onClick={() => setShowDisableModal(true)}>
|
||||
Disable Warehouse
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button onClick={() => setShowEnableModal(true)}>Enable Warehouse</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{warehouseState.enabled && (
|
||||
<>
|
||||
<WarehouseObservabilityPanel />
|
||||
<WarehouseSchemaScope
|
||||
disabled={
|
||||
warehouseState.replicationPhase === 'provisioning' ||
|
||||
warehouseState.replicationPhase === 'backfilling'
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<WarehouseEnableModal open={showEnableModal} onOpenChange={setShowEnableModal} />
|
||||
<WarehouseDisableModal open={showDisableModal} onOpenChange={setShowDisableModal} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useParams } from 'common'
|
||||
import { useMemo } from 'react'
|
||||
import { Checkbox, Label } from 'ui'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
|
||||
import {
|
||||
getDefaultIncludedSchemas,
|
||||
isReplicableSchema,
|
||||
updateWarehouseIncludedSchemas,
|
||||
useWarehouseProjectState,
|
||||
} from './warehouseDemoStore'
|
||||
import { useSchemasQuery } from '@/data/database/schemas-query'
|
||||
|
||||
interface WarehouseSchemaScopeProps {
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function WarehouseSchemaScope({ disabled = false }: WarehouseSchemaScopeProps) {
|
||||
const { ref: projectRef } = useParams()
|
||||
const { data: schemas = [] } = useSchemasQuery({ projectRef })
|
||||
const warehouseState = useWarehouseProjectState(projectRef)
|
||||
|
||||
const replicableSchemas = useMemo(
|
||||
() =>
|
||||
schemas
|
||||
.map((s) => s.name)
|
||||
.filter(isReplicableSchema)
|
||||
.sort(),
|
||||
[schemas]
|
||||
)
|
||||
|
||||
const includedSet = new Set(warehouseState.includedSchemas)
|
||||
|
||||
const onToggle = (schema: string, checked: boolean) => {
|
||||
if (!projectRef) return
|
||||
const next = checked
|
||||
? [...warehouseState.includedSchemas, schema]
|
||||
: warehouseState.includedSchemas.filter((s) => s !== schema)
|
||||
updateWarehouseIncludedSchemas(projectRef, next)
|
||||
}
|
||||
|
||||
if (!warehouseState.enabled) return null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-3">
|
||||
<div>
|
||||
<p className="text-sm text-foreground">Replicated schemas</p>
|
||||
<p className="text-sm text-foreground-light">
|
||||
Tables in selected schemas are replicated to Warehouse with the same schema and table
|
||||
names.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-y-2">
|
||||
{replicableSchemas.map((schema) => (
|
||||
<FormItemLayout
|
||||
key={schema}
|
||||
isReactForm={false}
|
||||
layout="flex"
|
||||
label={schema}
|
||||
className="items-center"
|
||||
>
|
||||
<Checkbox
|
||||
id={`warehouse-schema-${schema}`}
|
||||
checked={includedSet.has(schema)}
|
||||
disabled={disabled}
|
||||
onCheckedChange={(checked) => onToggle(schema, checked === true)}
|
||||
/>
|
||||
<Label htmlFor={`warehouse-schema-${schema}`} className="sr-only">
|
||||
Replicate schema {schema}
|
||||
</Label>
|
||||
</FormItemLayout>
|
||||
))}
|
||||
{replicableSchemas.length === 0 && (
|
||||
<p className="text-sm text-foreground-light">No replicable schemas found.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function useDefaultWarehouseSchemas(): string[] {
|
||||
const { ref: projectRef } = useParams()
|
||||
const { data: schemas = [] } = useSchemasQuery({ projectRef })
|
||||
return useMemo(() => getDefaultIncludedSchemas(schemas.map((s) => s.name)), [schemas])
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Badge } from 'ui'
|
||||
|
||||
import type { ReplicationPhase } from './warehouseDemoStore'
|
||||
import { getWarehouseDestinationStatusLabel } from './warehouseReplication.utils'
|
||||
|
||||
interface WarehouseSyncChipProps {
|
||||
phase: ReplicationPhase
|
||||
}
|
||||
|
||||
export function WarehouseSyncChip({ phase }: WarehouseSyncChipProps) {
|
||||
const label = getWarehouseDestinationStatusLabel(phase)
|
||||
|
||||
const variant =
|
||||
phase === 'failed'
|
||||
? 'destructive'
|
||||
: phase === 'backfilling' || phase === 'provisioning'
|
||||
? 'warning'
|
||||
: phase === 'streaming'
|
||||
? 'success'
|
||||
: 'default'
|
||||
|
||||
return <Badge variant={variant}>{label}</Badge>
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export const SUPABASE_MANAGED_WAREHOUSE_RESOURCE_NAME = 'supabase_warehouse'
|
||||
|
||||
export function isSupabaseManagedWarehousePublicationName(name: string): boolean {
|
||||
return name === SUPABASE_MANAGED_WAREHOUSE_RESOURCE_NAME
|
||||
}
|
||||
|
||||
export const MANAGED_WAREHOUSE_PUBLICATION_TOOLTIP =
|
||||
'Managed by Supabase for Warehouse replication. Configure schemas on the Warehouse card above.'
|
||||
@@ -0,0 +1,185 @@
|
||||
import { proxy, subscribe, useSnapshot } from 'valtio'
|
||||
|
||||
import { SUPABASE_MANAGED_WAREHOUSE_RESOURCE_NAME } from './managedWarehouse.resources'
|
||||
import { getMockWarehouseHost } from './warehouseReplication.utils'
|
||||
import { INTERNAL_SCHEMAS } from '@/hooks/useProtectedSchemas'
|
||||
|
||||
export type ReplicationPhase = 'idle' | 'provisioning' | 'backfilling' | 'streaming' | 'failed'
|
||||
|
||||
export interface WarehouseProjectState {
|
||||
enabled: boolean
|
||||
replicationPhase: ReplicationPhase
|
||||
lagSeconds: number | null
|
||||
includedSchemas: string[]
|
||||
pipelineId: string | null
|
||||
destinationName: string
|
||||
publicationName: string
|
||||
warehouseHost: string
|
||||
enabledAt: string | null
|
||||
}
|
||||
|
||||
const DEFAULT_STATE: WarehouseProjectState = {
|
||||
enabled: false,
|
||||
replicationPhase: 'idle',
|
||||
lagSeconds: null,
|
||||
includedSchemas: [],
|
||||
pipelineId: null,
|
||||
destinationName: 'DuckLake',
|
||||
publicationName: SUPABASE_MANAGED_WAREHOUSE_RESOURCE_NAME,
|
||||
warehouseHost: '',
|
||||
enabledAt: null,
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'supabase-warehouse-wholesale-demo'
|
||||
|
||||
export const warehouseDemoStore = proxy<{
|
||||
projects: Record<string, WarehouseProjectState>
|
||||
}>({
|
||||
projects: {},
|
||||
})
|
||||
|
||||
function loadFromStorage(): void {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return
|
||||
const parsed = JSON.parse(raw) as Record<string, WarehouseProjectState>
|
||||
warehouseDemoStore.projects = parsed
|
||||
} catch {
|
||||
// ignore corrupt storage
|
||||
}
|
||||
}
|
||||
|
||||
function saveToStorage(): void {
|
||||
if (typeof window === 'undefined') return
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(warehouseDemoStore.projects))
|
||||
}
|
||||
|
||||
loadFromStorage()
|
||||
subscribe(warehouseDemoStore, saveToStorage)
|
||||
|
||||
const SYSTEM_TABLE_DENYLIST = new Set(['supabase_migrations', 'schema_migrations'])
|
||||
|
||||
const NON_REPLICABLE_SCHEMAS = new Set(INTERNAL_SCHEMAS)
|
||||
|
||||
export function isReplicableSchema(schema: string): boolean {
|
||||
return !NON_REPLICABLE_SCHEMAS.has(schema)
|
||||
}
|
||||
|
||||
export function getDefaultIncludedSchemas(availableSchemas: string[]): string[] {
|
||||
return availableSchemas.filter(isReplicableSchema)
|
||||
}
|
||||
|
||||
function getProjectState(projectRef: string): WarehouseProjectState {
|
||||
return warehouseDemoStore.projects[projectRef] ?? { ...DEFAULT_STATE }
|
||||
}
|
||||
|
||||
function setProjectState(projectRef: string, next: WarehouseProjectState): void {
|
||||
warehouseDemoStore.projects[projectRef] = next
|
||||
}
|
||||
|
||||
export function useWarehouseProjectState(projectRef: string | undefined) {
|
||||
const snap = useSnapshot(warehouseDemoStore)
|
||||
if (!projectRef) return { ...DEFAULT_STATE }
|
||||
return snap.projects[projectRef] ?? { ...DEFAULT_STATE }
|
||||
}
|
||||
|
||||
export function isWarehouseProjectEnabled(projectRef: string | undefined): boolean {
|
||||
if (!projectRef) return false
|
||||
return getProjectState(projectRef).enabled
|
||||
}
|
||||
|
||||
export function isTableReplicated(
|
||||
projectRef: string | undefined,
|
||||
schema: string,
|
||||
table: string
|
||||
): boolean {
|
||||
if (!projectRef) return false
|
||||
const state = getProjectState(projectRef)
|
||||
if (!state.enabled) return false
|
||||
if (!state.includedSchemas.includes(schema)) return false
|
||||
if (SYSTEM_TABLE_DENYLIST.has(table)) return false
|
||||
return isReplicableSchema(schema)
|
||||
}
|
||||
|
||||
let phaseTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function clearPhaseTimer(): void {
|
||||
if (phaseTimer) {
|
||||
clearTimeout(phaseTimer)
|
||||
phaseTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function simulatePhaseProgression(projectRef: string): void {
|
||||
clearPhaseTimer()
|
||||
|
||||
phaseTimer = setTimeout(() => {
|
||||
const current = getProjectState(projectRef)
|
||||
if (!current.enabled || current.replicationPhase !== 'provisioning') return
|
||||
setProjectState(projectRef, {
|
||||
...current,
|
||||
replicationPhase: 'backfilling',
|
||||
lagSeconds: 45,
|
||||
})
|
||||
|
||||
phaseTimer = setTimeout(() => {
|
||||
const backfilling = getProjectState(projectRef)
|
||||
if (!backfilling.enabled || backfilling.replicationPhase !== 'backfilling') return
|
||||
setProjectState(projectRef, {
|
||||
...backfilling,
|
||||
replicationPhase: 'streaming',
|
||||
lagSeconds: 2,
|
||||
})
|
||||
}, 6000)
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
export function enableWarehouseProject(projectRef: string, includedSchemas: string[]): void {
|
||||
const pipelineId = String(7000 + Math.floor(Math.random() * 100))
|
||||
setProjectState(projectRef, {
|
||||
enabled: true,
|
||||
replicationPhase: 'provisioning',
|
||||
lagSeconds: null,
|
||||
includedSchemas,
|
||||
pipelineId,
|
||||
destinationName: 'DuckLake',
|
||||
publicationName: SUPABASE_MANAGED_WAREHOUSE_RESOURCE_NAME,
|
||||
warehouseHost: getMockWarehouseHost(projectRef),
|
||||
enabledAt: new Date().toISOString(),
|
||||
})
|
||||
simulatePhaseProgression(projectRef)
|
||||
}
|
||||
|
||||
export function disableWarehouseProject(projectRef: string): void {
|
||||
clearPhaseTimer()
|
||||
setProjectState(projectRef, { ...DEFAULT_STATE })
|
||||
}
|
||||
|
||||
export function updateWarehouseIncludedSchemas(
|
||||
projectRef: string,
|
||||
includedSchemas: string[]
|
||||
): void {
|
||||
const current = getProjectState(projectRef)
|
||||
if (!current.enabled) return
|
||||
|
||||
const hadNewSchemas = includedSchemas.some((s) => !current.includedSchemas.includes(s))
|
||||
setProjectState(projectRef, {
|
||||
...current,
|
||||
includedSchemas,
|
||||
replicationPhase: hadNewSchemas ? 'backfilling' : current.replicationPhase,
|
||||
lagSeconds: hadNewSchemas ? 30 : current.lagSeconds,
|
||||
})
|
||||
|
||||
if (hadNewSchemas) {
|
||||
phaseTimer = setTimeout(() => {
|
||||
const state = getProjectState(projectRef)
|
||||
if (!state.enabled) return
|
||||
setProjectState(projectRef, {
|
||||
...state,
|
||||
replicationPhase: 'streaming',
|
||||
lagSeconds: 2,
|
||||
})
|
||||
}, 4000)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { ReplicationPhase, WarehouseProjectState } from './warehouseDemoStore'
|
||||
|
||||
export function formatReplicationPhase(phase: ReplicationPhase): string {
|
||||
switch (phase) {
|
||||
case 'idle':
|
||||
return 'Not enabled'
|
||||
case 'provisioning':
|
||||
return 'Provisioning'
|
||||
case 'backfilling':
|
||||
return 'Backfilling'
|
||||
case 'streaming':
|
||||
return 'Streaming'
|
||||
case 'failed':
|
||||
return 'Error'
|
||||
default:
|
||||
return phase
|
||||
}
|
||||
}
|
||||
|
||||
export function formatReplicationLagSeconds(lagSeconds: number | null): string {
|
||||
if (lagSeconds === null) return '—'
|
||||
if (lagSeconds === 0) return 'Caught up'
|
||||
if (lagSeconds < 60) return `${lagSeconds}s`
|
||||
if (lagSeconds < 3600) return `${Math.round(lagSeconds / 60)}m`
|
||||
return `${(lagSeconds / 3600).toFixed(1)}h`
|
||||
}
|
||||
|
||||
export function getWarehouseDestinationStatusLabel(phase: ReplicationPhase): string {
|
||||
switch (phase) {
|
||||
case 'provisioning':
|
||||
return 'Starting'
|
||||
case 'backfilling':
|
||||
return 'Backfilling'
|
||||
case 'streaming':
|
||||
return 'Active'
|
||||
case 'failed':
|
||||
return 'Failed'
|
||||
default:
|
||||
return 'Unknown'
|
||||
}
|
||||
}
|
||||
|
||||
export function getMockWarehouseHost(projectRef: string): string {
|
||||
return `warehouse.${projectRef}.supabase.co`
|
||||
}
|
||||
|
||||
export function buildWarehouseConnectionString({
|
||||
projectRef,
|
||||
databaseName = 'postgres',
|
||||
port = 5432,
|
||||
}: {
|
||||
projectRef: string
|
||||
databaseName?: string
|
||||
port?: number
|
||||
}): string {
|
||||
const host = getMockWarehouseHost(projectRef)
|
||||
return `postgresql://postgres.[YOUR-PASSWORD]@${host}:${port}/${databaseName}`
|
||||
}
|
||||
|
||||
export function isWarehouseReplicationHealthy(state: WarehouseProjectState): boolean {
|
||||
return state.enabled && state.replicationPhase === 'streaming' && (state.lagSeconds ?? 0) < 120
|
||||
}
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
isUpdateWithoutWhere,
|
||||
suffixWithLimit,
|
||||
} from './SQLEditor.utils'
|
||||
import { useSqlQueryTarget } from './SqlWarehouseTargetToggle'
|
||||
import { useAddDefinitions } from './useAddDefinitions'
|
||||
import { UtilityPanel } from './UtilityPanel/UtilityPanel'
|
||||
import {
|
||||
@@ -129,6 +130,7 @@ export const SQLEditor = () => {
|
||||
const getImpersonatedRoleState = useGetImpersonatedRoleState()
|
||||
const databaseSelectorState = useDatabaseSelectorStateSnapshot()
|
||||
const { aiOptInLevel } = useOrgAiOptInLevel()
|
||||
const sqlQueryTarget = useSqlQueryTarget()
|
||||
|
||||
// [Ali] Kill switch to hide the SQL Editor Explain tab and its entry points
|
||||
const disablePrettyExplain = useFlag('DisablePrettyExplainOnSqlEditor')
|
||||
@@ -1063,31 +1065,36 @@ export const SQLEditor = () => {
|
||||
<div className="h-9">
|
||||
{results?.rows !== undefined && !isExecuting && (
|
||||
<GridFooter className="flex items-center justify-between gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<p className="text-xs">
|
||||
<span className="text-foreground">
|
||||
{results.rows.length} row{results.rows.length > 1 ? 's' : ''}
|
||||
</span>
|
||||
<span className="text-foreground-lighter ml-1">
|
||||
{results.autoLimit !== undefined &&
|
||||
` (Limited to only ${results.autoLimit} rows)`}
|
||||
</span>
|
||||
</p>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
<p className="flex flex-col gap-y-1">
|
||||
<span>
|
||||
Results are automatically limited to preserve browser performance, in
|
||||
particular if your query returns an exceptionally large number of rows.
|
||||
</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<p className="text-xs">
|
||||
<span className="text-foreground">
|
||||
{results.rows.length} row{results.rows.length > 1 ? 's' : ''}
|
||||
</span>
|
||||
<span className="text-foreground-lighter ml-1">
|
||||
{results.autoLimit !== undefined &&
|
||||
` (Limited to only ${results.autoLimit} rows)`}
|
||||
</span>
|
||||
</p>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
<p className="flex flex-col gap-y-1">
|
||||
<span>
|
||||
Results are automatically limited to preserve browser performance, in
|
||||
particular if your query returns an exceptionally large number of rows.
|
||||
</span>
|
||||
|
||||
<span className="text-foreground-light">
|
||||
You may change or remove this limit from the dropdown on the right
|
||||
</span>
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<span className="text-foreground-light">
|
||||
You may change or remove this limit from the dropdown on the right
|
||||
</span>
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{sqlQueryTarget === 'warehouse' && (
|
||||
<p className="text-xs text-foreground-light">Served by: Warehouse</p>
|
||||
)}
|
||||
</div>
|
||||
{results.autoLimit !== undefined && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { LOCAL_STORAGE_KEYS, useParams } from 'common'
|
||||
import { cn } from 'ui'
|
||||
|
||||
import { isWarehouseProjectEnabled } from '@/components/interfaces/Database/Warehouse/warehouseDemoStore'
|
||||
import { useIsWarehouseEnabled } from '@/hooks/misc/useIsWarehouseEnabled'
|
||||
import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
|
||||
|
||||
export type SqlQueryTarget = 'postgres' | 'warehouse'
|
||||
|
||||
interface SqlWarehouseTargetToggleProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function SqlWarehouseTargetToggle({ className }: SqlWarehouseTargetToggleProps) {
|
||||
const { ref: projectRef } = useParams()
|
||||
const isWarehouseFeatureEnabled = useIsWarehouseEnabled()
|
||||
const warehouseEnabled = isWarehouseProjectEnabled(projectRef)
|
||||
|
||||
const [queryTarget, setQueryTarget] = useLocalStorageQuery<SqlQueryTarget>(
|
||||
LOCAL_STORAGE_KEYS.SQL_EDITOR_QUERY_TARGET(projectRef as string),
|
||||
'postgres'
|
||||
)
|
||||
|
||||
if (!isWarehouseFeatureEnabled || !warehouseEnabled) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center rounded-md border border-overlay bg-surface-200 p-0.5 text-xs',
|
||||
className
|
||||
)}
|
||||
role="group"
|
||||
aria-label="SQL query target"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'rounded px-2 py-1 transition-colors',
|
||||
queryTarget === 'postgres'
|
||||
? 'bg-surface-100 text-foreground shadow-sm'
|
||||
: 'text-foreground-light hover:text-foreground'
|
||||
)}
|
||||
onClick={() => setQueryTarget('postgres')}
|
||||
>
|
||||
Postgres
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'rounded px-2 py-1 transition-colors',
|
||||
queryTarget === 'warehouse'
|
||||
? 'bg-surface-100 text-foreground shadow-sm'
|
||||
: 'text-foreground-light hover:text-foreground'
|
||||
)}
|
||||
onClick={() => setQueryTarget('warehouse')}
|
||||
>
|
||||
Warehouse
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function useSqlQueryTarget(): SqlQueryTarget {
|
||||
const { ref: projectRef } = useParams()
|
||||
const [queryTarget] = useLocalStorageQuery<SqlQueryTarget>(
|
||||
LOCAL_STORAGE_KEYS.SQL_EDITOR_QUERY_TARGET(projectRef as string),
|
||||
'postgres'
|
||||
)
|
||||
return queryTarget
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from 'ui'
|
||||
|
||||
import { SqlWarehouseTargetToggle } from '../SqlWarehouseTargetToggle'
|
||||
import { AutosaveStatus } from './AutosaveStatus'
|
||||
import { SqlRunButton } from './RunButton'
|
||||
import { SqlSaveButton } from './SaveButton'
|
||||
@@ -212,6 +213,7 @@ export const UtilityActions = ({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-x-2">
|
||||
<SqlWarehouseTargetToggle />
|
||||
<div className="flex items-center">
|
||||
{IS_PLATFORM && (
|
||||
<DatabaseSelector
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useFlag } from 'common'
|
||||
|
||||
import { useSelectedOrganizationQuery } from './useSelectedOrganization'
|
||||
|
||||
/**
|
||||
* Whether the Warehouse product is enabled for the current session.
|
||||
*
|
||||
* Gates every Warehouse surface while in preview. Backed by the `warehouse`
|
||||
* ConfigCat flag, configured as a comma-separated organization slug allowlist.
|
||||
* Use `all` to enable every organization.
|
||||
*/
|
||||
export function useIsWarehouseEnabled(): boolean {
|
||||
const flagValue = useFlag<string>('warehouse')
|
||||
const { data: organization } = useSelectedOrganizationQuery()
|
||||
|
||||
const allowedOrgSlugs =
|
||||
typeof flagValue === 'string' ? flagValue.split(',').map((slug) => slug.trim()) : []
|
||||
|
||||
if (allowedOrgSlugs.includes('all')) return true
|
||||
|
||||
return allowedOrgSlugs.includes(organization?.slug ?? '')
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
import { Destinations } from '@/components/interfaces/Database/Replication/Destinations'
|
||||
import { ReplicationDiagram } from '@/components/interfaces/Database/Replication/ReplicationDiagram'
|
||||
import { WarehouseProjectCard } from '@/components/interfaces/Database/Warehouse/WarehouseProjectCard'
|
||||
import DatabaseLayout from '@/components/layouts/DatabaseLayout/DatabaseLayout'
|
||||
import { DefaultLayout } from '@/components/layouts/DefaultLayout'
|
||||
import { ScaffoldContainer, ScaffoldSection } from '@/components/layouts/Scaffold'
|
||||
@@ -55,6 +56,11 @@ const DatabaseReplicationPage: NextPageWithLayout = () => {
|
||||
</ScaffoldContainer>
|
||||
) : (
|
||||
<>
|
||||
<ScaffoldContainer>
|
||||
<ScaffoldSection isFullWidth>
|
||||
<WarehouseProjectCard />
|
||||
</ScaffoldSection>
|
||||
</ScaffoldContainer>
|
||||
<ReplicationDiagram />
|
||||
<ScaffoldContainer>
|
||||
<ScaffoldSection isFullWidth className="pt-6!">
|
||||
|
||||
@@ -47,6 +47,7 @@ export const LOCAL_STORAGE_KEYS = {
|
||||
SQL_EDITOR_AI_SCHEMA: (ref: string) => `supabase_sql-editor-ai-schema-enabled-${ref}`,
|
||||
SQL_EDITOR_AI_OPEN: 'supabase_sql-editor-ai-open',
|
||||
SQL_EDITOR_LAST_SELECTED_DB: (ref: string) => `sql-editor-last-selected-db-${ref}`,
|
||||
SQL_EDITOR_QUERY_TARGET: (ref: string) => `sql-editor-query-target-${ref}`,
|
||||
SQL_EDITOR_SQL_BLOCK_ACKNOWLEDGED: (ref: string) => `sql-editor-sql-block-acknowledged-${ref}`,
|
||||
SQL_EDITOR_SECTION_STATE: (ref: string) => `sql-editor-section-state-${ref}`,
|
||||
SQL_EDITOR_SORT: (ref: string) => `sql-editor-sort-${ref}`,
|
||||
|
||||
@@ -2099,7 +2099,7 @@ export interface HomeConnectActionClickedEvent {
|
||||
/**
|
||||
* The connect action/tile that was clicked
|
||||
*/
|
||||
mode: 'framework' | 'direct' | 'orm' | 'mcp' | 'server' | 'api_keys'
|
||||
mode: 'framework' | 'direct' | 'orm' | 'mcp' | 'server' | 'warehouse' | 'api_keys'
|
||||
}
|
||||
groups: TelemetryGroups
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user