diff --git a/e2e-tests/fixtures/execute-sql-1.md b/e2e-tests/fixtures/execute-sql-1.md new file mode 100644 index 0000000..82fa7ba --- /dev/null +++ b/e2e-tests/fixtures/execute-sql-1.md @@ -0,0 +1,7 @@ +Example SQL + + +CREATE TABLE users (id serial primary key); + + +Done. diff --git a/e2e-tests/fixtures/execute-sql-no-description.md b/e2e-tests/fixtures/execute-sql-no-description.md new file mode 100644 index 0000000..cc709f2 --- /dev/null +++ b/e2e-tests/fixtures/execute-sql-no-description.md @@ -0,0 +1,7 @@ +No description! + + +DROP TABLE users; + + +Done. diff --git a/e2e-tests/helpers/test_helper.ts b/e2e-tests/helpers/test_helper.ts index ba5b62c..3bc5f83 100644 --- a/e2e-tests/helpers/test_helper.ts +++ b/e2e-tests/helpers/test_helper.ts @@ -579,6 +579,10 @@ export class PageObject { await this.page.getByRole("link", { name: "Apps" }).click(); } + async goToChatTab() { + await this.page.getByRole("link", { name: "Chat" }).click(); + } + async goToHubTab() { await this.page.getByRole("link", { name: "Hub" }).click(); } diff --git a/e2e-tests/supabase_migrations.spec.ts b/e2e-tests/supabase_migrations.spec.ts new file mode 100644 index 0000000..4c9d4f5 --- /dev/null +++ b/e2e-tests/supabase_migrations.spec.ts @@ -0,0 +1,61 @@ +import { expect } from "@playwright/test"; +import { test } from "./helpers/test_helper"; +import fs from "fs-extra"; +import path from "path"; + +test("supabase migrations", async ({ po }) => { + await po.setUp({ autoApprove: true }); + await po.sendPrompt("tc=add-supabase"); + + // Connect to Supabase + await po.page.getByText("Set up supabase").click(); + await po.clickConnectSupabaseButton(); + await po.clickBackButton(); + + const appPath = await po.getCurrentAppPath(); + const migrationsDir = path.join(appPath, "supabase", "migrations"); + + // --- SCENARIO 1: OFF BY DEFAULT --- + await po.sendPrompt("tc=execute-sql-1"); + await po.waitForChatCompletion(); + + expect(fs.existsSync(migrationsDir)).toBe(false); + + // --- SCENARIO 2: TOGGLE ON --- + // Go to settings to find the Supabase integration + await po.goToSettingsTab(); + const migrationsSwitch = po.page.locator("#supabase-migrations"); + await migrationsSwitch.click(); + await po.goToChatTab(); + + // Send a prompt that triggers a migration + await po.sendPrompt("tc=execute-sql-1"); + await po.waitForChatCompletion(); + + let files: string[] = []; + await expect(async () => { + // Check that one migration file was created + files = await fs.readdir(migrationsDir); + expect(files).toHaveLength(1); + }).toPass(); + + expect(files[0]).toMatch(/0000_create_users_table\.sql/); + expect(await fs.readFile(path.join(migrationsDir, files[0]), "utf8")).toEqual( + "CREATE TABLE users (id serial primary key);", + ); + + // Send a prompt that triggers a migration + await po.sendPrompt("tc=execute-sql-no-description"); + await po.waitForChatCompletion(); + + await expect(async () => { + // Check that one migration file was created + files = await fs.readdir(migrationsDir); + expect(files).toHaveLength(2); + }).toPass(); + + expect(files[1]).toMatch(/0001_\w+_\w+_\w+\.sql/); + expect(await fs.readFile(path.join(migrationsDir, files[1]), "utf8")).toEqual( + "DROP TABLE users;", + ); +}); diff --git a/src/components/SupabaseIntegration.tsx b/src/components/SupabaseIntegration.tsx index 4d72921..bef7157 100644 --- a/src/components/SupabaseIntegration.tsx +++ b/src/components/SupabaseIntegration.tsx @@ -1,5 +1,7 @@ import { useState } from "react"; import { Button } from "@/components/ui/button"; +import { Switch } from "@/components/ui/switch"; +import { Label } from "@/components/ui/label"; // We might need a Supabase icon here, but for now, let's use a generic one or text. // import { Supabase } from "lucide-react"; // Placeholder import { DatabaseZap } from "lucide-react"; // Using DatabaseZap as a placeholder @@ -16,6 +18,8 @@ export function SupabaseIntegration() { // Clear the entire supabase object in settings const result = await updateSettings({ supabase: undefined, + // Also disable the migration setting on disconnect + enableSupabaseWriteSqlMigration: false, }); if (result) { showSuccess("Successfully disconnected from Supabase"); @@ -31,6 +35,17 @@ export function SupabaseIntegration() { } }; + const handleMigrationSettingChange = async (enabled: boolean) => { + try { + await updateSettings({ + enableSupabaseWriteSqlMigration: enabled, + }); + showSuccess("Setting updated"); + } catch (err: any) { + showError(err.message || "Failed to update setting"); + } + }; + // Check if there's any Supabase accessToken to determine connection status const isConnected = !!settings?.supabase?.accessToken; @@ -39,26 +54,50 @@ export function SupabaseIntegration() { } return ( -
-
-

- Supabase Integration -

-

- Your account is connected to Supabase. -

+
+
+
+

+ Supabase Integration +

+

+ Your account is connected to Supabase. +

+
+ +
+
+
+ +
+ +

+ Generate SQL migration files when modifying your Supabase schema. + This helps you track database changes in version control, though + these files aren't used for chat context, which uses the live + schema. +

+
+
- -
); } diff --git a/src/ipc/processors/response_processor.ts b/src/ipc/processors/response_processor.ts index 1f6fdfa..e102a82 100644 --- a/src/ipc/processors/response_processor.ts +++ b/src/ipc/processors/response_processor.ts @@ -14,8 +14,10 @@ import { executeSupabaseSql, } from "../../supabase_admin/supabase_management_client"; import { isServerFunction } from "../../supabase_admin/supabase_utils"; -import { SqlQuery } from "../../lib/schemas"; +import { SqlQuery, UserSettings } from "../../lib/schemas"; import { gitCommit } from "../utils/git_utils"; +import { readSettings } from "@/main/settings"; +import { writeMigrationFile } from "../utils/file_utils"; const readFile = fs.promises.readFile; const logger = log.scope("response_processor"); @@ -207,6 +209,7 @@ export async function processFullResponseActions( return {}; } + const settings: UserSettings = readSettings(); const appPath = getDyadAppPath(chatWithApp.app.path); const writtenFiles: string[] = []; const renamedFiles: string[] = []; @@ -247,6 +250,22 @@ export async function processFullResponseActions( supabaseProjectId: chatWithApp.app.supabaseProjectId!, query: query.content, }); + + // Only write migration file if SQL execution succeeded + if (settings.enableSupabaseWriteSqlMigration) { + try { + await writeMigrationFile( + appPath, + query.content, + query.description, + ); + } catch (error) { + errors.push({ + message: `Failed to write SQL migration file for: ${query.description}`, + error: error, + }); + } + } } catch (error) { errors.push({ message: `Failed to execute SQL query: ${query.content}`, diff --git a/src/ipc/utils/file_utils.ts b/src/ipc/utils/file_utils.ts index 8065087..c2c8fd2 100644 --- a/src/ipc/utils/file_utils.ts +++ b/src/ipc/utils/file_utils.ts @@ -1,6 +1,8 @@ import fs from "node:fs"; import { promises as fsPromises } from "node:fs"; import path from "node:path"; +import fsExtra from "fs-extra"; +import { generateCuteAppName } from "../../lib/utils"; /** * Recursively gets all files in a directory, excluding node_modules and .git @@ -57,3 +59,36 @@ export async function copyDirectoryRecursive( } } } + +export async function writeMigrationFile( + appPath: string, + queryContent: string, + queryDescription?: string, +) { + const migrationsDir = path.join(appPath, "supabase", "migrations"); + await fsExtra.ensureDir(migrationsDir); + + const files = await fsExtra.readdir(migrationsDir); + const migrationNumbers = files + .map((file) => { + const match = file.match(/^(\d{4})_/); + return match ? parseInt(match[1], 10) : -1; + }) + .filter((num) => num !== -1); + + const nextMigrationNumber = + migrationNumbers.length > 0 ? Math.max(...migrationNumbers) + 1 : 0; + const paddedNumber = String(nextMigrationNumber).padStart(4, "0"); + + let description = "migration"; + if (queryDescription) { + description = queryDescription.toLowerCase().replace(/[\s\W-]+/g, "_"); + } else { + description = generateCuteAppName().replace(/-/g, "_"); + } + + const migrationFileName = `${paddedNumber}_${description}.sql`; + const migrationFilePath = path.join(migrationsDir, migrationFileName); + + await fsExtra.writeFile(migrationFilePath, queryContent); +} diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index 904c3d5..c679c64 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -142,6 +142,7 @@ export const UserSettingsSchema = z.object({ enableProLazyEditsMode: z.boolean().optional(), enableProSmartFilesContextMode: z.boolean().optional(), selectedTemplateId: z.string().optional(), + enableSupabaseWriteSqlMigration: z.boolean().optional(), enableNativeGit: z.boolean().optional(), diff --git a/src/supabase_admin/supabase_context.ts b/src/supabase_admin/supabase_context.ts index f3a5077..5dcbd99 100644 --- a/src/supabase_admin/supabase_context.ts +++ b/src/supabase_admin/supabase_context.ts @@ -1,7 +1,12 @@ +import { IS_TEST_BUILD } from "@/ipc/utils/test_utils"; import { getSupabaseClient } from "./supabase_management_client"; import { SUPABASE_SCHEMA_QUERY } from "./supabase_schema_query"; async function getPublishableKey({ projectId }: { projectId: string }) { + if (IS_TEST_BUILD) { + return "test-publishable-key"; + } + const supabase = await getSupabaseClient(); let keys; try { @@ -50,6 +55,10 @@ export async function getSupabaseContext({ }: { supabaseProjectId: string; }) { + if (IS_TEST_BUILD) { + return "[[TEST_BUILD_SUPABASE_CONTEXT]]"; + } + const supabase = await getSupabaseClient(); const publishableKey = await getPublishableKey({ projectId: supabaseProjectId, diff --git a/src/supabase_admin/supabase_management_client.ts b/src/supabase_admin/supabase_management_client.ts index 4ee90be..a4bdb52 100644 --- a/src/supabase_admin/supabase_management_client.ts +++ b/src/supabase_admin/supabase_management_client.ts @@ -140,6 +140,10 @@ export async function executeSupabaseSql({ supabaseProjectId: string; query: string; }): Promise { + if (IS_TEST_BUILD) { + return "{}"; + } + const supabase = await getSupabaseClient(); const result = await supabase.runQuery(supabaseProjectId, query); return JSON.stringify(result);