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

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