plans/supabase-finegrained-redeploy.md
Avoid redeploying every Supabase Edge Function when a file under supabase/functions/_shared/ changes. Instead, use TypeScript-based static dependency analysis to identify which functions transitively import the changed shared module, redeploy only those functions, and fall back to the current all-functions redeploy whenever analysis is ambiguous.
The goal is to make common shared-module edits faster without risking stale deployed functions.
Dyad currently treats any _shared change as affecting all Supabase Edge Functions:
ctx.isSharedModulesChanged = true when editing supabase/functions/_shared/**.deployAllFunctionsIfNeeded(...) calls deployAllSupabaseFunctions(...).This is safe, but slow for apps with many functions. In most real projects, a shared module is only imported by a subset of functions.
.js import specifiers resolving to .ts source files in the first pass.supabase/functions/{functionName}/**; those already deploy individual functions where possible.Primary files:
src/supabase_admin/supabase_utils.ts
isServerFunction(...)isSharedServerModule(...)extractFunctionNameFromPath(...)deployAllSupabaseFunctions(...)src/pro/main/ipc/handlers/local_agent/processors/file_operations.ts
deployAllFunctionsIfNeeded(...)src/ipc/processors/response_processor.ts
isSharedModulesChanged
write_file.tssearch_replace.tsrename_file.tsdelete_file.tscopy_file.ts / src/ipc/utils/copy_file_utils.tsThe current boolean isSharedModulesChanged is enough to decide that some shared module changed, but not enough to know which shared module changed.
Add a changed-path accumulator alongside the boolean:
sharedServerModulePaths: string[];
For the local-agent path, add this to AgentContext and append any changed _shared path when write, search-replace, rename, delete, or copy touches supabase/functions/_shared/**.
For the legacy response processor, keep a local changedSharedModulePaths: string[] array while processing tags.
For renames, include both from and to when either path is under _shared; a rename should be treated conservatively because dependents may now have unresolved imports.
This is required to avoid an under-deploy regression. Today, every individual function deploy is guarded on !isSharedModulesChanged:
write_file.ts, search_replace.ts, rename_file.ts (the isServerFunction(...) && !ctx.isSharedModulesChanged deploy paths), and the copy path in copy_file_utils.ts.response_processor.ts individual-deploy branches for write / search-replace / rename.That guard is safe only because the current fallback redeploys all functions, which incidentally covers any function whose individual deploy was skipped. Partial deploy breaks that assumption:
In one turn the agent edits
_shared/foo.ts(flag flips true), then writesfunction-bar/index.tswherebardoes not importfoo.bar's individual deploy is skipped (flag is true), and the shared-module analysis returns only functions that importfoo.barwould never be deployed. The behavior is also order-dependent: ifbarwere written before the shared edit, it would deploy.
Fix: record the function name at each guard site whenever an individual server-function deploy is skipped because isSharedModulesChanged was already true.
// local-agent: AgentContext
pendingFunctionDeploys: string[];
For the legacy path, keep a local pendingFunctionDeploys: string[] alongside changedSharedModulePaths.
At each individual-deploy site, replace the silent skip with:
if (supabaseProjectId && isServerFunction(path)) {
if (!isSharedModulesChanged) {
// existing individual deploy
} else {
pendingFunctionDeploys.push(extractFunctionNameFromPath(path));
}
}
Recording only the skipped deploys (not every edited function) keeps the rescue set minimal: functions already deployed individually before the shared edit are not re-listed. This set is later unioned into the deploy set (see Section 6). Functions deleted via the delete tool are not added (they are pruned/removed, not deployed).
Add a helper in src/supabase_admin/supabase_utils.ts:
type SupabaseFunctionImpact =
| { kind: "partial"; functionNames: string[] }
| { kind: "all"; reason: string };
export async function getSupabaseFunctionsAffectedBySharedModules({
appPath,
changedSharedModulePaths,
}: {
appPath: string;
changedSharedModulePaths: string[];
}): Promise<SupabaseFunctionImpact>;
Behavior:
require.resolve("typescript", { paths: [appPath] }). If unavailable, return { kind: "all", reason: "typescript_not_installed" }.supabase/functions.index.ts.supabase/functions/**.index.ts.{ kind: "all", reason }.If no valid function directories exist, return { kind: "partial", functionNames: [] }.
Use the TypeScript compiler API rather than regex or Babel.
Load TypeScript from the target app, not from Dyad's own bundle. Dyad does not ship typescript in the main-process bundle: it is a devDependency, and the existing main-process consumers (tsc.ts, code_explorer.ts) run the compiler in worker threads with typescript marked external and resolved from the app's node_modules. A direct import ts from "typescript" in supabase_utils.ts would either bloat the main bundle or fail to resolve at runtime.
Reuse the established pattern from code_explorer.ts:
import { createRequire } from "node:module";
function loadAppTypeScript(
appPath: string,
): typeof import("typescript") | null {
try {
const tsPath = require.resolve("typescript", { paths: [appPath] });
return createRequire(import.meta.url)(tsPath);
} catch {
return null; // typescript_not_installed
}
}
If the app does not have typescript installed, the analysis returns { kind: "all", reason: "typescript_not_installed" } and we redeploy all functions exactly as today. This keeps the optimization purely additive: apps with TypeScript get fine-grained deploys, apps without it keep current behavior. This is listed as a safety-rule fallback below.
(Whether to run the analysis inline in the main process or in a worker thread like tsc/code_explorer is an implementation detail; parsing the small supabase/functions/** tree is cheap, so inline is acceptable for the first pass. Revisit if profiling shows it blocking the event loop on large function sets.)
Parse each source file with:
ts.createSourceFile(
filePath,
sourceText,
ts.ScriptTarget.Latest,
true,
scriptKindForPath(filePath),
);
Use ScriptKind based on the file extension:
.ts, .mts, .cts -> ts.ScriptKind.TS.tsx -> ts.ScriptKind.TSX.js, .mjs, .cjs -> ts.ScriptKind.JS.jsx -> ts.ScriptKind.JSXCollect module specifiers from:
ImportDeclarationExportDeclarationimport(...) callsDetect, but do not support in the first pass:
ImportEqualsDeclaration with an external module reference, e.g. import foo = require("../_shared/foo")Rules:
.js and .jsx files is supported with the same rules as TypeScript files.require(...) is unsafe in the first pass and should trigger all-functions redeploy. Even literal require("../_shared/foo") is not worth partially supporting until there is a clear need, because computed and conditionally executed requires are easy to misread.import foo = require("...") syntax is unsafe in the first pass and should trigger all-functions redeploy..ts specifiers, e.g. ../_shared/logger.ts, so the first pass should optimize for exact TypeScript source imports.npm:, jsr:, http://, https://, @supabase/....Resolve only local relative imports that stay within supabase/functions/**.
Support extension and directory variants commonly used in Supabase function code:
.ts.tsx.js.jsx.mjs.cjs.mts.cts/index.ts/index.tsx/index.js/index.jsx/index.mjs/index.cjs/index.mts/index.ctsIf a relative import cannot be resolved to an existing file, return { kind: "all", reason }.
If a relative import resolves outside supabase/functions/**, return { kind: "all", reason }. The Supabase prompt tells agents not to import project code from Edge Functions, but existing user code may violate that rule. Falling back prevents stale deployments when a function depends on files outside the graph.
Do not resolve .js specifiers to .ts source files in the first pass. For example, if a file imports ./foo.js but only foo.ts exists, return { kind: "all", reason }. Dyad-generated Supabase code is prompted to use explicit .ts shared-module specifiers, so this fallback should not affect the common path.
If a changed _shared path is not a TypeScript-like source file, return { kind: "all", reason }. Initial supported extensions:
.ts.tsx.js.jsx.mjs.cjs.mts.ctsDeletion and rename cases may cause imports to become unresolved; that should naturally trigger all-functions redeploy.
Refactor deployAllSupabaseFunctions(...) to support an optional subset:
export async function deploySupabaseFunctions({
appPath,
supabaseProjectId,
supabaseOrganizationSlug,
skipPruneEdgeFunctions,
functionNames,
onProgress,
}: {
appPath: string;
supabaseProjectId: string;
supabaseOrganizationSlug: string | null;
skipPruneEdgeFunctions: boolean;
functionNames?: string[];
onProgress?: (progress: SupabaseDeployProgress) => void;
}): Promise<string[]>;
Then keep deployAllSupabaseFunctions(...) as a wrapper or compatibility function:
export async function deployAllSupabaseFunctions(args) {
return deploySupabaseFunctions(args);
}
When functionNames is provided:
supabase/functions/{name}/index.ts.localFunctionNames from all valid local function directories, not from the partial functionNames subset.skipPruneEdgeFunctions is false, compare deployed functions against the complete localFunctionNames set and prune dangling deployed functions exactly as the all-functions deploy path does.In deployAllFunctionsIfNeeded(...):
pendingFunctionDeploys is empty, return success.getSupabaseFunctionsAffectedBySharedModules(...).kind === "partial", compute the deploy set as the union of functionNames (functions affected by the shared change) and pendingFunctionDeploys (functions whose individual deploy was skipped — see Section 1b), de-duplicated. Call deploySupabaseFunctions(...) with that set. If the union is empty, return success without deploying.kind === "all", call deployAllSupabaseFunctions(...). (The pendingFunctionDeploys set is subsumed by the all-functions deploy.)Shared modules changed, redeploying affected Supabase functions: a, bShared module dependency analysis fell back to all functions: <reason>Note on wiring: deployAllFunctionsIfNeeded currently accepts Pick<AgentContext, ...>. Add the new sharedServerModulePaths and pendingFunctionDeploys fields to that Pick, to the AgentContext interface in tools/types.ts, and to the context initializer in local_agent_handler.ts.
Apply the same partial/union/fallback behavior in response_processor.ts, using its local changedSharedModulePaths and pendingFunctionDeploys arrays. Note that without the union, a function directly edited after a shared edit in the same response would be silently dropped here too.
The implementation must choose all-functions redeploy for:
typescript installed (require.resolve fails).require(...) call in an analyzed local source file.ImportEqualsDeclaration with an external module reference.supabase/functions/**.Cycles in valid static imports are not unsafe by themselves. Track visited files during traversal to avoid infinite loops.
The optimization is acceptable only if uncertainty deploys too many functions, not too few.
This should be invisible except for faster deploys and clearer status text/logging.
For streamed deploy progress, keep the existing <dyad-status> format. The total should reflect the number of functions being deployed in the current operation, so partial deploys show accurate progress.
No new user setting is needed. If the analysis cannot prove a smaller affected set, the user gets today's behavior.
Add tests in src/supabase_admin/supabase_utils.test.ts or a new focused test file.
Cover:
function-a/index.ts imports ../_shared/foo.tsfunction-a is affected.function-a/index.ts imports ./lib/service.tsservice.ts imports ../_shared/foo.tsfunction-a is affected.service.ts uses export * from "../../_shared/foo.ts"_shared/unused.ts returns an empty partial set.await import("../_shared/foo.ts") is supported.await import("../_shared/" + name) returns kind: "all"..js and .jsx files with static imports are analyzed correctly.require("../_shared/foo") returns kind: "all" in the first pass.import foo = require("../_shared/foo") returns kind: "all" in the first pass.supabase/functions:
import helper from "../../../src/helper.ts" returns kind: "all".import "./foo.js" with only foo.ts present returns kind: "all".kind: "all".kind: "all".../_shared/foo to ../_shared/foo/index.ts.Extend src/supabase_admin/supabase_deploy_progress.test.ts or add a new test:
deploySupabaseFunctions(..., functionNames: ["alpha"]) bundles and activates only alpha.total equals the subset count.skipPruneEdgeFunctions: false prunes deployed functions that are absent from the complete local function set.skipPruneEdgeFunctions: true does not call prune logic.functionNames subset as dangling when they still exist locally.Add focused tests for:
response_processor.ts path follows the same partial/fallback behavior._shared file and then edits an unrelated server function (one that does not import the changed shared file) deploys both the shared-affected functions and the unrelated function. Verify the unrelated function is in the deploy set even though its individual deploy was skipped. Test both the local-agent and response_processor.ts paths.typescript installed, a shared change deploys all functions (kind: "all", reason typescript_not_installed).require.resolve, falling back to kind: "all" when absent) and unit tests.pendingFunctionDeploys) in local-agent contexts and legacy response processing.npm test -- src/supabase_admin/supabase_utils.test.ts
npm test -- src/supabase_admin/supabase_deploy_progress.test.ts
npm run fmt
npm run lint
npm run ts
Generated Dyad apps mostly reference _shared via relative path imports (../_shared/foo.ts), so the optimization fires in the common case. Import maps (deno.json / import_map.json) are the residual exception: any _shared reference through an import-map alias is treated as an unknown bare specifier and falls back to kind: "all", which is safe. No further premise validation is needed before building.
_shared imports, or keep falling back to all for those? (Not blocking — current fallback is safe; revisit only if alias usage grows.)_shared TypeScript edit redeploys only functions that statically and transitively import that changed file.