support setting for writing supabase migration files (#427)

This commit is contained in:
Will Chen
2025-06-17 15:14:02 -07:00
committed by GitHub
parent ff4e93d747
commit 382fe9bab5
10 changed files with 206 additions and 20 deletions

View File

@@ -0,0 +1,7 @@
Example SQL
<dyad-execute-sql description="create_users_table">
CREATE TABLE users (id serial primary key);
</dyad-execute-sql>
Done.

View File

@@ -0,0 +1,7 @@
No description!
<dyad-execute-sql>
DROP TABLE users;
</dyad-execute-sql>
Done.

View File

@@ -579,6 +579,10 @@ export class PageObject {
await this.page.getByRole("link", { name: "Apps" }).click(); await this.page.getByRole("link", { name: "Apps" }).click();
} }
async goToChatTab() {
await this.page.getByRole("link", { name: "Chat" }).click();
}
async goToHubTab() { async goToHubTab() {
await this.page.getByRole("link", { name: "Hub" }).click(); await this.page.getByRole("link", { name: "Hub" }).click();
} }

View File

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

View File

@@ -1,5 +1,7 @@
import { useState } from "react"; import { useState } from "react";
import { Button } from "@/components/ui/button"; 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. // 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 { Supabase } from "lucide-react"; // Placeholder
import { DatabaseZap } from "lucide-react"; // Using DatabaseZap as a 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 // Clear the entire supabase object in settings
const result = await updateSettings({ const result = await updateSettings({
supabase: undefined, supabase: undefined,
// Also disable the migration setting on disconnect
enableSupabaseWriteSqlMigration: false,
}); });
if (result) { if (result) {
showSuccess("Successfully disconnected from Supabase"); 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 // Check if there's any Supabase accessToken to determine connection status
const isConnected = !!settings?.supabase?.accessToken; const isConnected = !!settings?.supabase?.accessToken;
@@ -39,6 +54,7 @@ export function SupabaseIntegration() {
} }
return ( return (
<div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300"> <h3 className="text-sm font-medium text-gray-700 dark:text-gray-300">
@@ -48,7 +64,6 @@ export function SupabaseIntegration() {
Your account is connected to Supabase. Your account is connected to Supabase.
</p> </p>
</div> </div>
<Button <Button
onClick={handleDisconnectFromSupabase} onClick={handleDisconnectFromSupabase}
variant="destructive" variant="destructive"
@@ -57,8 +72,32 @@ export function SupabaseIntegration() {
className="flex items-center gap-2" className="flex items-center gap-2"
> >
{isDisconnecting ? "Disconnecting..." : "Disconnect from Supabase"} {isDisconnecting ? "Disconnecting..." : "Disconnect from Supabase"}
<DatabaseZap className="h-4 w-4" /> {/* Placeholder icon */} <DatabaseZap className="h-4 w-4" />
</Button> </Button>
</div> </div>
<div className="mt-4">
<div className="flex items-center space-x-3">
<Switch
id="supabase-migrations"
checked={!!settings?.enableSupabaseWriteSqlMigration}
onCheckedChange={handleMigrationSettingChange}
/>
<div className="space-y-1">
<Label
htmlFor="supabase-migrations"
className="text-sm font-medium"
>
Write SQL migration files
</Label>
<p className="text-xs text-gray-500 dark:text-gray-400">
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.
</p>
</div>
</div>
</div>
</div>
); );
} }

View File

@@ -14,8 +14,10 @@ import {
executeSupabaseSql, executeSupabaseSql,
} from "../../supabase_admin/supabase_management_client"; } from "../../supabase_admin/supabase_management_client";
import { isServerFunction } from "../../supabase_admin/supabase_utils"; 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 { gitCommit } from "../utils/git_utils";
import { readSettings } from "@/main/settings";
import { writeMigrationFile } from "../utils/file_utils";
const readFile = fs.promises.readFile; const readFile = fs.promises.readFile;
const logger = log.scope("response_processor"); const logger = log.scope("response_processor");
@@ -207,6 +209,7 @@ export async function processFullResponseActions(
return {}; return {};
} }
const settings: UserSettings = readSettings();
const appPath = getDyadAppPath(chatWithApp.app.path); const appPath = getDyadAppPath(chatWithApp.app.path);
const writtenFiles: string[] = []; const writtenFiles: string[] = [];
const renamedFiles: string[] = []; const renamedFiles: string[] = [];
@@ -247,6 +250,22 @@ export async function processFullResponseActions(
supabaseProjectId: chatWithApp.app.supabaseProjectId!, supabaseProjectId: chatWithApp.app.supabaseProjectId!,
query: query.content, 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) { } catch (error) {
errors.push({ errors.push({
message: `Failed to execute SQL query: ${query.content}`, message: `Failed to execute SQL query: ${query.content}`,

View File

@@ -1,6 +1,8 @@
import fs from "node:fs"; import fs from "node:fs";
import { promises as fsPromises } from "node:fs"; import { promises as fsPromises } from "node:fs";
import path from "node:path"; 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 * 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);
}

View File

@@ -142,6 +142,7 @@ export const UserSettingsSchema = z.object({
enableProLazyEditsMode: z.boolean().optional(), enableProLazyEditsMode: z.boolean().optional(),
enableProSmartFilesContextMode: z.boolean().optional(), enableProSmartFilesContextMode: z.boolean().optional(),
selectedTemplateId: z.string().optional(), selectedTemplateId: z.string().optional(),
enableSupabaseWriteSqlMigration: z.boolean().optional(),
enableNativeGit: z.boolean().optional(), enableNativeGit: z.boolean().optional(),

View File

@@ -1,7 +1,12 @@
import { IS_TEST_BUILD } from "@/ipc/utils/test_utils";
import { getSupabaseClient } from "./supabase_management_client"; import { getSupabaseClient } from "./supabase_management_client";
import { SUPABASE_SCHEMA_QUERY } from "./supabase_schema_query"; import { SUPABASE_SCHEMA_QUERY } from "./supabase_schema_query";
async function getPublishableKey({ projectId }: { projectId: string }) { async function getPublishableKey({ projectId }: { projectId: string }) {
if (IS_TEST_BUILD) {
return "test-publishable-key";
}
const supabase = await getSupabaseClient(); const supabase = await getSupabaseClient();
let keys; let keys;
try { try {
@@ -50,6 +55,10 @@ export async function getSupabaseContext({
}: { }: {
supabaseProjectId: string; supabaseProjectId: string;
}) { }) {
if (IS_TEST_BUILD) {
return "[[TEST_BUILD_SUPABASE_CONTEXT]]";
}
const supabase = await getSupabaseClient(); const supabase = await getSupabaseClient();
const publishableKey = await getPublishableKey({ const publishableKey = await getPublishableKey({
projectId: supabaseProjectId, projectId: supabaseProjectId,

View File

@@ -140,6 +140,10 @@ export async function executeSupabaseSql({
supabaseProjectId: string; supabaseProjectId: string;
query: string; query: string;
}): Promise<string> { }): Promise<string> {
if (IS_TEST_BUILD) {
return "{}";
}
const supabase = await getSupabaseClient(); const supabase = await getSupabaseClient();
const result = await supabase.runQuery(supabaseProjectId, query); const result = await supabase.runQuery(supabaseProjectId, query);
return JSON.stringify(result); return JSON.stringify(result);