Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,9 @@ When Functions is selected, the CLI reads and validates `supabase/functions/.env
`start`, skipping reserved `SUPABASE_*` entries with a warning. That file overrides
`edge_runtime.secrets`, per-function `.env` files are not read, and `functions.<name>.env`
provides per-function values from project environment references. Multiline values are supported.
Functions receive `SUPABASE_DB_URL` for the `postgres` role, like legacy `start`. A stack saved
with the earlier `supabase_admin` URL keeps it through `stack restart` and gets the `postgres` one
after stop and start.
`--debug` runs the Functions runtime verbose; `status` does not report it as drift. Per-function
enabled/JWT policies, entrypoints, import maps and static files are forwarded to the worker
bootstrap. Without a configured `import_map`, a function uses its own `deno.json` or `deno.jsonc`; a
Expand Down
3 changes: 2 additions & 1 deletion apps/cli/src/commands/functions/serve/SIDE_EFFECTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,8 @@ the process environment cannot carry reach the runtime through its Functions con
runs the runtime verbose for the command lifetime. A temporary service uses the API port of a
running REST, Auth, Storage or Realtime service, else the configured `api.port`; on a stack
started with `[api.tls]` it also gets `SUPABASE_INTERNAL_HOST_URL=https://127.0.0.1:<api>`, which
its banner prints, like a composed one.
its banner prints, like a composed one. Functions receive `SUPABASE_DB_URL` for the `postgres`
role, like legacy serve.
On a Docker or Podman stack serving `[api.tls]` with its database running, starting the Functions
instance runs the `stack start` api.tls check; a failure stops the stack's services and fails serve
with that error.
Expand Down
29 changes: 18 additions & 11 deletions apps/cli/src/commands/functions/serve/serve.stack.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,9 +133,10 @@ const fixture = Effect.fn("FunctionsServeE2e.fixture")(function* (
if (rest === undefined || database === undefined)
return yield* Effect.die("fixture services missing");
const apiUrl = (yield* rest.credentials()).apiUrl;
const databaseUrl = (yield* database.credentials({ from: "runtime" })).databaseUrl;
if (apiUrl === undefined || databaseUrl === undefined)
const { databaseUrl, functionsDatabaseUrl } = yield* database.credentials({ from: "runtime" });
if (apiUrl === undefined || databaseUrl === undefined || functionsDatabaseUrl === undefined)
return yield* Effect.die("fixture URLs missing");
expect(new URL(functionsDatabaseUrl).username).toBe("postgres");
if (!included) {
const httpPort = (yield* rest.status).endpoints.find(({ name }) => name === "http")?.port;
if (httpPort === undefined) return yield* Effect.die("API port missing");
Expand All @@ -151,7 +152,15 @@ const fixture = Effect.fn("FunctionsServeE2e.fixture")(function* (
endpoints: { http: { port: httpPort } },
});
}
return { root, home, stack, services: yield* stack.services.list, apiUrl, databaseUrl };
return {
root,
home,
stack,
services: yield* stack.services.list,
apiUrl,
databaseUrl,
functionsDatabaseUrl,
};
});

const serve = Effect.fn("FunctionsServeE2e.serve")(function* (
Expand Down Expand Up @@ -249,7 +258,7 @@ describe("functions serve (stack e2e)", () => {
`attaches and restores temporary overrides on the ${runtime} Functions member`,
() =>
Effect.gen(function* () {
const { root, home, stack, services, apiUrl, databaseUrl } = yield* fixture(
const { root, home, stack, services, apiUrl, functionsDatabaseUrl } = yield* fixture(
runtime,
true,
);
Expand All @@ -276,7 +285,7 @@ describe("functions serve (stack e2e)", () => {
value: "original",
anon: true,
service: true,
databaseUrl,
databaseUrl: functionsDatabaseUrl,
});
const fs = yield* FileSystem.FileSystem;
const sourcePath = `${root}/supabase/functions/hello/index.ts`;
Expand All @@ -303,7 +312,7 @@ describe("functions serve (stack e2e)", () => {
value: "overridden",
anon: true,
service: true,
databaseUrl,
databaseUrl: functionsDatabaseUrl,
});
yield* interrupt(overridden);
// The override's own restart is the only launch before its configuration is running.
Expand All @@ -328,18 +337,16 @@ describe("functions serve (stack e2e)", () => {
`removes temporary ${runtime} Functions and keeps it excluded on Ctrl-C`,
() =>
Effect.gen(function* () {
const { root, home, stack, services, apiUrl, databaseUrl } = yield* fixture(
runtime,
false,
);
const { root, home, stack, services, apiUrl, databaseUrl, functionsDatabaseUrl } =
yield* fixture(runtime, false);
const composition = yield* stack.composition.describe;
const startedAt = yield* postmasterStartTime(stack, databaseUrl);
const child = yield* serve(root, home);
expect(yield* payload(apiUrl, true)).toEqual({
value: null,
anon: true,
service: true,
databaseUrl,
databaseUrl: functionsDatabaseUrl,
});
expect(yield* stack.composition.describe).toEqual(composition);
expect(yield* stack.services.list).toHaveLength(services.length + 1);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,7 @@ const session = Effect.fn("functions.serve.session")(function* (flags: Functions
const remoteKeys =
refreshedJwks === undefined ? [] : yield* Schema.decodeEffect(JwkArray)(refreshedJwks);
const jwks = yield* Schema.encodeEffect(JwksDocument)({ keys: [...remoteKeys, ...localKeys] });
const databaseUrl = (yield* database.credentials({ from: "runtime" })).databaseUrl;
const databaseUrl = (yield* database.credentials({ from: "runtime" })).functionsDatabaseUrl;
if (databaseUrl === undefined) return yield* invalidConfig("The database has no runtime URL.");
const apiSource = [...members, ...instances].find(
(instance) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,10 @@ const fixture = (
service: "database" as const,
status: Effect.succeed(observation("database", databaseCreation)),
credentials: () =>
Effect.succeed({ databaseUrl: "postgresql://postgres@127.0.0.1:54322/postgres" }),
Effect.succeed({
databaseUrl: "postgresql://supabase_admin@127.0.0.1:54322/postgres",
functionsDatabaseUrl: "postgresql://postgres@127.0.0.1:54322/postgres",
}),
start: Effect.void,
ready: Effect.void,
stop: Effect.void,
Expand Down Expand Up @@ -449,6 +452,7 @@ describe("experimental Stack Functions serve", () => {
expect(created.config.anonKey).toBeUndefined();
expect(created.config.serviceRoleKey).toBeUndefined();
expect(created.config.jwks).toBeDefined();
expect(created.config.databaseUrl).toBe("postgresql://postgres@127.0.0.1:54322/postgres");
const jwks = yield* Schema.decodeEffect(testJwksDocument)(created.config.jwks ?? "");
expect(
jwks.keys.some((key) => key.kty === "oct" && key.k === "c2F2ZWQtand0LXNlY3JldA"),
Expand Down
13 changes: 12 additions & 1 deletion apps/cli/tests/contract/cases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,7 @@ const invokesEcho = (key: "PUBLISHABLE_KEY" | "ANON_KEY") =>
Effect.gen(function* () {
const client = yield* contractClient(key);
const data = yield* sdk("invoke echo", (signal) => client.functions.invoke("echo", { signal }));
expect(data).toEqual({ ok: true });
expect(data).toMatchObject({ ok: true });
});

const functionsCases: ReadonlyArray<ContractCase> = [
Expand All @@ -367,6 +367,17 @@ const functionsCases: ReadonlyArray<ContractCase> = [
run: invokesEcho("ANON_KEY"),
timeout: COLD_LAUNCH_TIMEOUT_MS,
},
{
name: "Functions sees SUPABASE_DB_URL for the postgres role",
run: Effect.gen(function* () {
const client = yield* contractClient("PUBLISHABLE_KEY");
const data = yield* sdk("invoke echo", (signal) =>
client.functions.invoke("echo", { signal }),
);
expect(data).toMatchObject({ dbUser: "postgres" });
}),
timeout: COLD_LAUNCH_TIMEOUT_MS,
},
{
name: "Functions rejects a request without Authorization when verify_jwt is on",
run: Effect.gen(function* () {
Expand Down
4 changes: 3 additions & 1 deletion apps/cli/tests/contract/target.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,9 @@ insert into public.contract_log (source) select format('migration 0 sees auth=%s
`,
"buckets/public/a.txt": "seeded object\n",
"buckets/public/nested/b.txt": "nested object\n",
"functions/echo/index.ts": `Deno.serve(() => Response.json({ ok: true }));
"functions/echo/index.ts": `Deno.serve(() =>
Response.json({ ok: true, dbUser: URL.parse(Deno.env.get("SUPABASE_DB_URL") ?? "")?.username ?? null }),
);
`,
"templates/shared/invite.html": `<p>${inviteTemplateMarker}</p><a href="{{ .ConfirmationURL }}">Accept</a>
`,
Expand Down
2 changes: 1 addition & 1 deletion packages/stack/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,7 @@ Validate composition references, input/output types and cycles when registering

Composition start first assigns or reuses and binds every configured public listener of its selected members, including lazy members, and registers their routes. One worker per member then waits for its prerequisites' outcomes: a lazy member is armed (also after a prerequisite failed), and an eager member is started and awaited for readiness, or reported as blocked when a prerequisite failed. Prerequisites outside the configured members run as eager standalone instances. It does not select unrelated standalone instances. Successful start returns member observations with their public endpoints; credentials reads render usable connection strings from these assignments without starting lazy backends. A failed launch or readiness check makes the operation return an error with per-instance outcomes; a running but unhealthy instance remains running, and completed steps are not rolled back.

After binding the shared API listener, default composition setup uses the endpoint renderer to populate ordinary configuration values: Studio receives `apiUrl` and `publicApiUrl`, Auth receives `externalApiUrl`, plus `templateBaseUrl` beside a generated `templateToken` when it has templates, and Functions receives `apiUrl`. When the database has a configured SQL endpoint, Functions also receives its rendered `databaseUrl`; this saved value is refreshed when callers recompose the composition, and does not create a database readiness dependency for Function execution. These values are saved in each instance's configuration; individual starts and normal host reopening consume that configuration. This is concrete default setup code, not declarative input wiring or a public listener-binding API. The endpoint renderer owns the host/runtime reachability rules; custom configurations supply plain URLs through the same inputs. URL values do not imply graph edges.
After binding the shared API listener, default composition setup uses the endpoint renderer to populate ordinary configuration values: Studio receives `apiUrl` and `publicApiUrl`, Auth receives `externalApiUrl`, plus `templateBaseUrl` beside a generated `templateToken` when it has templates, and Functions receives `apiUrl`. When the database has a configured SQL endpoint, Functions also receives its rendered `functionsDatabaseUrl` (the `postgres` role) as `databaseUrl`; this saved value is refreshed when callers recompose the composition, and does not create a database readiness dependency for Function execution. These values are saved in each instance's configuration; individual starts and normal host reopening consume that configuration. This is concrete default setup code, not declarative input wiring or a public listener-binding API. The endpoint renderer owns the host/runtime reachability rules; custom configurations supply plain URLs through the same inputs. URL values do not imply graph edges.

Composition restart is two passes: stop the selected instances in reverse dependency order, then perform composition start. If the stop pass fails, report partial results without beginning the start pass. Cancelling composition withdraws its waits; admitted executions continue in the owner scope, and launched instances remain until an explicit stop or owner close. Explicit empty selection is a no-op; there is no generic rollback engine.

Expand Down
2 changes: 1 addition & 1 deletion packages/stack/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ const services = await stack.composition.supabase([
await stack.composition.start();
```

The factory accepts one instance of each selected recipe, binds its configured public endpoints, and wires managed inputs such as REST's database URL. When the database SQL endpoint is configured, Functions receives the saved database URL as an ordinary input too; recompose the composition after rotating database credentials to refresh that value. This binding does not make Functions wait for database readiness. Database is eager; Studio is lazy with a 5-minute idle timeout; other public services, Functions included, are lazy with a 60-second idle timeout. Services without public endpoints are eager. Pass `{ eager: true }` to start every member eagerly. A managed URL binding requires its producer's endpoint to be configured. The factory also supplies ordinary host/runtime API URLs to Auth, Studio, and Functions without adding dependencies from those URLs. It rejects an already configured composition.
The factory accepts one instance of each selected recipe, binds its configured public endpoints, and wires managed inputs such as REST's database URL. When the database SQL endpoint is configured, Functions receives the database's `postgres`-role URL as an ordinary input too; recompose the composition after rotating database credentials to refresh that value. This binding does not make Functions wait for database readiness. Database is eager; Studio is lazy with a 5-minute idle timeout; other public services, Functions included, are lazy with a 60-second idle timeout. Services without public endpoints are eager. Pass `{ eager: true }` to start every member eagerly. A managed URL binding requires its producer's endpoint to be configured. The factory also supplies ordinary host/runtime API URLs to Auth, Studio, and Functions without adding dependencies from those URLs. It rejects an already configured composition.

Inputs that the composition binds or the owner fills from the stack credentials, such as REST's `databaseUrl` or a JWT secret, are optional in creation configuration. Starting or restarting an instance without a required input that was neither bound nor provided fails before any lifecycle change with a `ServiceError` whose `operation` is `"input"` and whose message names the input; a running instance keeps running.

Expand Down
2 changes: 1 addition & 1 deletion packages/stack/src/composition/Supabase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -832,7 +832,7 @@ export const makeSupabaseComposition = Effect.fn("Supabase.compose")(
database !== undefined &&
endpointNames(database.creation).includes("sql")
)
values.databaseUrl = yield* operations.output(database.id, "databaseUrl");
values.databaseUrl = yield* operations.output(database.id, "functionsDatabaseUrl");
if (Object.keys(values).length > 0) {
return yield* operations.updateCreation(entry.id, values);
}
Expand Down
8 changes: 8 additions & 0 deletions packages/stack/src/composition/Supabase.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,14 @@ it.live(
if (configuredStudio?.creation.service !== "studio")
return yield* Effect.die("Studio was not configured");
expect(configuredStudio.creation.config.functionsUrl).toBe(`url::${functionsId}`);
const configuredFunctions = configured.find(
({ creation }) => creation.service === "functions",
);
if (configuredFunctions?.creation.service !== "functions")
return yield* Effect.die("Functions was not configured");
expect(configuredFunctions.creation.config.databaseUrl).toBe(
`functionsDatabaseUrl::${idFor("database")}`,
);

const orchestrator = yield* Orchestrator.make<Orchestrator.RegisteredInstance>();
const database = yield* makeInstance(orchestrator, idFor("database"), {
Expand Down
2 changes: 2 additions & 0 deletions packages/stack/src/host/Endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,7 @@ export const outputsFor = <E>(
if (creation.service === "database" && endpointNames(creation).includes("sql")) {
for (const [name, role, database] of [
["databaseUrl", "supabase_admin", "postgres"],
["functionsDatabaseUrl", "postgres", "postgres"],
["authenticatorUrl", "authenticator", "postgres"],
["authDatabaseUrl", "supabase_auth_admin", "postgres"],
["storageDatabaseUrl", "supabase_storage_admin", "postgres"],
Expand Down Expand Up @@ -320,6 +321,7 @@ export const credentialsFor = Effect.fn("Endpoints.credentialsFor")(function* <E
const value = yield* databasePassword(creation);
for (const [name, role, database] of [
["databaseUrl", "supabase_admin", "postgres"],
["functionsDatabaseUrl", "postgres", "postgres"],
["authenticatorUrl", "authenticator", "postgres"],
["authDatabaseUrl", "supabase_auth_admin", "postgres"],
["storageDatabaseUrl", "supabase_storage_admin", "postgres"],
Expand Down
Loading