diff --git a/src/__tests__/supabase_utils.test.ts b/src/__tests__/supabase_utils.test.ts new file mode 100644 index 0000000..743344b --- /dev/null +++ b/src/__tests__/supabase_utils.test.ts @@ -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)); + }); +}); diff --git a/src/ipc/handlers/app_handlers.ts b/src/ipc/handlers/app_handlers.ts index 599d8dd..afc9304 100644 --- a/src/ipc/handlers/app_handlers.ts +++ b/src/ipc/handlers/app_handlers.ts @@ -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 => { + // 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 {}; diff --git a/src/ipc/processors/response_processor.ts b/src/ipc/processors/response_processor.ts index 789c67b..d5e5434 100644 --- a/src/ipc/processors/response_processor.ts +++ b/src/ipc/processors/response_processor.ts @@ -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 { - // 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 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 || diff --git a/src/prompts/supabase_prompt.ts b/src/prompts/supabase_prompt.ts index a3bec82..a002842 100644 --- a/src/prompts/supabase_prompt.ts +++ b/src/prompts/supabase_prompt.ts @@ -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 tags to make changes to edge functions. - The function will be deployed automatically when the user approves the 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. diff --git a/src/supabase_admin/supabase_management_client.ts b/src/supabase_admin/supabase_management_client.ts index de86dc5..71abe58 100644 --- a/src/supabase_admin/supabase_management_client.ts +++ b/src/supabase_admin/supabase_management_client.ts @@ -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(); + /** * 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 { 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 { + 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 { + 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 { + 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 { + 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); diff --git a/src/supabase_admin/supabase_utils.ts b/src/supabase_admin/supabase_utils.ts index 9f0fd7c..3c02584 100644 --- a/src/supabase_admin/supabase_utils.ts +++ b/src/supabase_admin/supabase_utils.ts @@ -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}`);