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();
}
async goToChatTab() {
await this.page.getByRole("link", { name: "Chat" }).click();
}
async goToHubTab() {
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 { 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 (
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300">
Supabase Integration
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Your account is connected to Supabase.
</p>
<div>
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300">
Supabase Integration
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Your account is connected to Supabase.
</p>
</div>
<Button
onClick={handleDisconnectFromSupabase}
variant="destructive"
size="sm"
disabled={isDisconnecting}
className="flex items-center gap-2"
>
{isDisconnecting ? "Disconnecting..." : "Disconnect from Supabase"}
<DatabaseZap className="h-4 w-4" />
</Button>
</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>
<Button
onClick={handleDisconnectFromSupabase}
variant="destructive"
size="sm"
disabled={isDisconnecting}
className="flex items-center gap-2"
>
{isDisconnecting ? "Disconnecting..." : "Disconnect from Supabase"}
<DatabaseZap className="h-4 w-4" /> {/* Placeholder icon */}
</Button>
</div>
);
}

View File

@@ -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}`,

View File

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

View File

@@ -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(),

View File

@@ -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,

View File

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