feat: integrate Supabase for database operations and migrations

Add support for Supabase database operations, including migrations and queries. Implement new Supabase-related types, actions, and components to handle database interactions. Enhance the prompt system to include Supabase-specific instructions and constraints. Ensure data integrity and security by enforcing row-level security and proper migration practices.
This commit is contained in:
KevIsDev
2025-03-19 23:11:31 +00:00
parent 9fd5f149c9
commit 02974089de
18 changed files with 1316 additions and 12 deletions

View File

@@ -35,7 +35,11 @@ export const Artifact = memo(({ messageId }: ArtifactProps) => {
const actions = useStore(
computed(artifact.runner.actions, (actions) => {
return Object.values(actions);
// Filter out Supabase actions except for migrations
return Object.values(actions).filter((action) => {
// Exclude actions with type 'supabase' or actions that contain 'supabase' in their content
return action.type !== 'supabase' && !(action.type === 'shell' && action.content?.includes('supabase'));
});
}),
);

View File

@@ -29,13 +29,15 @@ import type { ProviderInfo } from '~/types/model';
import { ScreenshotStateManager } from './ScreenshotStateManager';
import { toast } from 'react-toastify';
import StarterTemplates from './StarterTemplates';
import type { ActionAlert } from '~/types/actions';
import type { ActionAlert, SupabaseAlert } from '~/types/actions';
import ChatAlert from './ChatAlert';
import type { ModelInfo } from '~/lib/modules/llm/types';
import ProgressCompilation from './ProgressCompilation';
import type { ProgressAnnotation } from '~/types/context';
import type { ActionRunner } from '~/lib/runtime/action-runner';
import { LOCAL_PROVIDERS } from '~/lib/stores/settings';
import { SupabaseChatAlert } from '~/components/chat/SupabaseAlert';
import { SupabaseConnection } from './SupabaseConnection';
const TEXTAREA_MIN_HEIGHT = 76;
@@ -69,6 +71,8 @@ interface BaseChatProps {
setImageDataList?: (dataList: string[]) => void;
actionAlert?: ActionAlert;
clearAlert?: () => void;
supabaseAlert?: SupabaseAlert;
clearSupabaseAlert?: () => void;
data?: JSONValue[] | undefined;
actionRunner?: ActionRunner;
}
@@ -105,6 +109,8 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
messages,
actionAlert,
clearAlert,
supabaseAlert,
clearSupabaseAlert,
data,
actionRunner,
},
@@ -343,6 +349,16 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
) : null;
}}
</ClientOnly>
{supabaseAlert && (
<SupabaseChatAlert
alert={supabaseAlert}
clearAlert={() => clearSupabaseAlert?.()}
postMessage={(message) => {
sendMessage?.({} as any, message);
clearSupabaseAlert?.();
}}
/>
)}
<div
className={classNames('flex flex-col gap-4 w-full max-w-chat mx-auto z-prompt mb-6', {
'sticky bottom-2': chatStarted,
@@ -590,6 +606,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
a new line
</div>
) : null}
<SupabaseConnection />
</div>
</div>
</div>

View File

@@ -26,6 +26,7 @@ import { getTemplates, selectStarterTemplate } from '~/utils/selectStarterTempla
import { logStore } from '~/lib/stores/logs';
import { streamingState } from '~/lib/stores/streaming';
import { filesToArtifacts } from '~/utils/fileUtils';
import { supabaseConnection } from '~/lib/stores/supabase';
const toastAnimation = cssTransition({
enter: 'animated fadeInRight',
@@ -123,6 +124,11 @@ export const ChatImpl = memo(
const [fakeLoading, setFakeLoading] = useState(false);
const files = useStore(workbenchStore.files);
const actionAlert = useStore(workbenchStore.alert);
const supabaseConn = useStore(supabaseConnection); // Add this line to get Supabase connection
const selectedProject = supabaseConn.stats?.projects?.find(
(project) => project.id === supabaseConn.selectedProjectId,
);
const supabaseAlert = useStore(workbenchStore.supabaseAlert);
const { activeProviders, promptId, autoSelectTemplate, contextOptimizationEnabled } = useSettings();
const [model, setModel] = useState(() => {
@@ -160,6 +166,10 @@ export const ChatImpl = memo(
files,
promptId,
contextOptimization: contextOptimizationEnabled,
supabase: {
isConnected: supabaseConn.isConnected,
hasSelectedProject: !!selectedProject,
},
},
sendExtraMessageFields: true,
onError: (e) => {
@@ -544,6 +554,8 @@ export const ChatImpl = memo(
setImageDataList={setImageDataList}
actionAlert={actionAlert}
clearAlert={() => workbenchStore.clearAlert()}
supabaseAlert={supabaseAlert}
clearSupabaseAlert={() => workbenchStore.clearSupabaseAlert()}
data={chatData}
/>
);

View File

@@ -0,0 +1,199 @@
import { AnimatePresence, motion } from 'framer-motion';
import type { SupabaseAlert } from '~/types/actions';
import { classNames } from '~/utils/classNames';
import { supabaseConnection } from '~/lib/stores/supabase';
import { useStore } from '@nanostores/react';
import { useState } from 'react';
interface Props {
alert: SupabaseAlert;
clearAlert: () => void;
postMessage: (message: string) => void;
}
export function SupabaseChatAlert({ alert, clearAlert, postMessage }: Props) {
const { content } = alert;
const connection = useStore(supabaseConnection);
const [isExecuting, setIsExecuting] = useState(false);
const [isCollapsed, setIsCollapsed] = useState(true);
// Determine connection state
const isConnected = !!(connection.token && connection.selectedProjectId);
// Set title and description based on connection state
const title = isConnected ? 'Supabase Query' : 'Supabase Connection Required';
const description = isConnected ? 'Execute database query' : 'Supabase connection required';
const message = isConnected
? 'Please review the proposed changes and apply them to your database.'
: 'Please connect to Supabase to continue with this operation.';
const handleConnectClick = () => {
// Dispatch an event to open the Supabase connection dialog
document.dispatchEvent(new CustomEvent('open-supabase-connection'));
};
// Determine if we should show the Connect button or Apply Changes button
const showConnectButton = !isConnected;
const executeSupabaseAction = async (sql: string) => {
if (!connection.token || !connection.selectedProjectId) {
console.error('No Supabase token or project selected');
return;
}
setIsExecuting(true);
try {
const response = await fetch('/api/supabase/query', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${connection.token}`,
},
body: JSON.stringify({
projectId: connection.selectedProjectId,
query: sql,
}),
});
if (!response.ok) {
const errorData = (await response.json()) as any;
throw new Error(`Supabase query failed: ${errorData.error?.message || response.statusText}`);
}
const result = await response.json();
console.log('Supabase query executed successfully:', result);
clearAlert();
} catch (error) {
console.error('Failed to execute Supabase action:', error);
postMessage(
`*Error executing Supabase query please fix and return the query again*\n\`\`\`\n${error instanceof Error ? error.message : String(error)}\n\`\`\`\n`,
);
} finally {
setIsExecuting(false);
}
};
const cleanSqlContent = (content: string) => {
if (!content) {
return '';
}
let cleaned = content.replace(/\/\*[\s\S]*?\*\//g, '');
cleaned = cleaned.replace(/(--).*$/gm, '').replace(/(#).*$/gm, '');
const statements = cleaned
.split(';')
.map((stmt) => stmt.trim())
.filter((stmt) => stmt.length > 0)
.join(';\n\n');
return statements;
};
return (
<AnimatePresence>
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
className="max-w-chat rounded-lg border-l-2 border-l-[#098F5F] border-bolt-elements-borderColor bg-bolt-elements-background-depth-2"
>
{/* Header */}
<div className="p-4 pb-2">
<div className="flex items-center gap-2">
<img height="10" width="18" crossOrigin="anonymous" src="https://cdn.simpleicons.org/supabase" />
<h3 className="text-sm font-medium text-[#3DCB8F]">{title}</h3>
</div>
</div>
{/* SQL Content */}
<div className="px-4">
{!isConnected ? (
<div className="p-3 rounded-md bg-bolt-elements-background-depth-3">
<span className="text-sm text-bolt-elements-textPrimary">
You must first connect to Supabase and select a project.
</span>
</div>
) : (
<>
<div
className="flex items-center p-2 rounded-md bg-bolt-elements-background-depth-3 cursor-pointer"
onClick={() => setIsCollapsed(!isCollapsed)}
>
<div className="i-ph:database text-bolt-elements-textPrimary mr-2"></div>
<span className="text-sm text-bolt-elements-textPrimary flex-grow">
{description || 'Create table and setup auth'}
</span>
<div
className={`i-ph:caret-up text-bolt-elements-textPrimary transition-transform ${isCollapsed ? 'rotate-180' : ''}`}
></div>
</div>
{!isCollapsed && content && (
<div className="mt-2 p-3 bg-bolt-elements-background-depth-4 rounded-md overflow-auto max-h-60 font-mono text-xs text-bolt-elements-textSecondary">
<pre>{cleanSqlContent(content)}</pre>
</div>
)}
</>
)}
</div>
{/* Message and Actions */}
<div className="p-4">
<p className="text-sm text-bolt-elements-textSecondary mb-4">{message}</p>
<div className="flex gap-2">
{showConnectButton ? (
<button
onClick={handleConnectClick}
className={classNames(
`px-3 py-2 rounded-md text-sm font-medium`,
'bg-[#098F5F]',
'hover:bg-[#0aa06c]',
'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500',
'text-white',
'flex items-center gap-1.5',
)}
>
Connect to Supabase
</button>
) : (
<button
onClick={() => executeSupabaseAction(content)}
disabled={isExecuting}
className={classNames(
`px-3 py-2 rounded-md text-sm font-medium`,
'bg-[#098F5F]',
'hover:bg-[#0aa06c]',
'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500',
'text-white',
'flex items-center gap-1.5',
isExecuting ? 'opacity-70 cursor-not-allowed' : '',
)}
>
{isExecuting ? 'Applying...' : 'Apply Changes'}
</button>
)}
<button
onClick={clearAlert}
disabled={isExecuting}
className={classNames(
`px-3 py-2 rounded-md text-sm font-medium`,
'bg-[#503B26]',
'hover:bg-[#774f28]',
'focus:outline-none',
'text-[#F79007]',
isExecuting ? 'opacity-70 cursor-not-allowed' : '',
)}
>
Dismiss
</button>
</div>
</div>
</motion.div>
</AnimatePresence>
);
}

View File

@@ -0,0 +1,324 @@
import { useEffect } from 'react';
import { useSupabaseConnection } from '~/lib/hooks/useSupabaseConnection';
import { classNames } from '~/utils/classNames';
import { useStore } from '@nanostores/react';
import { chatId } from '~/lib/persistence/useChatHistory';
import { fetchSupabaseStats } from '~/lib/stores/supabase';
import { Dialog, DialogRoot, DialogClose, DialogTitle, DialogButton } from '~/components/ui/Dialog';
export function SupabaseConnection() {
const {
connection: supabaseConn,
connecting,
fetchingStats,
isProjectsExpanded,
setIsProjectsExpanded,
isDropdownOpen: isDialogOpen,
setIsDropdownOpen: setIsDialogOpen,
handleConnect,
handleDisconnect,
selectProject,
handleCreateProject,
updateToken,
isConnected,
} = useSupabaseConnection();
const currentChatId = useStore(chatId);
// Add event listener for opening the connection dialog
useEffect(() => {
const handleOpenConnectionDialog = () => {
setIsDialogOpen(true);
};
document.addEventListener('open-supabase-connection', handleOpenConnectionDialog);
return () => {
document.removeEventListener('open-supabase-connection', handleOpenConnectionDialog);
};
}, [setIsDialogOpen]);
// Load the selected project from localStorage when connected or chat changes
useEffect(() => {
if (isConnected && currentChatId) {
const savedProjectId = localStorage.getItem(`supabase-project-${currentChatId}`);
/*
* If there's no saved project for this chat but there is a global selected project,
* use the global one instead of clearing it
*/
if (!savedProjectId && supabaseConn.selectedProjectId) {
// Save the current global project to this chat
localStorage.setItem(`supabase-project-${currentChatId}`, supabaseConn.selectedProjectId);
} else if (savedProjectId && savedProjectId !== supabaseConn.selectedProjectId) {
selectProject(savedProjectId);
}
}
}, [isConnected, currentChatId]);
useEffect(() => {
if (currentChatId && supabaseConn.selectedProjectId) {
localStorage.setItem(`supabase-project-${currentChatId}`, supabaseConn.selectedProjectId);
} else if (currentChatId && !supabaseConn.selectedProjectId) {
localStorage.removeItem(`supabase-project-${currentChatId}`);
}
}, [currentChatId, supabaseConn.selectedProjectId]);
useEffect(() => {
if (isConnected && supabaseConn.token) {
fetchSupabaseStats(supabaseConn.token).catch(console.error);
}
}, [isConnected, supabaseConn.token]);
return (
<div className="relative">
<div className="flex border border-bolt-elements-borderColor rounded-md overflow-hidden mr-2 text-sm">
<Button
active
disabled={connecting}
onClick={() => setIsDialogOpen(!isDialogOpen)}
className="hover:bg-bolt-elements-item-backgroundActive !text-white flex items-center gap-2"
>
<img
className="w-4 h-4"
height="20"
width="20"
crossOrigin="anonymous"
src="https://cdn.simpleicons.org/supabase"
/>
{isConnected && supabaseConn.project && (
<span className="ml-1 text-xs max-w-[100px] truncate">{supabaseConn.project.name}</span>
)}
</Button>
</div>
<DialogRoot open={isDialogOpen} onOpenChange={setIsDialogOpen}>
{isDialogOpen && (
<Dialog className="max-w-[520px] p-6">
{!isConnected ? (
<div className="space-y-4">
<DialogTitle>
<img
className="w-5 h-5"
height="24"
width="24"
crossOrigin="anonymous"
src="https://cdn.simpleicons.org/supabase"
/>
Connect to Supabase
</DialogTitle>
<div>
<label className="block text-sm text-bolt-elements-textSecondary mb-2">Access Token</label>
<input
type="password"
value={supabaseConn.token}
onChange={(e) => updateToken(e.target.value)}
disabled={connecting}
placeholder="Enter your Supabase access token"
className={classNames(
'w-full px-3 py-2 rounded-lg text-sm',
'bg-[#F8F8F8] dark:bg-[#1A1A1A]',
'border border-[#E5E5E5] dark:border-[#333333]',
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
'focus:outline-none focus:ring-1 focus:ring-[#3ECF8E]',
'disabled:opacity-50',
)}
/>
<div className="mt-2 text-sm text-bolt-elements-textSecondary">
<a
href="https://app.supabase.com/account/tokens"
target="_blank"
rel="noopener noreferrer"
className="text-[#3ECF8E] hover:underline inline-flex items-center gap-1"
>
Get your token
<div className="i-ph:arrow-square-out w-4 h-4" />
</a>
</div>
</div>
<div className="flex justify-end gap-2 mt-6">
<DialogClose asChild>
<DialogButton type="secondary">Cancel</DialogButton>
</DialogClose>
<button
onClick={handleConnect}
disabled={connecting || !supabaseConn.token}
className={classNames(
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
'bg-[#3ECF8E] text-white',
'hover:bg-[#3BBF84]',
'disabled:opacity-50 disabled:cursor-not-allowed',
)}
>
{connecting ? (
<>
<div className="i-ph:spinner-gap animate-spin" />
Connecting...
</>
) : (
<>
<div className="i-ph:plug-charging w-4 h-4" />
Connect
</>
)}
</button>
</div>
</div>
) : (
<div className="space-y-4">
<div className="flex items-center justify-between mb-2">
<DialogTitle>
<img
className="w-5 h-5"
height="24"
width="24"
crossOrigin="anonymous"
src="https://cdn.simpleicons.org/supabase"
/>
Supabase Connection
</DialogTitle>
</div>
<div className="flex items-center gap-4 p-3 bg-[#F8F8F8] dark:bg-[#1A1A1A] rounded-lg">
<div>
<h4 className="text-sm font-medium text-bolt-elements-textPrimary">{supabaseConn.user?.email}</h4>
<p className="text-xs text-bolt-elements-textSecondary">Role: {supabaseConn.user?.role}</p>
</div>
</div>
{fetchingStats ? (
<div className="flex items-center gap-2 text-sm text-bolt-elements-textSecondary">
<div className="i-ph:spinner-gap w-4 h-4 animate-spin" />
Fetching projects...
</div>
) : (
<div>
<div className="flex items-center justify-between mb-2">
<button
onClick={() => setIsProjectsExpanded(!isProjectsExpanded)}
className="bg-transparent text-left text-sm font-medium text-bolt-elements-textPrimary flex items-center gap-2"
>
<div className="i-ph:database w-4 h-4" />
Your Projects ({supabaseConn.stats?.totalProjects || 0})
<div
className={classNames(
'i-ph:caret-down w-4 h-4 transition-transform',
isProjectsExpanded ? 'rotate-180' : '',
)}
/>
</button>
<button
onClick={() => handleCreateProject()}
className="px-2 py-1 rounded-md text-xs bg-[#3ECF8E] text-white hover:bg-[#3BBF84] flex items-center gap-1"
>
<div className="i-ph:plus w-3 h-3" />
New Project
</button>
</div>
{isProjectsExpanded && (
<>
{!supabaseConn.selectedProjectId && (
<div className="mb-2 p-3 bg-[#F8F8F8] dark:bg-[#1A1A1A] rounded-lg text-sm text-bolt-elements-textSecondary">
Select a project or create a new one for this chat
</div>
)}
{supabaseConn.stats?.projects?.length ? (
<div className="grid gap-2 max-h-60 overflow-y-auto">
{supabaseConn.stats.projects.map((project) => (
<div
key={project.id}
className="block p-3 rounded-lg border border-[#E5E5E5] dark:border-[#1A1A1A] hover:border-[#3ECF8E] dark:hover:border-[#3ECF8E] transition-colors"
>
<div className="flex items-center justify-between">
<div>
<h5 className="text-sm font-medium text-bolt-elements-textPrimary flex items-center gap-1">
<div className="i-ph:database w-3 h-3 text-[#3ECF8E]" />
{project.name}
</h5>
<div className="text-xs text-bolt-elements-textSecondary mt-1">
{project.region}
</div>
</div>
<button
onClick={() => selectProject(project.id)}
className={classNames(
'px-3 py-1 rounded-md text-xs',
supabaseConn.selectedProjectId === project.id
? 'bg-[#3ECF8E] text-white'
: 'bg-[#F0F0F0] dark:bg-[#252525] text-bolt-elements-textSecondary hover:bg-[#3ECF8E] hover:text-white',
)}
>
{supabaseConn.selectedProjectId === project.id ? (
<span className="flex items-center gap-1">
<div className="i-ph:check w-3 h-3" />
Selected
</span>
) : (
'Select'
)}
</button>
</div>
</div>
))}
</div>
) : (
<div className="text-sm text-bolt-elements-textSecondary flex items-center gap-2">
<div className="i-ph:info w-4 h-4" />
No projects found
</div>
)}
</>
)}
</div>
)}
<div className="flex justify-end gap-2 mt-6">
<DialogClose asChild>
<DialogButton type="secondary">Close</DialogButton>
</DialogClose>
<DialogButton type="danger" onClick={handleDisconnect}>
<div className="i-ph:plug-x w-4 h-4" />
Disconnect
</DialogButton>
</div>
</div>
)}
</Dialog>
)}
</DialogRoot>
</div>
);
}
interface ButtonProps {
active?: boolean;
disabled?: boolean;
children?: any;
onClick?: VoidFunction;
className?: string;
}
function Button({ active = false, disabled = false, children, onClick, className }: ButtonProps) {
return (
<button
className={classNames(
'flex items-center p-1.5',
{
'bg-bolt-elements-item-backgroundDefault hover:bg-bolt-elements-item-backgroundActive text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary':
!active,
'bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent': active && !disabled,
'bg-bolt-elements-item-backgroundDefault text-alpha-gray-20 dark:text-alpha-white-20 cursor-not-allowed':
disabled,
},
className,
)}
onClick={onClick}
>
{children}
</button>
);
}