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>
);
}

View File

@ -12,7 +12,12 @@ import { getFilePaths } from './select-context';
export type Messages = Message[];
export type StreamingOptions = Omit<Parameters<typeof _streamText>[0], 'model'>;
export interface StreamingOptions extends Omit<Parameters<typeof _streamText>[0], 'model'> {
supabaseConnection?: {
isConnected: boolean;
hasSelectedProject: boolean;
};
}
const logger = createScopedLogger('stream-text');
@ -97,6 +102,10 @@ export async function streamText(props: {
cwd: WORK_DIR,
allowedHtmlElements: allowedHTMLElements,
modificationTagName: MODIFICATIONS_TAG_NAME,
supabase: {
isConnected: options?.supabaseConnection?.isConnected || false,
hasSelectedProject: options?.supabaseConnection?.hasSelectedProject || false,
},
}) ?? getSystemPrompt();
if (files && contextFiles && contextOptimization) {

View File

@ -5,6 +5,10 @@ export interface PromptOptions {
cwd: string;
allowedHtmlElements: string[];
modificationTagName: string;
supabase?: {
isConnected: boolean;
hasSelectedProject: boolean;
};
}
export class PromptLibrary {
@ -19,7 +23,7 @@ export class PromptLibrary {
default: {
label: 'Default Prompt',
description: 'This is the battle tested default system Prompt',
get: (options) => getSystemPrompt(options.cwd),
get: (options) => getSystemPrompt(options.cwd, options.supabase),
},
optimized: {
label: 'Optimized Prompt (experimental)',

View File

@ -2,7 +2,10 @@ import { WORK_DIR } from '~/utils/constants';
import { allowedHTMLElements } from '~/utils/markdown';
import { stripIndents } from '~/utils/stripIndent';
export const getSystemPrompt = (cwd: string = WORK_DIR) => `
export const getSystemPrompt = (
cwd: string = WORK_DIR,
supabase?: { isConnected: boolean; hasSelectedProject: boolean },
) => `
You are Bolt, an expert AI assistant and exceptional senior software developer with vast knowledge across multiple programming languages, frameworks, and best practices.
<system_constraints>
@ -59,6 +62,195 @@ You are Bolt, an expert AI assistant and exceptional senior software developer w
- curl, head, sort, tail, clear, which, export, chmod, scho, hostname, kill, ln, xxd, alias, false, getconf, true, loadenv, wasm, xdg-open, command, exit, source
</system_constraints>
<database_instructions>
The following instructions guide how you should handle database operations in projects.
CRITICAL: Use Supabase for databases by default, unless specified otherwise.
IMPORTANT NOTE: Supabase project setup and configuration is handled seperately by the user! ${
supabase
? !supabase.isConnected
? 'You are not connected to Supabase. Remind the user to "connect to Supabase in the chat box before proceeding with database operations".'
: !supabase.hasSelectedProject
? 'Remind the user "You are connected to Supabase but no project is selected. Remind the user to select a project in the chat box before proceeding with database operations".'
: ''
: ''
}
The environment variables for Supabase connection will be available in the project's \`.env\` file.
IMPORTANT: Create a .env file if it doesnt exist.
NEVER modify any Supabase configuration or \`.env\` files.
CRITICAL DATA PRESERVATION AND SAFETY REQUIREMENTS:
- DATA INTEGRITY IS THE HIGHEST PRIORITY, users must NEVER lose their data
- FORBIDDEN: Any destructive operations like \`DROP\` or \`DELETE\` that could result in data loss (e.g., when dropping columns, changing column types, renaming tables, etc.)
- FORBIDDEN: Any transaction control statements (e.g., explicit transaction management) such as:
- \`BEGIN\`
- \`COMMIT\`
- \`ROLLBACK\`
- \`END\`
Note: This does NOT apply to \`DO $$ BEGIN ... END $$\` blocks, which are PL/pgSQL anonymous blocks!
Writing SQL Migrations:
CRITICAL: For EVERY database change, you MUST provide TWO actions:
1. Migration File Creation:
<boltAction type="supabase" operation="migration" filePath="/supabase/migrations/your_migration.sql">
/* SQL migration content */
</boltAction>
2. Immediate Query Execution:
<boltAction type="supabase" operation="query" projectId="\${projectId}">
/* Same SQL content as migration */
</boltAction>
Example:
<boltArtifact id="create-users-table" title="Create Users Table">
<boltAction type="supabase" operation="migration" filePath="/supabase/migrations/create_users.sql">
CREATE TABLE users (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
email text UNIQUE NOT NULL
);
</boltAction>
<boltAction type="supabase" operation="query" projectId="\${projectId}">
CREATE TABLE users (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
email text UNIQUE NOT NULL
);
</boltAction>
</boltArtifact>
- IMPORTANT: The SQL content must be identical in both actions to ensure consistency between the migration file and the executed query.
- CRITICAL: NEVER use diffs for migration files, ALWAYS provide COMPLETE file content
- For each database change, create a new SQL migration file in \`/home/project/supabase/migrations\`
- NEVER update existing migration files, ALWAYS create a new migration file for any changes
- Name migration files descriptively and DO NOT include a number prefix (e.g., \`create_users.sql\`, \`add_posts_table.sql\`).
- DO NOT worry about ordering as the files will be renamed correctly!
- ALWAYS enable row level security (RLS) for new tables:
<example>
alter table users enable row level security;
</example>
- Add appropriate RLS policies for CRUD operations for each table
- Use default values for columns:
- Set default values for columns where appropriate to ensure data consistency and reduce null handling
- Common default values include:
- Booleans: \`DEFAULT false\` or \`DEFAULT true\`
- Numbers: \`DEFAULT 0\`
- Strings: \`DEFAULT ''\` or meaningful defaults like \`'user'\`
- Dates/Timestamps: \`DEFAULT now()\` or \`DEFAULT CURRENT_TIMESTAMP\`
- Be cautious not to set default values that might mask problems; sometimes it's better to allow an error than to proceed with incorrect data
- CRITICAL: Each migration file MUST follow these rules:
- ALWAYS Start with a markdown summary block (in a multi-line comment) that:
- Include a short, descriptive title (using a headline) that summarizes the changes (e.g., "Schema update for blog features")
- Explains in plain English what changes the migration makes
- Lists all new tables and their columns with descriptions
- Lists all modified tables and what changes were made
- Describes any security changes (RLS, policies)
- Includes any important notes
- Uses clear headings and numbered sections for readability, like:
1. New Tables
2. Security
3. Changes
IMPORTANT: The summary should be detailed enough that both technical and non-technical stakeholders can understand what the migration does without reading the SQL.
- Include all necessary operations (e.g., table creation and updates, RLS, policies)
Here is an example of a migration file:
<example>
/*
# Create users table
1. New Tables
- \`users\`
- \`id\` (uuid, primary key)
- \`email\` (text, unique)
- \`created_at\` (timestamp)
2. Security
- Enable RLS on \`users\` table
- Add policy for authenticated users to read their own data
*/
CREATE TABLE IF NOT EXISTS users (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
email text UNIQUE NOT NULL,
created_at timestamptz DEFAULT now()
);
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can read own data"
ON users
FOR SELECT
TO authenticated
USING (auth.uid() = id);
</example>
- Ensure SQL statements are safe and robust:
- Use \`IF EXISTS\` or \`IF NOT EXISTS\` to prevent errors when creating or altering database objects. Here are examples:
<example>
CREATE TABLE IF NOT EXISTS users (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
email text UNIQUE NOT NULL,
created_at timestamptz DEFAULT now()
);
</example>
<example>
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'users' AND column_name = 'last_login'
) THEN
ALTER TABLE users ADD COLUMN last_login timestamptz;
END IF;
END $$;
</example>
Client Setup:
- Use \`@supabase/supabase-js\`
- Create a singleton client instance
- Use the environment variables from the project's \`.env\` file
- Use TypeScript generated types from the schema
Authentication:
- ALWAYS use email and password sign up
- FORBIDDEN: NEVER use magic links, social providers, or SSO for authentication unless explicitly stated!
- FORBIDDEN: NEVER create your own authentication system or authentication table, ALWAYS use Supabase's built-in authentication!
- Email confirmation is ALWAYS disabled unless explicitly stated!
Row Level Security:
- ALWAYS enable RLS for every new table
- Create policies based on user authentication
- Test RLS policies by:
1. Verifying authenticated users can only access their allowed data
2. Confirming unauthenticated users cannot access protected data
3. Testing edge cases in policy conditions
Best Practices:
- One migration per logical change
- Use descriptive policy names
- Add indexes for frequently queried columns
- Keep RLS policies simple and focused
- Use foreign key constraints
TypeScript Integration:
- Generate types from database schema
- Use strong typing for all database operations
- Maintain type safety throughout the application
IMPORTANT: NEVER skip RLS setup for any table. Security is non-negotiable!
</database_instructions>
<code_formatting_info>
Use 2 spaces for code indentation
</code_formatting_info>

View File

@ -0,0 +1,111 @@
import { useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import { useStore } from '@nanostores/react';
import { logStore } from '~/lib/stores/logs';
import { supabaseConnection, isConnecting, isFetchingStats, updateSupabaseConnection } from '~/lib/stores/supabase';
export function useSupabaseConnection() {
const connection = useStore(supabaseConnection);
const connecting = useStore(isConnecting);
const fetchingStats = useStore(isFetchingStats);
const [isProjectsExpanded, setIsProjectsExpanded] = useState(false);
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
useEffect(() => {
const savedConnection = localStorage.getItem('supabase_connection');
if (savedConnection) {
const parsed = JSON.parse(savedConnection);
updateSupabaseConnection(parsed);
}
}, []);
const handleConnect = async () => {
isConnecting.set(true);
try {
const cleanToken = connection.token.trim();
const response = await fetch('/api/supabase', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
token: cleanToken,
}),
});
const data = (await response.json()) as any;
if (!response.ok) {
throw new Error(data.error || 'Failed to connect');
}
updateSupabaseConnection({
user: data.user,
token: connection.token,
stats: data.stats,
});
toast.success('Successfully connected to Supabase');
// Keep the dialog open and expand the projects section
setIsProjectsExpanded(true);
return true;
} catch (error) {
console.error('Connection error:', error);
logStore.logError('Failed to authenticate with Supabase', { error });
toast.error(error instanceof Error ? error.message : 'Failed to connect to Supabase');
updateSupabaseConnection({ user: null, token: '' });
return false;
} finally {
isConnecting.set(false);
}
};
const handleDisconnect = () => {
updateSupabaseConnection({ user: null, token: '' });
toast.success('Disconnected from Supabase');
setIsDropdownOpen(false);
};
const selectProject = (projectId: string) => {
const currentState = supabaseConnection.get();
let projectData = undefined;
if (projectId && currentState.stats?.projects) {
projectData = currentState.stats.projects.find((project) => project.id === projectId);
}
updateSupabaseConnection({
selectedProjectId: projectId,
project: projectData,
});
toast.success('Project selected successfully');
setIsDropdownOpen(false);
};
const handleCreateProject = async () => {
window.open('https://app.supabase.com/new/new-project', '_blank');
};
return {
connection,
connecting,
fetchingStats,
isProjectsExpanded,
setIsProjectsExpanded,
isDropdownOpen,
setIsDropdownOpen,
handleConnect,
handleDisconnect,
selectProject,
handleCreateProject,
updateToken: (token: string) => updateSupabaseConnection({ ...connection, token }),
isConnected: !!(connection.user && connection.token),
};
}

View File

@ -1,7 +1,7 @@
import type { WebContainer } from '@webcontainer/api';
import { path as nodePath } from '~/utils/path';
import { atom, map, type MapStore } from 'nanostores';
import type { ActionAlert, BoltAction, FileHistory } from '~/types/actions';
import type { ActionAlert, BoltAction, FileHistory, SupabaseAction, SupabaseAlert } from '~/types/actions';
import { createScopedLogger } from '~/utils/logger';
import { unreachable } from '~/utils/unreachable';
import type { ActionCallbackData } from './message-parser';
@ -70,16 +70,19 @@ export class ActionRunner {
runnerId = atom<string>(`${Date.now()}`);
actions: ActionsMap = map({});
onAlert?: (alert: ActionAlert) => void;
onSupabaseAlert?: (alert: SupabaseAlert) => void;
buildOutput?: { path: string; exitCode: number; output: string };
constructor(
webcontainerPromise: Promise<WebContainer>,
getShellTerminal: () => BoltShell,
onAlert?: (alert: ActionAlert) => void,
onSupabaseAlert?: (alert: SupabaseAlert) => void,
) {
this.#webcontainer = webcontainerPromise;
this.#shellTerminal = getShellTerminal;
this.onAlert = onAlert;
this.onSupabaseAlert = onSupabaseAlert;
}
addAction(data: ActionCallbackData) {
@ -157,6 +160,21 @@ export class ActionRunner {
await this.#runFileAction(action);
break;
}
case 'supabase': {
try {
await this.handleSupabaseAction(action as SupabaseAction);
} catch (error: any) {
// Update action status
this.#updateAction(actionId, {
status: 'failed',
error: error instanceof Error ? error.message : 'Supabase action failed',
});
// Return early without re-throwing
return;
}
break;
}
case 'build': {
const buildOutput = await this.#runBuildAction(action);
@ -377,4 +395,50 @@ export class ActionRunner {
output,
};
}
async handleSupabaseAction(action: SupabaseAction) {
const { operation, content, filePath } = action;
logger.debug('[Supabase Action]:', { operation, filePath, content });
switch (operation) {
case 'migration':
if (!filePath) {
throw new Error('Migration requires a filePath');
}
// Show alert for migration action
this.onSupabaseAlert?.({
type: 'info',
title: 'Supabase Migration',
description: `Create migration file: ${filePath}`,
content,
source: 'supabase',
});
// Only create the migration file
await this.#runFileAction({
type: 'file',
filePath,
content,
changeSource: 'supabase',
} as any);
return { success: true };
case 'query': {
// Always show the alert and let the SupabaseAlert component handle connection state
this.onSupabaseAlert?.({
type: 'info',
title: 'Supabase Query',
description: 'Execute database query',
content,
source: 'supabase',
});
// The actual execution will be triggered from SupabaseChatAlert
return { pending: true };
}
default:
throw new Error(`Unknown operation: ${operation}`);
}
}
}

View File

@ -1,4 +1,4 @@
import type { ActionType, BoltAction, BoltActionData, FileAction, ShellAction } from '~/types/actions';
import type { ActionType, BoltAction, BoltActionData, FileAction, ShellAction, SupabaseAction } from '~/types/actions';
import type { BoltArtifactData } from '~/types/artifact';
import { createScopedLogger } from '~/utils/logger';
import { unreachable } from '~/utils/unreachable';
@ -293,7 +293,27 @@ export class StreamingMessageParser {
content: '',
};
if (actionType === 'file') {
if (actionType === 'supabase') {
const operation = this.#extractAttribute(actionTag, 'operation');
if (!operation || !['migration', 'query'].includes(operation)) {
logger.warn(`Invalid or missing operation for Supabase action: ${operation}`);
throw new Error(`Invalid Supabase operation: ${operation}`);
}
(actionAttributes as SupabaseAction).operation = operation as 'migration' | 'query';
if (operation === 'migration') {
const filePath = this.#extractAttribute(actionTag, 'filePath');
if (!filePath) {
logger.warn('Migration requires a filePath');
throw new Error('Migration requires a filePath');
}
(actionAttributes as SupabaseAction).filePath = filePath;
}
} else if (actionType === 'file') {
const filePath = this.#extractAttribute(actionTag, 'filePath') as string;
if (!filePath) {

130
app/lib/stores/supabase.ts Normal file
View File

@ -0,0 +1,130 @@
import { atom } from 'nanostores';
import type { SupabaseUser, SupabaseStats } from '~/types/supabase';
export interface SupabaseProject {
id: string;
name: string;
region: string;
organization_id: string;
status: string;
database?: {
host: string;
version: string;
postgres_engine: string;
release_channel: string;
};
created_at: string;
}
export interface SupabaseConnectionState {
user: SupabaseUser | null;
token: string;
stats?: SupabaseStats;
selectedProjectId?: string;
isConnected?: boolean;
project?: SupabaseProject; // Add the selected project data
}
// Init from localStorage if available
const savedConnection = typeof localStorage !== 'undefined' ? localStorage.getItem('supabase_connection') : null;
const initialState: SupabaseConnectionState = savedConnection
? JSON.parse(savedConnection)
: {
user: null,
token: '',
stats: undefined,
selectedProjectId: undefined,
isConnected: false,
project: undefined, // Initialize as undefined
};
export const supabaseConnection = atom<SupabaseConnectionState>(initialState);
// After init, fetch stats if we have a token
if (initialState.token && !initialState.stats) {
fetchSupabaseStats(initialState.token).catch(console.error);
}
export const isConnecting = atom(false);
export const isFetchingStats = atom(false);
export function updateSupabaseConnection(connection: Partial<SupabaseConnectionState>) {
const currentState = supabaseConnection.get();
// Set isConnected based on user presence AND token
if (connection.user !== undefined || connection.token !== undefined) {
const newUser = connection.user !== undefined ? connection.user : currentState.user;
const newToken = connection.token !== undefined ? connection.token : currentState.token;
connection.isConnected = !!(newUser && newToken);
}
// Update the project data when selectedProjectId changes
if (connection.selectedProjectId !== undefined) {
if (connection.selectedProjectId && currentState.stats?.projects) {
const selectedProject = currentState.stats.projects.find(
(project) => project.id === connection.selectedProjectId,
);
if (selectedProject) {
connection.project = selectedProject;
} else {
// If project not found in stats but ID is provided, set a minimal project object
connection.project = {
id: connection.selectedProjectId,
name: `Project ${connection.selectedProjectId.substring(0, 8)}...`,
region: 'unknown',
organization_id: '',
status: 'active',
created_at: new Date().toISOString(),
};
}
} else if (connection.selectedProjectId === '') {
// Clear the project when selectedProjectId is empty
connection.project = undefined;
}
}
const newState = { ...currentState, ...connection };
supabaseConnection.set(newState);
/*
* Always save the connection state to localStorage to persist across chats
* Always save the connection state to localStorage to persist across chats
*/
if (connection.user || connection.token || connection.selectedProjectId !== undefined) {
localStorage.setItem('supabase_connection', JSON.stringify(newState));
} else {
localStorage.removeItem('supabase_connection');
}
}
export async function fetchSupabaseStats(token: string) {
isFetchingStats.set(true);
try {
const response = await fetch('https://api.supabase.com/v1/projects', {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
throw new Error('Failed to fetch projects');
}
const projects = (await response.json()) as any;
updateSupabaseConnection({
stats: {
projects,
totalProjects: projects.length,
},
});
} catch (error) {
console.error('Failed to fetch Supabase stats:', error);
throw error;
} finally {
isFetchingStats.set(false);
}
}

View File

@ -17,7 +17,7 @@ import { extractRelativePath } from '~/utils/diff';
import { description } from '~/lib/persistence';
import Cookies from 'js-cookie';
import { createSampler } from '~/utils/sampler';
import type { ActionAlert } from '~/types/actions';
import type { ActionAlert, SupabaseAlert } from '~/types/actions';
const { saveAs } = fileSaver;
@ -50,6 +50,8 @@ export class WorkbenchStore {
unsavedFiles: WritableAtom<Set<string>> = import.meta.hot?.data.unsavedFiles ?? atom(new Set<string>());
actionAlert: WritableAtom<ActionAlert | undefined> =
import.meta.hot?.data.unsavedFiles ?? atom<ActionAlert | undefined>(undefined);
supabaseAlert: WritableAtom<SupabaseAlert | undefined> =
import.meta.hot?.data.unsavedFiles ?? atom<ActionAlert | undefined>(undefined);
modifiedFiles = new Set<string>();
artifactIdList: string[] = [];
#globalExecutionQueue = Promise.resolve();
@ -60,6 +62,7 @@ export class WorkbenchStore {
import.meta.hot.data.showWorkbench = this.showWorkbench;
import.meta.hot.data.currentView = this.currentView;
import.meta.hot.data.actionAlert = this.actionAlert;
import.meta.hot.data.supabaseAlert = this.supabaseAlert;
// Ensure binary files are properly preserved across hot reloads
const filesMap = this.files.get();
@ -114,6 +117,14 @@ export class WorkbenchStore {
this.actionAlert.set(undefined);
}
get SupabaseAlert() {
return this.supabaseAlert;
}
clearSupabaseAlert() {
this.supabaseAlert.set(undefined);
}
toggleTerminal(value?: boolean) {
this.#terminalStore.toggleTerminal(value);
}
@ -405,6 +416,13 @@ export class WorkbenchStore {
this.actionAlert.set(alert);
},
(alert) => {
if (this.#reloadedMessages.has(messageId)) {
return;
}
this.supabaseAlert.set(alert);
},
),
});
}

View File

@ -37,11 +37,15 @@ function parseCookies(cookieHeader: string): Record<string, string> {
}
async function chatAction({ context, request }: ActionFunctionArgs) {
const { messages, files, promptId, contextOptimization } = await request.json<{
const { messages, files, promptId, contextOptimization, supabase } = await request.json<{
messages: Messages;
files: any;
promptId?: string;
contextOptimization: boolean;
supabase?: {
isConnected: boolean;
hasSelectedProject: boolean;
};
}>();
const cookieHeader = request.headers.get('Cookie');
@ -181,6 +185,7 @@ async function chatAction({ context, request }: ActionFunctionArgs) {
// Stream the text
const options: StreamingOptions = {
supabaseConnection: supabase,
toolChoice: 'none',
onFinish: async ({ text: content, finishReason, usage }) => {
logger.debug('usage', JSON.stringify(usage));

View File

@ -0,0 +1,92 @@
import { type ActionFunctionArgs } from '@remix-run/cloudflare';
import { createScopedLogger } from '~/utils/logger';
const logger = createScopedLogger('api.supabase.query');
export async function action({ request }: ActionFunctionArgs) {
if (request.method !== 'POST') {
return new Response('Method not allowed', { status: 405 });
}
const authHeader = request.headers.get('Authorization');
if (!authHeader) {
return new Response('No authorization token provided', { status: 401 });
}
try {
const { projectId, query } = (await request.json()) as any;
logger.debug('Executing query:', { projectId, query });
const response = await fetch(`https://api.supabase.com/v1/projects/${projectId}/database/query`, {
method: 'POST',
headers: {
Authorization: authHeader,
'Content-Type': 'application/json',
},
body: JSON.stringify({ query }),
});
if (!response.ok) {
const errorText = await response.text();
let errorData;
try {
errorData = JSON.parse(errorText);
} catch (e) {
console.log(e);
errorData = { message: errorText };
}
logger.error(
'Supabase API error:',
JSON.stringify({
status: response.status,
statusText: response.statusText,
error: errorData,
}),
);
return new Response(
JSON.stringify({
error: {
status: response.status,
statusText: response.statusText,
message: errorData.message || errorData.error || errorText,
details: errorData,
},
}),
{
status: response.status,
headers: {
'Content-Type': 'application/json',
},
},
);
}
const result = await response.json();
return new Response(JSON.stringify(result), {
headers: {
'Content-Type': 'application/json',
},
});
} catch (error) {
logger.error('Query execution error:', error);
return new Response(
JSON.stringify({
error: {
message: error instanceof Error ? error.message : 'Query execution failed',
stack: error instanceof Error ? error.stack : undefined,
},
}),
{
status: 500,
headers: {
'Content-Type': 'application/json',
},
},
);
}
}

View File

@ -0,0 +1,67 @@
import { json } from '@remix-run/node';
import type { ActionFunction } from '@remix-run/node';
import type { SupabaseProject } from '~/types/supabase';
export const action: ActionFunction = async ({ request }) => {
if (request.method !== 'POST') {
return json({ error: 'Method not allowed' }, { status: 405 });
}
// Inside the action function
try {
const { token } = (await request.json()) as any;
const projectsResponse = await fetch('https://api.supabase.com/v1/projects', {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
if (!projectsResponse.ok) {
const errorText = await projectsResponse.text();
console.error('Projects fetch failed:', errorText);
return json({ error: 'Failed to fetch projects' }, { status: 401 });
}
const projects = (await projectsResponse.json()) as SupabaseProject[];
// Create a Map to store unique projects by ID
const uniqueProjectsMap = new Map<string, SupabaseProject>();
// Only keep the latest version of each project
for (const project of projects) {
if (!uniqueProjectsMap.has(project.id)) {
uniqueProjectsMap.set(project.id, project);
}
}
// Debug log to see unique projects
console.log(
'Unique projects:',
Array.from(uniqueProjectsMap.values()).map((p) => ({ id: p.id, name: p.name })),
);
const uniqueProjects = Array.from(uniqueProjectsMap.values());
// Sort projects by creation date (newest first)
uniqueProjects.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
return json({
user: { email: 'Connected', role: 'Admin' },
stats: {
projects: uniqueProjects,
totalProjects: uniqueProjects.length,
},
});
} catch (error) {
console.error('Supabase API error:', error);
return json(
{
error: error instanceof Error ? error.message : 'Authentication failed',
},
{ status: 401 },
);
}
};

View File

@ -1,6 +1,6 @@
import type { Change } from 'diff';
export type ActionType = 'file' | 'shell';
export type ActionType = 'file' | 'shell' | 'supabase';
export interface BaseAction {
content: string;
@ -23,7 +23,14 @@ export interface BuildAction extends BaseAction {
type: 'build';
}
export type BoltAction = FileAction | ShellAction | StartAction | BuildAction;
export interface SupabaseAction extends BaseAction {
type: 'supabase';
operation: 'migration' | 'query';
filePath?: string;
projectId?: string;
}
export type BoltAction = FileAction | ShellAction | StartAction | BuildAction | SupabaseAction;
export type BoltActionData = BoltAction | BaseAction;
@ -35,6 +42,14 @@ export interface ActionAlert {
source?: 'terminal' | 'preview'; // Add source to differentiate between terminal and preview errors
}
export interface SupabaseAlert {
type: string;
title: string;
description: string;
content: string;
source?: 'supabase';
}
export interface FileHistory {
originalContent: string;
lastModified: number;

21
app/types/supabase.ts Normal file
View File

@ -0,0 +1,21 @@
export interface SupabaseUser {
id: string;
email: string;
role: string;
created_at: string;
last_sign_in_at: string;
}
export interface SupabaseProject {
id: string;
name: string;
organization_id: string;
region: string;
created_at: string;
status: string;
}
export interface SupabaseStats {
projects: SupabaseProject[];
totalProjects: number;
}