diff --git a/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md index f16075a914..b65ea3a418 100644 --- a/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md @@ -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..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 diff --git a/apps/cli/src/commands/functions/serve/SIDE_EFFECTS.md b/apps/cli/src/commands/functions/serve/SIDE_EFFECTS.md index dd8d9dc5de..fb4d3c40e4 100644 --- a/apps/cli/src/commands/functions/serve/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/functions/serve/SIDE_EFFECTS.md @@ -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:`, 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. diff --git a/apps/cli/src/commands/functions/serve/serve.stack.e2e.test.ts b/apps/cli/src/commands/functions/serve/serve.stack.e2e.test.ts index 2e7728ecd7..601770e4d8 100644 --- a/apps/cli/src/commands/functions/serve/serve.stack.e2e.test.ts +++ b/apps/cli/src/commands/functions/serve/serve.stack.e2e.test.ts @@ -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"); @@ -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* ( @@ -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, ); @@ -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`; @@ -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. @@ -328,10 +337,8 @@ 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); @@ -339,7 +346,7 @@ describe("functions serve (stack e2e)", () => { 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); diff --git a/apps/cli/src/commands/functions/serve/serve.stack.handler.ts b/apps/cli/src/commands/functions/serve/serve.stack.handler.ts index e6a70ce1dc..f7b00791f9 100644 --- a/apps/cli/src/commands/functions/serve/serve.stack.handler.ts +++ b/apps/cli/src/commands/functions/serve/serve.stack.handler.ts @@ -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) => diff --git a/apps/cli/src/commands/functions/serve/serve.stack.integration.test.ts b/apps/cli/src/commands/functions/serve/serve.stack.integration.test.ts index ae69fa2188..b213732a6b 100644 --- a/apps/cli/src/commands/functions/serve/serve.stack.integration.test.ts +++ b/apps/cli/src/commands/functions/serve/serve.stack.integration.test.ts @@ -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, @@ -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"), diff --git a/apps/cli/tests/contract/cases.ts b/apps/cli/tests/contract/cases.ts index 0a7da4ca55..f40a736c72 100644 --- a/apps/cli/tests/contract/cases.ts +++ b/apps/cli/tests/contract/cases.ts @@ -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 = [ @@ -367,6 +367,17 @@ const functionsCases: ReadonlyArray = [ 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* () { diff --git a/apps/cli/tests/contract/target.ts b/apps/cli/tests/contract/target.ts index ca82b716a5..30aba2e6e2 100644 --- a/apps/cli/tests/contract/target.ts +++ b/apps/cli/tests/contract/target.ts @@ -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": `

${inviteTemplateMarker}

Accept `, diff --git a/packages/stack/ARCHITECTURE.md b/packages/stack/ARCHITECTURE.md index f5c850c614..4054a81523 100644 --- a/packages/stack/ARCHITECTURE.md +++ b/packages/stack/ARCHITECTURE.md @@ -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. diff --git a/packages/stack/README.md b/packages/stack/README.md index 2c25a2ba73..40e9a6339e 100644 --- a/packages/stack/README.md +++ b/packages/stack/README.md @@ -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. diff --git a/packages/stack/src/composition/Supabase.ts b/packages/stack/src/composition/Supabase.ts index 796b3cdecd..2b5983482a 100644 --- a/packages/stack/src/composition/Supabase.ts +++ b/packages/stack/src/composition/Supabase.ts @@ -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); } diff --git a/packages/stack/src/composition/Supabase.unit.test.ts b/packages/stack/src/composition/Supabase.unit.test.ts index c71981c91e..9bfc756dc3 100644 --- a/packages/stack/src/composition/Supabase.unit.test.ts +++ b/packages/stack/src/composition/Supabase.unit.test.ts @@ -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(); const database = yield* makeInstance(orchestrator, idFor("database"), { diff --git a/packages/stack/src/host/Endpoints.ts b/packages/stack/src/host/Endpoints.ts index 1c1e752c68..b10254998f 100644 --- a/packages/stack/src/host/Endpoints.ts +++ b/packages/stack/src/host/Endpoints.ts @@ -272,6 +272,7 @@ export const outputsFor = ( 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"], @@ -320,6 +321,7 @@ export const credentialsFor = Effect.fn("Endpoints.credentialsFor")(function*