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 util from "util";
|
||||||
import log from "electron-log";
|
import log from "electron-log";
|
||||||
import {
|
import {
|
||||||
deploySupabaseFunctions,
|
deploySupabaseFunction,
|
||||||
getSupabaseProjectName,
|
getSupabaseProjectName,
|
||||||
} from "../../supabase_admin/supabase_management_client";
|
} from "../../supabase_admin/supabase_management_client";
|
||||||
import { createLoggedHandler } from "./safe_handle";
|
import { createLoggedHandler } from "./safe_handle";
|
||||||
@@ -52,7 +52,12 @@ import {
|
|||||||
} from "../utils/git_utils";
|
} from "../utils/git_utils";
|
||||||
import { safeSend } from "../utils/safe_sender";
|
import { safeSend } from "../utils/safe_sender";
|
||||||
import { normalizePath } from "../../../shared/normalizePath";
|
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 { getVercelTeamSlug } from "../utils/vercel_utils";
|
||||||
import { storeDbTimestampAtCurrentVersion } from "../utils/neon_timestamp_utils";
|
import { storeDbTimestampAtCurrentVersion } from "../utils/neon_timestamp_utils";
|
||||||
import { AppSearchResult } from "@/lib/schemas";
|
import { AppSearchResult } from "@/lib/schemas";
|
||||||
@@ -997,6 +1002,8 @@ export function registerAppHandlers() {
|
|||||||
content,
|
content,
|
||||||
}: { appId: number; filePath: string; content: string },
|
}: { appId: number; filePath: string; content: string },
|
||||||
): Promise<EditAppFileReturnType> => {
|
): Promise<EditAppFileReturnType> => {
|
||||||
|
// It should already be normalized, but just in case.
|
||||||
|
filePath = normalizePath(filePath);
|
||||||
const app = await db.query.apps.findFirst({
|
const app = await db.query.apps.findFirst({
|
||||||
where: eq(apps.id, appId),
|
where: eq(apps.id, appId),
|
||||||
});
|
});
|
||||||
@@ -1051,18 +1058,49 @@ export function registerAppHandlers() {
|
|||||||
throw new Error(`Failed to write file: ${error.message}`);
|
throw new Error(`Failed to write file: ${error.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isServerFunction(filePath) && app.supabaseProjectId) {
|
if (app.supabaseProjectId) {
|
||||||
try {
|
// Check if shared module was modified - redeploy all functions
|
||||||
await deploySupabaseFunctions({
|
if (isSharedServerModule(filePath)) {
|
||||||
supabaseProjectId: app.supabaseProjectId,
|
try {
|
||||||
functionName: path.basename(path.dirname(filePath)),
|
logger.info(
|
||||||
content: content,
|
`Shared module ${filePath} modified, redeploying all Supabase functions`,
|
||||||
});
|
);
|
||||||
} catch (error) {
|
const deployErrors = await deployAllSupabaseFunctions({
|
||||||
logger.error(`Error deploying Supabase function ${filePath}:`, error);
|
appPath,
|
||||||
return {
|
supabaseProjectId: app.supabaseProjectId,
|
||||||
warning: `File saved, but failed to deploy Supabase function: ${filePath}: ${error}`,
|
});
|
||||||
};
|
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 {};
|
return {};
|
||||||
|
|||||||
@@ -10,10 +10,15 @@ import log from "electron-log";
|
|||||||
import { executeAddDependency } from "./executeAddDependency";
|
import { executeAddDependency } from "./executeAddDependency";
|
||||||
import {
|
import {
|
||||||
deleteSupabaseFunction,
|
deleteSupabaseFunction,
|
||||||
deploySupabaseFunctions,
|
deploySupabaseFunction,
|
||||||
executeSupabaseSql,
|
executeSupabaseSql,
|
||||||
} from "../../supabase_admin/supabase_management_client";
|
} 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 { UserSettings } from "../../lib/schemas";
|
||||||
import {
|
import {
|
||||||
gitCommit,
|
gitCommit,
|
||||||
@@ -45,18 +50,6 @@ interface Output {
|
|||||||
error: unknown;
|
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({
|
export async function dryRunSearchReplace({
|
||||||
fullResponse,
|
fullResponse,
|
||||||
appPath,
|
appPath,
|
||||||
@@ -153,6 +146,8 @@ export async function processFullResponseActions(
|
|||||||
const renamedFiles: string[] = [];
|
const renamedFiles: string[] = [];
|
||||||
const deletedFiles: string[] = [];
|
const deletedFiles: string[] = [];
|
||||||
let hasChanges = false;
|
let hasChanges = false;
|
||||||
|
// Track if any shared modules were modified
|
||||||
|
let sharedModulesChanged = false;
|
||||||
|
|
||||||
const warnings: Output[] = [];
|
const warnings: Output[] = [];
|
||||||
const errors: Output[] = [];
|
const errors: Output[] = [];
|
||||||
@@ -258,6 +253,11 @@ export async function processFullResponseActions(
|
|||||||
for (const filePath of dyadDeletePaths) {
|
for (const filePath of dyadDeletePaths) {
|
||||||
const fullFilePath = safeJoin(appPath, filePath);
|
const fullFilePath = safeJoin(appPath, filePath);
|
||||||
|
|
||||||
|
// Track if this is a shared module
|
||||||
|
if (isSharedServerModule(filePath)) {
|
||||||
|
sharedModulesChanged = true;
|
||||||
|
}
|
||||||
|
|
||||||
// Delete the file if it exists
|
// Delete the file if it exists
|
||||||
if (fs.existsSync(fullFilePath)) {
|
if (fs.existsSync(fullFilePath)) {
|
||||||
if (fs.lstatSync(fullFilePath).isDirectory()) {
|
if (fs.lstatSync(fullFilePath).isDirectory()) {
|
||||||
@@ -278,11 +278,12 @@ export async function processFullResponseActions(
|
|||||||
} else {
|
} else {
|
||||||
logger.warn(`File to delete does not exist: ${fullFilePath}`);
|
logger.warn(`File to delete does not exist: ${fullFilePath}`);
|
||||||
}
|
}
|
||||||
|
// Only delete individual functions, not shared modules
|
||||||
if (isServerFunction(filePath)) {
|
if (isServerFunction(filePath)) {
|
||||||
try {
|
try {
|
||||||
await deleteSupabaseFunction({
|
await deleteSupabaseFunction({
|
||||||
supabaseProjectId: chatWithApp.app.supabaseProjectId!,
|
supabaseProjectId: chatWithApp.app.supabaseProjectId!,
|
||||||
functionName: getFunctionNameFromPath(filePath),
|
functionName: extractFunctionNameFromPath(filePath),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errors.push({
|
errors.push({
|
||||||
@@ -298,6 +299,11 @@ export async function processFullResponseActions(
|
|||||||
const fromPath = safeJoin(appPath, tag.from);
|
const fromPath = safeJoin(appPath, tag.from);
|
||||||
const toPath = safeJoin(appPath, tag.to);
|
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
|
// Ensure target directory exists
|
||||||
const dirPath = path.dirname(toPath);
|
const dirPath = path.dirname(toPath);
|
||||||
fs.mkdirSync(dirPath, { recursive: true });
|
fs.mkdirSync(dirPath, { recursive: true });
|
||||||
@@ -319,11 +325,12 @@ export async function processFullResponseActions(
|
|||||||
} else {
|
} else {
|
||||||
logger.warn(`Source file for rename does not exist: ${fromPath}`);
|
logger.warn(`Source file for rename does not exist: ${fromPath}`);
|
||||||
}
|
}
|
||||||
|
// Only handle individual functions, not shared modules
|
||||||
if (isServerFunction(tag.from)) {
|
if (isServerFunction(tag.from)) {
|
||||||
try {
|
try {
|
||||||
await deleteSupabaseFunction({
|
await deleteSupabaseFunction({
|
||||||
supabaseProjectId: chatWithApp.app.supabaseProjectId!,
|
supabaseProjectId: chatWithApp.app.supabaseProjectId!,
|
||||||
functionName: getFunctionNameFromPath(tag.from),
|
functionName: extractFunctionNameFromPath(tag.from),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
warnings.push({
|
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 {
|
try {
|
||||||
await deploySupabaseFunctions({
|
await deploySupabaseFunction({
|
||||||
supabaseProjectId: chatWithApp.app.supabaseProjectId!,
|
supabaseProjectId: chatWithApp.app.supabaseProjectId!,
|
||||||
functionName: getFunctionNameFromPath(tag.to),
|
functionName: extractFunctionNameFromPath(tag.to),
|
||||||
content: await readFileFromFunctionPath(toPath),
|
appPath,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errors.push({
|
errors.push({
|
||||||
@@ -353,6 +361,12 @@ export async function processFullResponseActions(
|
|||||||
for (const tag of dyadSearchReplaceTags) {
|
for (const tag of dyadSearchReplaceTags) {
|
||||||
const filePath = tag.path;
|
const filePath = tag.path;
|
||||||
const fullFilePath = safeJoin(appPath, filePath);
|
const fullFilePath = safeJoin(appPath, filePath);
|
||||||
|
|
||||||
|
// Track if this is a shared module
|
||||||
|
if (isSharedServerModule(filePath)) {
|
||||||
|
sharedModulesChanged = true;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!fs.existsSync(fullFilePath)) {
|
if (!fs.existsSync(fullFilePath)) {
|
||||||
// Do not show warning to user because we already attempt to do a <dyad-write> tag to fix it.
|
// 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);
|
fs.writeFileSync(fullFilePath, result.content);
|
||||||
writtenFiles.push(filePath);
|
writtenFiles.push(filePath);
|
||||||
|
|
||||||
// If server function, redeploy
|
// If server function (not shared), redeploy (skip if shared modules changed)
|
||||||
if (isServerFunction(filePath)) {
|
if (isServerFunction(filePath) && !sharedModulesChanged) {
|
||||||
try {
|
try {
|
||||||
await deploySupabaseFunctions({
|
await deploySupabaseFunction({
|
||||||
supabaseProjectId: chatWithApp.app.supabaseProjectId!,
|
supabaseProjectId: chatWithApp.app.supabaseProjectId!,
|
||||||
functionName: path.basename(path.dirname(filePath)),
|
functionName: extractFunctionNameFromPath(filePath),
|
||||||
content: result.content,
|
appPath,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errors.push({
|
errors.push({
|
||||||
@@ -401,6 +415,11 @@ export async function processFullResponseActions(
|
|||||||
let content: string | Buffer = tag.content;
|
let content: string | Buffer = tag.content;
|
||||||
const fullFilePath = safeJoin(appPath, filePath);
|
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
|
// Check if content (stripped of whitespace) exactly matches a file ID and replace with actual file content
|
||||||
if (fileUploadsMap) {
|
if (fileUploadsMap) {
|
||||||
const trimmedContent = tag.content.trim();
|
const trimmedContent = tag.content.trim();
|
||||||
@@ -433,12 +452,17 @@ export async function processFullResponseActions(
|
|||||||
fs.writeFileSync(fullFilePath, content);
|
fs.writeFileSync(fullFilePath, content);
|
||||||
logger.log(`Successfully wrote file: ${fullFilePath}`);
|
logger.log(`Successfully wrote file: ${fullFilePath}`);
|
||||||
writtenFiles.push(filePath);
|
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 {
|
try {
|
||||||
await deploySupabaseFunctions({
|
await deploySupabaseFunction({
|
||||||
supabaseProjectId: chatWithApp.app.supabaseProjectId!,
|
supabaseProjectId: chatWithApp.app.supabaseProjectId!,
|
||||||
functionName: path.basename(path.dirname(filePath)),
|
functionName: extractFunctionNameFromPath(filePath),
|
||||||
content: content,
|
appPath,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errors.push({
|
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
|
// If we have any file changes, commit them all at once
|
||||||
hasChanges =
|
hasChanges =
|
||||||
writtenFiles.length > 0 ||
|
writtenFiles.length > 0 ||
|
||||||
|
|||||||
@@ -287,6 +287,7 @@ CREATE TRIGGER on_auth_user_created
|
|||||||
1. Location:
|
1. Location:
|
||||||
- Write functions in the supabase/functions folder
|
- 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)
|
- 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.
|
- 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.
|
- 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.
|
- 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 { withLock } from "../ipc/utils/lock_utils";
|
||||||
import { readSettings, writeSettings } from "../main/settings";
|
import { readSettings, writeSettings } from "../main/settings";
|
||||||
import {
|
import {
|
||||||
@@ -7,8 +9,42 @@ import {
|
|||||||
import log from "electron-log";
|
import log from "electron-log";
|
||||||
import { IS_TEST_BUILD } from "../ipc/utils/test_utils";
|
import { IS_TEST_BUILD } from "../ipc/utils/test_utils";
|
||||||
|
|
||||||
|
const fsPromises = fs.promises;
|
||||||
|
|
||||||
const logger = log.scope("supabase_management_client");
|
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
|
* Checks if the Supabase access token is expired or about to expire
|
||||||
* Returns true if token needs to be refreshed
|
* Returns true if token needs to be refreshed
|
||||||
@@ -223,33 +259,89 @@ export async function listSupabaseBranches({
|
|||||||
return jsonResponse;
|
return jsonResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deploySupabaseFunctions({
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
// Deploy Supabase Functions with shared module support
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function deploySupabaseFunction({
|
||||||
supabaseProjectId,
|
supabaseProjectId,
|
||||||
functionName,
|
functionName,
|
||||||
content,
|
appPath,
|
||||||
}: {
|
}: {
|
||||||
supabaseProjectId: string;
|
supabaseProjectId: string;
|
||||||
functionName: string;
|
functionName: string;
|
||||||
content: string;
|
appPath: string;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
logger.info(
|
logger.info(
|
||||||
`Deploying Supabase function: ${functionName} to project: ${supabaseProjectId}`,
|
`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 supabase = await getSupabaseClient();
|
||||||
const formData = new FormData();
|
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(
|
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",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -266,9 +358,211 @@ export async function deploySupabaseFunctions({
|
|||||||
logger.info(
|
logger.info(
|
||||||
`Deployed Supabase function: ${functionName} to project: ${supabaseProjectId}`,
|
`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) {
|
async function createResponseError(response: Response, action: string) {
|
||||||
const errorBody = await safeParseErrorResponseBody(response);
|
const errorBody = await safeParseErrorResponseBody(response);
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,60 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import log from "electron-log";
|
import log from "electron-log";
|
||||||
import { deploySupabaseFunctions } from "./supabase_management_client";
|
import { deploySupabaseFunction } from "./supabase_management_client";
|
||||||
|
|
||||||
const logger = log.scope("supabase_utils");
|
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 {
|
try {
|
||||||
// Read all directories in supabase/functions
|
// Read all directories in supabase/functions
|
||||||
const entries = await fs.readdir(functionsDir, { withFileTypes: true });
|
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(
|
logger.info(
|
||||||
`Found ${functionDirs.length} functions to deploy in ${functionsDir}`,
|
`Found ${functionDirs.length} functions to deploy in ${functionsDir}`,
|
||||||
@@ -46,7 +97,8 @@ export async function deployAllSupabaseFunctions({
|
|||||||
// Deploy each function
|
// Deploy each function
|
||||||
for (const functionDir of functionDirs) {
|
for (const functionDir of functionDirs) {
|
||||||
const functionName = functionDir.name;
|
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
|
// Check if index.ts exists
|
||||||
try {
|
try {
|
||||||
@@ -59,13 +111,12 @@ export async function deployAllSupabaseFunctions({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const content = await fs.readFile(indexPath, "utf-8");
|
|
||||||
logger.info(`Deploying function: ${functionName}`);
|
logger.info(`Deploying function: ${functionName}`);
|
||||||
|
|
||||||
await deploySupabaseFunctions({
|
await deploySupabaseFunction({
|
||||||
supabaseProjectId,
|
supabaseProjectId,
|
||||||
functionName,
|
functionName,
|
||||||
content,
|
appPath,
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info(`Successfully deployed function: ${functionName}`);
|
logger.info(`Successfully deployed function: ${functionName}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user