Support shared modules for supabase edge functions (#1964)
Credit: thanks @SlayTheDragons whose PR https://github.com/dyad-sh/dyad/pull/1665 paved the way for this implementation. <!-- CURSOR_SUMMARY --> > [!NOTE] > Adds _shared module support for Supabase edge functions with import-map packaging and automatic redeploys; updates deployment to include full function directories plus shared files, and adds path utilities and tests. > > - **Supabase Edge Functions** > - **Shared Modules Support**: Detect `_shared` changes and redeploy all functions; regular function changes deploy only that function. > - **Deployment Overhaul**: `deploySupabaseFunctions` now uploads full function directories plus `_shared` files via multipart form-data, sets `entrypoint_path`, and writes `import_map.json` (`_shared/` → `../_shared/`). > - **Function Discovery & Packaging**: Add file collection helpers (`listFilesWithStats`, `loadZipEntries`) and path utilities (`toPosixPath`, `findFunctionDirectory`, `stripSupabaseFunctionsPrefix`) with signature-based caching for `_shared`. > - **APIs & Utils**: Introduce `isSharedServerModule`, refine `isServerFunction` (excludes `_shared`), add `extractFunctionNameFromPath`, and `buildSignature`. > - **IPC Changes** > - Update file edit/rename/delete flows to track shared module edits and trigger full redeploys; otherwise deploy per-function using extracted name and `appPath`. > - **Prompts** > - Document `_shared` usage and import pattern in Supabase prompt. > - **Tests** > - Add tests for function/shared detection, name extraction, path handling, and signature building. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit f35599ec0e708e2ef6b7e78ae7901b29953a6dff. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Adds support for shared modules for Supabase edge functions. Shared code in supabase/functions/_shared is now bundled via an import map and triggers redeploys across all functions when changed. - **New Features** - Detects shared modules in supabase/functions/_shared and redeploys all functions when they change. - Deploys full function directories plus shared files, and writes an import_map.json that resolves "_shared/" imports. - Auto-deploys only the affected function on file changes; switches to redeploy-all when a shared module is touched. - **Refactors** - deploySupabaseFunction now uploads multiple files (function + shared) using multipart form-data and sets entrypoint/import map. - Added file collection, path utilities, and shared-file caching via content signatures to reduce redundant reads. - Updated deployAllSupabaseFunctions to skip non-function dirs (e.g., _shared) and use functionPath. - Added tests for function/shared detection, path handling, and signature building. <sup>Written for commit 302d84625d9e61477db9ada052a027b29ff18cef. Summary will update automatically on new commits.</sup> <!-- End of auto-generated description by cubic. -->
This commit is contained in:
352
src/__tests__/supabase_utils.test.ts
Normal file
352
src/__tests__/supabase_utils.test.ts
Normal file
@@ -0,0 +1,352 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
isServerFunction,
|
||||
isSharedServerModule,
|
||||
extractFunctionNameFromPath,
|
||||
} from "@/supabase_admin/supabase_utils";
|
||||
import {
|
||||
toPosixPath,
|
||||
stripSupabaseFunctionsPrefix,
|
||||
buildSignature,
|
||||
type FileStatEntry,
|
||||
} from "@/supabase_admin/supabase_management_client";
|
||||
|
||||
describe("isServerFunction", () => {
|
||||
describe("returns true for valid function paths", () => {
|
||||
it("should return true for function index.ts", () => {
|
||||
expect(isServerFunction("supabase/functions/hello/index.ts")).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true for nested function files", () => {
|
||||
expect(isServerFunction("supabase/functions/hello/lib/utils.ts")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("should return true for function with complex name", () => {
|
||||
expect(isServerFunction("supabase/functions/send-email/index.ts")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("returns false for non-function paths", () => {
|
||||
it("should return false for shared modules", () => {
|
||||
expect(isServerFunction("supabase/functions/_shared/utils.ts")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("should return false for regular source files", () => {
|
||||
expect(isServerFunction("src/components/Button.tsx")).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for root supabase files", () => {
|
||||
expect(isServerFunction("supabase/config.toml")).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for non-supabase paths", () => {
|
||||
expect(isServerFunction("package.json")).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSharedServerModule", () => {
|
||||
describe("returns true for _shared paths", () => {
|
||||
it("should return true for files in _shared", () => {
|
||||
expect(isSharedServerModule("supabase/functions/_shared/utils.ts")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("should return true for nested _shared files", () => {
|
||||
expect(
|
||||
isSharedServerModule("supabase/functions/_shared/lib/helpers.ts"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true for _shared directory itself", () => {
|
||||
expect(isSharedServerModule("supabase/functions/_shared/")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("returns false for non-_shared paths", () => {
|
||||
it("should return false for regular functions", () => {
|
||||
expect(isSharedServerModule("supabase/functions/hello/index.ts")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("should return false for similar but different paths", () => {
|
||||
expect(isSharedServerModule("supabase/functions/shared/utils.ts")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("should return false for _shared in wrong location", () => {
|
||||
expect(isSharedServerModule("src/_shared/utils.ts")).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractFunctionNameFromPath", () => {
|
||||
describe("extracts function name correctly from nested paths", () => {
|
||||
it("should extract function name from index.ts path", () => {
|
||||
expect(
|
||||
extractFunctionNameFromPath("supabase/functions/hello/index.ts"),
|
||||
).toBe("hello");
|
||||
});
|
||||
|
||||
it("should extract function name from deeply nested path", () => {
|
||||
expect(
|
||||
extractFunctionNameFromPath("supabase/functions/hello/lib/utils.ts"),
|
||||
).toBe("hello");
|
||||
});
|
||||
|
||||
it("should extract function name from very deeply nested path", () => {
|
||||
expect(
|
||||
extractFunctionNameFromPath(
|
||||
"supabase/functions/hello/src/helpers/format.ts",
|
||||
),
|
||||
).toBe("hello");
|
||||
});
|
||||
|
||||
it("should extract function name with dashes", () => {
|
||||
expect(
|
||||
extractFunctionNameFromPath("supabase/functions/send-email/index.ts"),
|
||||
).toBe("send-email");
|
||||
});
|
||||
|
||||
it("should extract function name with underscores", () => {
|
||||
expect(
|
||||
extractFunctionNameFromPath("supabase/functions/my_function/index.ts"),
|
||||
).toBe("my_function");
|
||||
});
|
||||
});
|
||||
|
||||
describe("throws for invalid paths", () => {
|
||||
it("should throw for _shared paths", () => {
|
||||
expect(() =>
|
||||
extractFunctionNameFromPath("supabase/functions/_shared/utils.ts"),
|
||||
).toThrow(/Function names starting with "_" are reserved/);
|
||||
});
|
||||
|
||||
it("should throw for other _ prefixed directories", () => {
|
||||
expect(() =>
|
||||
extractFunctionNameFromPath("supabase/functions/_internal/utils.ts"),
|
||||
).toThrow(/Function names starting with "_" are reserved/);
|
||||
});
|
||||
|
||||
it("should throw for non-supabase paths", () => {
|
||||
expect(() =>
|
||||
extractFunctionNameFromPath("src/components/Button.tsx"),
|
||||
).toThrow(/Invalid Supabase function path/);
|
||||
});
|
||||
|
||||
it("should throw for supabase root files", () => {
|
||||
expect(() => extractFunctionNameFromPath("supabase/config.toml")).toThrow(
|
||||
/Invalid Supabase function path/,
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw for partial matches", () => {
|
||||
expect(() => extractFunctionNameFromPath("supabase/functions")).toThrow(
|
||||
/Invalid Supabase function path/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handles edge cases", () => {
|
||||
it("should handle backslashes (Windows paths)", () => {
|
||||
expect(
|
||||
extractFunctionNameFromPath(
|
||||
"supabase\\functions\\hello\\lib\\utils.ts",
|
||||
),
|
||||
).toBe("hello");
|
||||
});
|
||||
|
||||
it("should handle mixed slashes", () => {
|
||||
expect(
|
||||
extractFunctionNameFromPath("supabase/functions\\hello/lib\\utils.ts"),
|
||||
).toBe("hello");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("toPosixPath", () => {
|
||||
it("should keep forward slashes unchanged", () => {
|
||||
expect(toPosixPath("supabase/functions/hello/index.ts")).toBe(
|
||||
"supabase/functions/hello/index.ts",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle empty string", () => {
|
||||
expect(toPosixPath("")).toBe("");
|
||||
});
|
||||
|
||||
it("should handle single filename", () => {
|
||||
expect(toPosixPath("index.ts")).toBe("index.ts");
|
||||
});
|
||||
|
||||
// Note: On Unix, path.sep is "/", so backslashes won't be converted
|
||||
// This test is for documentation - actual behavior depends on platform
|
||||
it("should handle path with no separators", () => {
|
||||
expect(toPosixPath("filename")).toBe("filename");
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripSupabaseFunctionsPrefix", () => {
|
||||
describe("strips prefix correctly", () => {
|
||||
it("should strip full prefix from index.ts", () => {
|
||||
expect(
|
||||
stripSupabaseFunctionsPrefix(
|
||||
"supabase/functions/hello/index.ts",
|
||||
"hello",
|
||||
),
|
||||
).toBe("index.ts");
|
||||
});
|
||||
|
||||
it("should strip prefix from nested file", () => {
|
||||
expect(
|
||||
stripSupabaseFunctionsPrefix(
|
||||
"supabase/functions/hello/lib/utils.ts",
|
||||
"hello",
|
||||
),
|
||||
).toBe("lib/utils.ts");
|
||||
});
|
||||
|
||||
it("should handle leading slash", () => {
|
||||
expect(
|
||||
stripSupabaseFunctionsPrefix(
|
||||
"/supabase/functions/hello/index.ts",
|
||||
"hello",
|
||||
),
|
||||
).toBe("index.ts");
|
||||
});
|
||||
});
|
||||
|
||||
describe("handles edge cases", () => {
|
||||
it("should return filename when no prefix match", () => {
|
||||
const result = stripSupabaseFunctionsPrefix("just-a-file.ts", "hello");
|
||||
expect(result).toBe("just-a-file.ts");
|
||||
});
|
||||
|
||||
it("should handle paths without function name", () => {
|
||||
const result = stripSupabaseFunctionsPrefix(
|
||||
"supabase/functions/other/index.ts",
|
||||
"hello",
|
||||
);
|
||||
// Should strip base prefix and return the rest
|
||||
expect(result).toBe("other/index.ts");
|
||||
});
|
||||
|
||||
it("should handle empty relative path after prefix", () => {
|
||||
// When the path is exactly the function directory
|
||||
const result = stripSupabaseFunctionsPrefix(
|
||||
"supabase/functions/hello",
|
||||
"hello",
|
||||
);
|
||||
expect(result).toBe("hello");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSignature", () => {
|
||||
it("should build signature from single entry", () => {
|
||||
const entries: FileStatEntry[] = [
|
||||
{
|
||||
absolutePath: "/app/file.ts",
|
||||
relativePath: "file.ts",
|
||||
mtimeMs: 1000,
|
||||
size: 100,
|
||||
},
|
||||
];
|
||||
const result = buildSignature(entries);
|
||||
expect(result).toBe("file.ts:3e8:64");
|
||||
});
|
||||
|
||||
it("should build signature from multiple entries sorted by relativePath", () => {
|
||||
const entries: FileStatEntry[] = [
|
||||
{
|
||||
absolutePath: "/app/b.ts",
|
||||
relativePath: "b.ts",
|
||||
mtimeMs: 2000,
|
||||
size: 200,
|
||||
},
|
||||
{
|
||||
absolutePath: "/app/a.ts",
|
||||
relativePath: "a.ts",
|
||||
mtimeMs: 1000,
|
||||
size: 100,
|
||||
},
|
||||
];
|
||||
const result = buildSignature(entries);
|
||||
// Should be sorted by relativePath
|
||||
expect(result).toBe("a.ts:3e8:64|b.ts:7d0:c8");
|
||||
});
|
||||
|
||||
it("should return empty string for empty array", () => {
|
||||
const result = buildSignature([]);
|
||||
expect(result).toBe("");
|
||||
});
|
||||
|
||||
it("should produce different signatures for different mtimes", () => {
|
||||
const entries1: FileStatEntry[] = [
|
||||
{
|
||||
absolutePath: "/app/file.ts",
|
||||
relativePath: "file.ts",
|
||||
mtimeMs: 1000,
|
||||
size: 100,
|
||||
},
|
||||
];
|
||||
const entries2: FileStatEntry[] = [
|
||||
{
|
||||
absolutePath: "/app/file.ts",
|
||||
relativePath: "file.ts",
|
||||
mtimeMs: 2000,
|
||||
size: 100,
|
||||
},
|
||||
];
|
||||
expect(buildSignature(entries1)).not.toBe(buildSignature(entries2));
|
||||
});
|
||||
|
||||
it("should produce different signatures for different sizes", () => {
|
||||
const entries1: FileStatEntry[] = [
|
||||
{
|
||||
absolutePath: "/app/file.ts",
|
||||
relativePath: "file.ts",
|
||||
mtimeMs: 1000,
|
||||
size: 100,
|
||||
},
|
||||
];
|
||||
const entries2: FileStatEntry[] = [
|
||||
{
|
||||
absolutePath: "/app/file.ts",
|
||||
relativePath: "file.ts",
|
||||
mtimeMs: 1000,
|
||||
size: 200,
|
||||
},
|
||||
];
|
||||
expect(buildSignature(entries1)).not.toBe(buildSignature(entries2));
|
||||
});
|
||||
|
||||
it("should include path in signature for cache invalidation", () => {
|
||||
const entries1: FileStatEntry[] = [
|
||||
{
|
||||
absolutePath: "/app/a.ts",
|
||||
relativePath: "a.ts",
|
||||
mtimeMs: 1000,
|
||||
size: 100,
|
||||
},
|
||||
];
|
||||
const entries2: FileStatEntry[] = [
|
||||
{
|
||||
absolutePath: "/app/b.ts",
|
||||
relativePath: "b.ts",
|
||||
mtimeMs: 1000,
|
||||
size: 100,
|
||||
},
|
||||
];
|
||||
expect(buildSignature(entries1)).not.toBe(buildSignature(entries2));
|
||||
});
|
||||
});
|
||||
@@ -35,7 +35,7 @@ import killPort from "kill-port";
|
||||
import util from "util";
|
||||
import log from "electron-log";
|
||||
import {
|
||||
deploySupabaseFunctions,
|
||||
deploySupabaseFunction,
|
||||
getSupabaseProjectName,
|
||||
} from "../../supabase_admin/supabase_management_client";
|
||||
import { createLoggedHandler } from "./safe_handle";
|
||||
@@ -52,7 +52,12 @@ import {
|
||||
} from "../utils/git_utils";
|
||||
import { safeSend } from "../utils/safe_sender";
|
||||
import { normalizePath } from "../../../shared/normalizePath";
|
||||
import { isServerFunction } from "@/supabase_admin/supabase_utils";
|
||||
import {
|
||||
isServerFunction,
|
||||
isSharedServerModule,
|
||||
deployAllSupabaseFunctions,
|
||||
extractFunctionNameFromPath,
|
||||
} from "@/supabase_admin/supabase_utils";
|
||||
import { getVercelTeamSlug } from "../utils/vercel_utils";
|
||||
import { storeDbTimestampAtCurrentVersion } from "../utils/neon_timestamp_utils";
|
||||
import { AppSearchResult } from "@/lib/schemas";
|
||||
@@ -997,6 +1002,8 @@ export function registerAppHandlers() {
|
||||
content,
|
||||
}: { appId: number; filePath: string; content: string },
|
||||
): Promise<EditAppFileReturnType> => {
|
||||
// It should already be normalized, but just in case.
|
||||
filePath = normalizePath(filePath);
|
||||
const app = await db.query.apps.findFirst({
|
||||
where: eq(apps.id, appId),
|
||||
});
|
||||
@@ -1051,18 +1058,49 @@ export function registerAppHandlers() {
|
||||
throw new Error(`Failed to write file: ${error.message}`);
|
||||
}
|
||||
|
||||
if (isServerFunction(filePath) && app.supabaseProjectId) {
|
||||
try {
|
||||
await deploySupabaseFunctions({
|
||||
supabaseProjectId: app.supabaseProjectId,
|
||||
functionName: path.basename(path.dirname(filePath)),
|
||||
content: content,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Error deploying Supabase function ${filePath}:`, error);
|
||||
return {
|
||||
warning: `File saved, but failed to deploy Supabase function: ${filePath}: ${error}`,
|
||||
};
|
||||
if (app.supabaseProjectId) {
|
||||
// Check if shared module was modified - redeploy all functions
|
||||
if (isSharedServerModule(filePath)) {
|
||||
try {
|
||||
logger.info(
|
||||
`Shared module ${filePath} modified, redeploying all Supabase functions`,
|
||||
);
|
||||
const deployErrors = await deployAllSupabaseFunctions({
|
||||
appPath,
|
||||
supabaseProjectId: app.supabaseProjectId,
|
||||
});
|
||||
if (deployErrors.length > 0) {
|
||||
return {
|
||||
warning: `File saved, but some Supabase functions failed to deploy: ${deployErrors.join(", ")}`,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Error redeploying Supabase functions after shared module change:`,
|
||||
error,
|
||||
);
|
||||
return {
|
||||
warning: `File saved, but failed to redeploy Supabase functions: ${error}`,
|
||||
};
|
||||
}
|
||||
} else if (isServerFunction(filePath)) {
|
||||
// Regular function file - deploy just this function
|
||||
try {
|
||||
const functionName = extractFunctionNameFromPath(filePath);
|
||||
await deploySupabaseFunction({
|
||||
supabaseProjectId: app.supabaseProjectId,
|
||||
functionName,
|
||||
appPath,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Error deploying Supabase function ${filePath}:`,
|
||||
error,
|
||||
);
|
||||
return {
|
||||
warning: `File saved, but failed to deploy Supabase function: ${filePath}: ${error}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return {};
|
||||
|
||||
@@ -10,10 +10,15 @@ import log from "electron-log";
|
||||
import { executeAddDependency } from "./executeAddDependency";
|
||||
import {
|
||||
deleteSupabaseFunction,
|
||||
deploySupabaseFunctions,
|
||||
deploySupabaseFunction,
|
||||
executeSupabaseSql,
|
||||
} from "../../supabase_admin/supabase_management_client";
|
||||
import { isServerFunction } from "../../supabase_admin/supabase_utils";
|
||||
import {
|
||||
isServerFunction,
|
||||
isSharedServerModule,
|
||||
deployAllSupabaseFunctions,
|
||||
extractFunctionNameFromPath,
|
||||
} from "../../supabase_admin/supabase_utils";
|
||||
import { UserSettings } from "../../lib/schemas";
|
||||
import {
|
||||
gitCommit,
|
||||
@@ -45,18 +50,6 @@ interface Output {
|
||||
error: unknown;
|
||||
}
|
||||
|
||||
function getFunctionNameFromPath(input: string): string {
|
||||
return path.basename(path.extname(input) ? path.dirname(input) : input);
|
||||
}
|
||||
|
||||
async function readFileFromFunctionPath(input: string): Promise<string> {
|
||||
// Sometimes, the path given is a directory, sometimes it's the file itself.
|
||||
if (path.extname(input) === "") {
|
||||
return readFile(path.join(input, "index.ts"), "utf8");
|
||||
}
|
||||
return readFile(input, "utf8");
|
||||
}
|
||||
|
||||
export async function dryRunSearchReplace({
|
||||
fullResponse,
|
||||
appPath,
|
||||
@@ -153,6 +146,8 @@ export async function processFullResponseActions(
|
||||
const renamedFiles: string[] = [];
|
||||
const deletedFiles: string[] = [];
|
||||
let hasChanges = false;
|
||||
// Track if any shared modules were modified
|
||||
let sharedModulesChanged = false;
|
||||
|
||||
const warnings: Output[] = [];
|
||||
const errors: Output[] = [];
|
||||
@@ -258,6 +253,11 @@ export async function processFullResponseActions(
|
||||
for (const filePath of dyadDeletePaths) {
|
||||
const fullFilePath = safeJoin(appPath, filePath);
|
||||
|
||||
// Track if this is a shared module
|
||||
if (isSharedServerModule(filePath)) {
|
||||
sharedModulesChanged = true;
|
||||
}
|
||||
|
||||
// Delete the file if it exists
|
||||
if (fs.existsSync(fullFilePath)) {
|
||||
if (fs.lstatSync(fullFilePath).isDirectory()) {
|
||||
@@ -278,11 +278,12 @@ export async function processFullResponseActions(
|
||||
} else {
|
||||
logger.warn(`File to delete does not exist: ${fullFilePath}`);
|
||||
}
|
||||
// Only delete individual functions, not shared modules
|
||||
if (isServerFunction(filePath)) {
|
||||
try {
|
||||
await deleteSupabaseFunction({
|
||||
supabaseProjectId: chatWithApp.app.supabaseProjectId!,
|
||||
functionName: getFunctionNameFromPath(filePath),
|
||||
functionName: extractFunctionNameFromPath(filePath),
|
||||
});
|
||||
} catch (error) {
|
||||
errors.push({
|
||||
@@ -298,6 +299,11 @@ export async function processFullResponseActions(
|
||||
const fromPath = safeJoin(appPath, tag.from);
|
||||
const toPath = safeJoin(appPath, tag.to);
|
||||
|
||||
// Track if this involves shared modules
|
||||
if (isSharedServerModule(tag.from) || isSharedServerModule(tag.to)) {
|
||||
sharedModulesChanged = true;
|
||||
}
|
||||
|
||||
// Ensure target directory exists
|
||||
const dirPath = path.dirname(toPath);
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
@@ -319,11 +325,12 @@ export async function processFullResponseActions(
|
||||
} else {
|
||||
logger.warn(`Source file for rename does not exist: ${fromPath}`);
|
||||
}
|
||||
// Only handle individual functions, not shared modules
|
||||
if (isServerFunction(tag.from)) {
|
||||
try {
|
||||
await deleteSupabaseFunction({
|
||||
supabaseProjectId: chatWithApp.app.supabaseProjectId!,
|
||||
functionName: getFunctionNameFromPath(tag.from),
|
||||
functionName: extractFunctionNameFromPath(tag.from),
|
||||
});
|
||||
} catch (error) {
|
||||
warnings.push({
|
||||
@@ -332,12 +339,13 @@ export async function processFullResponseActions(
|
||||
});
|
||||
}
|
||||
}
|
||||
if (isServerFunction(tag.to)) {
|
||||
// Deploy renamed function (skip if shared modules changed - will be handled later)
|
||||
if (isServerFunction(tag.to) && !sharedModulesChanged) {
|
||||
try {
|
||||
await deploySupabaseFunctions({
|
||||
await deploySupabaseFunction({
|
||||
supabaseProjectId: chatWithApp.app.supabaseProjectId!,
|
||||
functionName: getFunctionNameFromPath(tag.to),
|
||||
content: await readFileFromFunctionPath(toPath),
|
||||
functionName: extractFunctionNameFromPath(tag.to),
|
||||
appPath,
|
||||
});
|
||||
} catch (error) {
|
||||
errors.push({
|
||||
@@ -353,6 +361,12 @@ export async function processFullResponseActions(
|
||||
for (const tag of dyadSearchReplaceTags) {
|
||||
const filePath = tag.path;
|
||||
const fullFilePath = safeJoin(appPath, filePath);
|
||||
|
||||
// Track if this is a shared module
|
||||
if (isSharedServerModule(filePath)) {
|
||||
sharedModulesChanged = true;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(fullFilePath)) {
|
||||
// Do not show warning to user because we already attempt to do a <dyad-write> tag to fix it.
|
||||
@@ -372,13 +386,13 @@ export async function processFullResponseActions(
|
||||
fs.writeFileSync(fullFilePath, result.content);
|
||||
writtenFiles.push(filePath);
|
||||
|
||||
// If server function, redeploy
|
||||
if (isServerFunction(filePath)) {
|
||||
// If server function (not shared), redeploy (skip if shared modules changed)
|
||||
if (isServerFunction(filePath) && !sharedModulesChanged) {
|
||||
try {
|
||||
await deploySupabaseFunctions({
|
||||
await deploySupabaseFunction({
|
||||
supabaseProjectId: chatWithApp.app.supabaseProjectId!,
|
||||
functionName: path.basename(path.dirname(filePath)),
|
||||
content: result.content,
|
||||
functionName: extractFunctionNameFromPath(filePath),
|
||||
appPath,
|
||||
});
|
||||
} catch (error) {
|
||||
errors.push({
|
||||
@@ -401,6 +415,11 @@ export async function processFullResponseActions(
|
||||
let content: string | Buffer = tag.content;
|
||||
const fullFilePath = safeJoin(appPath, filePath);
|
||||
|
||||
// Track if this is a shared module
|
||||
if (isSharedServerModule(filePath)) {
|
||||
sharedModulesChanged = true;
|
||||
}
|
||||
|
||||
// Check if content (stripped of whitespace) exactly matches a file ID and replace with actual file content
|
||||
if (fileUploadsMap) {
|
||||
const trimmedContent = tag.content.trim();
|
||||
@@ -433,12 +452,17 @@ export async function processFullResponseActions(
|
||||
fs.writeFileSync(fullFilePath, content);
|
||||
logger.log(`Successfully wrote file: ${fullFilePath}`);
|
||||
writtenFiles.push(filePath);
|
||||
if (isServerFunction(filePath) && typeof content === "string") {
|
||||
// Deploy individual function (skip if shared modules changed - will be handled later)
|
||||
if (
|
||||
isServerFunction(filePath) &&
|
||||
typeof content === "string" &&
|
||||
!sharedModulesChanged
|
||||
) {
|
||||
try {
|
||||
await deploySupabaseFunctions({
|
||||
await deploySupabaseFunction({
|
||||
supabaseProjectId: chatWithApp.app.supabaseProjectId!,
|
||||
functionName: path.basename(path.dirname(filePath)),
|
||||
content: content,
|
||||
functionName: extractFunctionNameFromPath(filePath),
|
||||
appPath,
|
||||
});
|
||||
} catch (error) {
|
||||
errors.push({
|
||||
@@ -449,6 +473,34 @@ export async function processFullResponseActions(
|
||||
}
|
||||
}
|
||||
|
||||
// If shared modules changed, redeploy all functions
|
||||
if (sharedModulesChanged && chatWithApp.app.supabaseProjectId) {
|
||||
try {
|
||||
logger.info(
|
||||
"Shared modules changed, redeploying all Supabase functions",
|
||||
);
|
||||
const deployErrors = await deployAllSupabaseFunctions({
|
||||
appPath,
|
||||
supabaseProjectId: chatWithApp.app.supabaseProjectId,
|
||||
});
|
||||
if (deployErrors.length > 0) {
|
||||
for (const err of deployErrors) {
|
||||
errors.push({
|
||||
message:
|
||||
"Failed to deploy Supabase function after shared module change",
|
||||
error: err,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push({
|
||||
message:
|
||||
"Failed to redeploy all Supabase functions after shared module change",
|
||||
error: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// If we have any file changes, commit them all at once
|
||||
hasChanges =
|
||||
writtenFiles.length > 0 ||
|
||||
|
||||
@@ -287,6 +287,7 @@ CREATE TRIGGER on_auth_user_created
|
||||
1. Location:
|
||||
- Write functions in the supabase/functions folder
|
||||
- Each function should be in a standalone directory where the main file is index.ts (e.g., supabase/functions/hello/index.ts)
|
||||
- Reusable utilities belong in the supabase/functions/_shared folder. Import them in your edge functions with relative paths like ../_shared/logger.ts.
|
||||
- Make sure you use <dyad-write> tags to make changes to edge functions.
|
||||
- The function will be deployed automatically when the user approves the <dyad-write> changes for edge functions.
|
||||
- Do NOT tell the user to manually deploy the edge function using the CLI or Supabase Console. It's unhelpful and not needed.
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { withLock } from "../ipc/utils/lock_utils";
|
||||
import { readSettings, writeSettings } from "../main/settings";
|
||||
import {
|
||||
@@ -7,8 +9,42 @@ import {
|
||||
import log from "electron-log";
|
||||
import { IS_TEST_BUILD } from "../ipc/utils/test_utils";
|
||||
|
||||
const fsPromises = fs.promises;
|
||||
|
||||
const logger = log.scope("supabase_management_client");
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Interfaces for file collection and caching
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ZipFileEntry {
|
||||
relativePath: string;
|
||||
content: Buffer;
|
||||
date: Date;
|
||||
}
|
||||
|
||||
export interface FileStatEntry {
|
||||
absolutePath: string;
|
||||
relativePath: string;
|
||||
mtimeMs: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
interface CachedSharedFiles {
|
||||
signature: string;
|
||||
files: ZipFileEntry[];
|
||||
}
|
||||
|
||||
interface FunctionFilesResult {
|
||||
files: ZipFileEntry[];
|
||||
signature: string;
|
||||
entrypointPath: string;
|
||||
cacheKey: string;
|
||||
}
|
||||
|
||||
// Caches for shared files to avoid re-reading unchanged files
|
||||
const sharedFilesCache = new Map<string, CachedSharedFiles>();
|
||||
|
||||
/**
|
||||
* Checks if the Supabase access token is expired or about to expire
|
||||
* Returns true if token needs to be refreshed
|
||||
@@ -223,33 +259,89 @@ export async function listSupabaseBranches({
|
||||
return jsonResponse;
|
||||
}
|
||||
|
||||
export async function deploySupabaseFunctions({
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Deploy Supabase Functions with shared module support
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function deploySupabaseFunction({
|
||||
supabaseProjectId,
|
||||
functionName,
|
||||
content,
|
||||
appPath,
|
||||
}: {
|
||||
supabaseProjectId: string;
|
||||
functionName: string;
|
||||
content: string;
|
||||
appPath: string;
|
||||
}): Promise<void> {
|
||||
logger.info(
|
||||
`Deploying Supabase function: ${functionName} to project: ${supabaseProjectId}`,
|
||||
);
|
||||
|
||||
const functionPath = path.join(
|
||||
appPath,
|
||||
"supabase",
|
||||
"functions",
|
||||
functionName,
|
||||
);
|
||||
|
||||
// 1) Collect function files
|
||||
const functionFiles = await collectFunctionFiles({
|
||||
functionPath,
|
||||
functionName,
|
||||
});
|
||||
|
||||
// 2) Collect shared files (from supabase/functions/_shared/)
|
||||
const sharedFiles = await getSharedFiles(appPath);
|
||||
|
||||
// 3) Combine all files
|
||||
const filesToUpload = [...functionFiles.files, ...sharedFiles.files];
|
||||
|
||||
// 4) Create an import map next to the function entrypoint
|
||||
const entrypointPath = functionFiles.entrypointPath;
|
||||
const entryDir = path.posix.dirname(entrypointPath);
|
||||
const importMapRelPath = path.posix.join(entryDir, "import_map.json");
|
||||
|
||||
const importMapObject = {
|
||||
imports: {
|
||||
// This resolves "_shared/" imports to the _shared directory
|
||||
// From {functionName}/index.ts, ../_shared/ goes up to root then into _shared/
|
||||
"_shared/": "../_shared/",
|
||||
},
|
||||
};
|
||||
|
||||
// Add the import map file into the upload list
|
||||
filesToUpload.push({
|
||||
relativePath: importMapRelPath,
|
||||
content: Buffer.from(JSON.stringify(importMapObject, null, 2)),
|
||||
date: new Date(),
|
||||
});
|
||||
|
||||
// 5) Prepare multipart form-data
|
||||
const supabase = await getSupabaseClient();
|
||||
const formData = new FormData();
|
||||
formData.append(
|
||||
"metadata",
|
||||
JSON.stringify({
|
||||
entrypoint_path: "index.ts",
|
||||
name: functionName,
|
||||
// See: https://github.com/dyad-sh/dyad/issues/1010
|
||||
verify_jwt: false,
|
||||
}),
|
||||
);
|
||||
formData.append("file", new Blob([content]), "index.ts");
|
||||
|
||||
// Metadata: instruct Supabase to use our import map
|
||||
const metadata = {
|
||||
entrypoint_path: entrypointPath,
|
||||
name: functionName,
|
||||
verify_jwt: false,
|
||||
import_map: importMapRelPath,
|
||||
};
|
||||
|
||||
formData.append("metadata", JSON.stringify(metadata));
|
||||
|
||||
// Add all files to form data
|
||||
for (const f of filesToUpload) {
|
||||
const buf: Buffer = f.content;
|
||||
const mime = guessMimeType(f.relativePath);
|
||||
const blob = new Blob([new Uint8Array(buf)], { type: mime });
|
||||
formData.append("file", blob, f.relativePath);
|
||||
}
|
||||
|
||||
// 6) Perform the deploy request
|
||||
const response = await fetch(
|
||||
`https://api.supabase.com/v1/projects/${supabaseProjectId}/functions/deploy?slug=${functionName}`,
|
||||
`https://api.supabase.com/v1/projects/${encodeURIComponent(
|
||||
supabaseProjectId,
|
||||
)}/functions/deploy?slug=${encodeURIComponent(functionName)}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -266,9 +358,211 @@ export async function deploySupabaseFunctions({
|
||||
logger.info(
|
||||
`Deployed Supabase function: ${functionName} to project: ${supabaseProjectId}`,
|
||||
);
|
||||
return response.json();
|
||||
|
||||
await response.json();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// File collection helpers
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function collectFunctionFiles({
|
||||
functionPath,
|
||||
functionName,
|
||||
}: {
|
||||
functionPath: string;
|
||||
functionName: string;
|
||||
}): Promise<FunctionFilesResult> {
|
||||
const normalizedFunctionPath = path.resolve(functionPath);
|
||||
const stats = await fsPromises.stat(normalizedFunctionPath);
|
||||
|
||||
let functionDirectory: string | null = null;
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
functionDirectory = normalizedFunctionPath;
|
||||
}
|
||||
|
||||
if (!functionDirectory) {
|
||||
throw new Error(
|
||||
`Unable to locate directory for Supabase function ${functionName}`,
|
||||
);
|
||||
}
|
||||
|
||||
const indexPath = path.join(functionDirectory, "index.ts");
|
||||
|
||||
try {
|
||||
await fsPromises.access(indexPath);
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Supabase function ${functionName} is missing an index.ts entrypoint`,
|
||||
);
|
||||
}
|
||||
|
||||
// Prefix function files with functionName so the directory structure allows
|
||||
// relative imports like "../_shared/" to resolve correctly
|
||||
const statEntries = await listFilesWithStats(functionDirectory, functionName);
|
||||
const signature = buildSignature(statEntries);
|
||||
const files = await loadZipEntries(statEntries);
|
||||
|
||||
return {
|
||||
files,
|
||||
signature,
|
||||
entrypointPath: path.posix.join(
|
||||
functionName,
|
||||
toPosixPath(path.relative(functionDirectory, indexPath)),
|
||||
),
|
||||
cacheKey: functionDirectory,
|
||||
};
|
||||
}
|
||||
|
||||
async function getSharedFiles(appPath: string): Promise<CachedSharedFiles> {
|
||||
const sharedDirectory = path.join(
|
||||
appPath,
|
||||
"supabase",
|
||||
"functions",
|
||||
"_shared",
|
||||
);
|
||||
|
||||
try {
|
||||
const sharedStats = await fsPromises.stat(sharedDirectory);
|
||||
if (!sharedStats.isDirectory()) {
|
||||
return { signature: "", files: [] };
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error && error.code === "ENOENT") {
|
||||
return { signature: "", files: [] };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const statEntries = await listFilesWithStats(sharedDirectory, "_shared");
|
||||
const signature = buildSignature(statEntries);
|
||||
|
||||
const cached = sharedFilesCache.get(sharedDirectory);
|
||||
if (cached && cached.signature === signature) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const files = await loadZipEntries(statEntries);
|
||||
const result = { signature, files };
|
||||
sharedFilesCache.set(sharedDirectory, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function listFilesWithStats(
|
||||
directory: string,
|
||||
prefix: string,
|
||||
): Promise<FileStatEntry[]> {
|
||||
const dirents = await fsPromises.readdir(directory, { withFileTypes: true });
|
||||
dirents.sort((a, b) => a.name.localeCompare(b.name));
|
||||
const entries: FileStatEntry[] = [];
|
||||
|
||||
for (const dirent of dirents) {
|
||||
const absolutePath = path.join(directory, dirent.name);
|
||||
const relativePath = path.posix.join(prefix, dirent.name);
|
||||
|
||||
if (dirent.isDirectory()) {
|
||||
const nestedEntries = await listFilesWithStats(
|
||||
absolutePath,
|
||||
relativePath,
|
||||
);
|
||||
entries.push(...nestedEntries);
|
||||
} else if (dirent.isFile() || dirent.isSymbolicLink()) {
|
||||
const stat = await fsPromises.stat(absolutePath);
|
||||
entries.push({
|
||||
absolutePath,
|
||||
relativePath,
|
||||
mtimeMs: stat.mtimeMs,
|
||||
size: stat.size,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function buildSignature(entries: FileStatEntry[]): string {
|
||||
return entries
|
||||
.map(
|
||||
(entry) =>
|
||||
`${entry.relativePath}:${entry.mtimeMs.toString(16)}:${entry.size.toString(16)}`,
|
||||
)
|
||||
.sort()
|
||||
.join("|");
|
||||
}
|
||||
|
||||
async function loadZipEntries(
|
||||
entries: FileStatEntry[],
|
||||
): Promise<ZipFileEntry[]> {
|
||||
const files: ZipFileEntry[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const content = await fsPromises.readFile(entry.absolutePath);
|
||||
files.push({
|
||||
relativePath: toPosixPath(entry.relativePath),
|
||||
content,
|
||||
date: new Date(entry.mtimeMs),
|
||||
});
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Path helpers (exported for testing)
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function toPosixPath(filePath: string): string {
|
||||
return filePath.split(path.sep).join(path.posix.sep);
|
||||
}
|
||||
|
||||
export function stripSupabaseFunctionsPrefix(
|
||||
relativePath: string,
|
||||
functionName: string,
|
||||
): string {
|
||||
const normalized = toPosixPath(relativePath).replace(/^\//, "");
|
||||
const slugPrefix = `supabase/functions/${functionName}/`;
|
||||
|
||||
if (normalized.startsWith(slugPrefix)) {
|
||||
const remainder = normalized.slice(slugPrefix.length);
|
||||
return remainder || "index.ts";
|
||||
}
|
||||
|
||||
const slugFilePrefix = `supabase/functions/${functionName}`;
|
||||
|
||||
if (normalized.startsWith(slugFilePrefix)) {
|
||||
const remainder = normalized.slice(slugFilePrefix.length);
|
||||
if (remainder.startsWith("/")) {
|
||||
const trimmed = remainder.slice(1);
|
||||
return trimmed || "index.ts";
|
||||
}
|
||||
const combined = `${functionName}${remainder}`;
|
||||
return combined || "index.ts";
|
||||
}
|
||||
|
||||
const basePrefix = "supabase/functions/";
|
||||
if (normalized.startsWith(basePrefix)) {
|
||||
const withoutBase = normalized.slice(basePrefix.length);
|
||||
return withoutBase || path.posix.basename(normalized);
|
||||
}
|
||||
|
||||
return normalized || path.posix.basename(relativePath);
|
||||
}
|
||||
|
||||
function guessMimeType(filePath: string): string {
|
||||
if (filePath.endsWith(".json")) return "application/json";
|
||||
if (filePath.endsWith(".ts")) return "application/typescript";
|
||||
if (filePath.endsWith(".mjs")) return "application/javascript";
|
||||
if (filePath.endsWith(".js")) return "application/javascript";
|
||||
if (filePath.endsWith(".wasm")) return "application/wasm";
|
||||
if (filePath.endsWith(".map")) return "application/json";
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Error handling helpers
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function createResponseError(response: Response, action: string) {
|
||||
const errorBody = await safeParseErrorResponseBody(response);
|
||||
|
||||
|
||||
@@ -1,12 +1,60 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import log from "electron-log";
|
||||
import { deploySupabaseFunctions } from "./supabase_management_client";
|
||||
import { deploySupabaseFunction } from "./supabase_management_client";
|
||||
|
||||
const logger = log.scope("supabase_utils");
|
||||
|
||||
export function isServerFunction(filePath: string) {
|
||||
return filePath.startsWith("supabase/functions/");
|
||||
/**
|
||||
* Checks if a file path is a Supabase edge function
|
||||
* (i.e., inside supabase/functions/ but NOT in _shared/)
|
||||
*/
|
||||
export function isServerFunction(filePath: string): boolean {
|
||||
return (
|
||||
filePath.startsWith("supabase/functions/") &&
|
||||
!filePath.startsWith("supabase/functions/_shared/")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a file path is a shared module in supabase/functions/_shared/
|
||||
*/
|
||||
export function isSharedServerModule(filePath: string): boolean {
|
||||
return filePath.startsWith("supabase/functions/_shared/");
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the function name from a Supabase function file path.
|
||||
* Handles nested paths like "supabase/functions/hello/lib/utils.ts" → "hello"
|
||||
*
|
||||
* @param filePath - A path like "supabase/functions/{functionName}/..."
|
||||
* @returns The function name
|
||||
* @throws Error if the path is not a valid function path
|
||||
*/
|
||||
export function extractFunctionNameFromPath(filePath: string): string {
|
||||
// Normalize path separators to forward slashes
|
||||
const normalized = filePath.replace(/\\/g, "/");
|
||||
|
||||
// Match the pattern: supabase/functions/{functionName}/...
|
||||
// The function name is the segment immediately after "supabase/functions/"
|
||||
const match = normalized.match(/^supabase\/functions\/([^/]+)/);
|
||||
|
||||
if (!match) {
|
||||
throw new Error(
|
||||
`Invalid Supabase function path: ${filePath}. Expected format: supabase/functions/{functionName}/...`,
|
||||
);
|
||||
}
|
||||
|
||||
const functionName = match[1];
|
||||
|
||||
// Exclude _shared and other special directories
|
||||
if (functionName.startsWith("_")) {
|
||||
throw new Error(
|
||||
`Invalid Supabase function path: ${filePath}. Function names starting with "_" are reserved for special directories.`,
|
||||
);
|
||||
}
|
||||
|
||||
return functionName;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -37,7 +85,10 @@ export async function deployAllSupabaseFunctions({
|
||||
try {
|
||||
// Read all directories in supabase/functions
|
||||
const entries = await fs.readdir(functionsDir, { withFileTypes: true });
|
||||
const functionDirs = entries.filter((entry) => entry.isDirectory());
|
||||
// Filter out _shared and other non-function directories
|
||||
const functionDirs = entries.filter(
|
||||
(entry) => entry.isDirectory() && !entry.name.startsWith("_"),
|
||||
);
|
||||
|
||||
logger.info(
|
||||
`Found ${functionDirs.length} functions to deploy in ${functionsDir}`,
|
||||
@@ -46,7 +97,8 @@ export async function deployAllSupabaseFunctions({
|
||||
// Deploy each function
|
||||
for (const functionDir of functionDirs) {
|
||||
const functionName = functionDir.name;
|
||||
const indexPath = path.join(functionsDir, functionName, "index.ts");
|
||||
const functionPath = path.join(functionsDir, functionName);
|
||||
const indexPath = path.join(functionPath, "index.ts");
|
||||
|
||||
// Check if index.ts exists
|
||||
try {
|
||||
@@ -59,13 +111,12 @@ export async function deployAllSupabaseFunctions({
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(indexPath, "utf-8");
|
||||
logger.info(`Deploying function: ${functionName}`);
|
||||
|
||||
await deploySupabaseFunctions({
|
||||
await deploySupabaseFunction({
|
||||
supabaseProjectId,
|
||||
functionName,
|
||||
content,
|
||||
appPath,
|
||||
});
|
||||
|
||||
logger.info(`Successfully deployed function: ${functionName}`);
|
||||
|
||||
Reference in New Issue
Block a user