From 5e869179edcb2c9a575b767821386a6951aec88b Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Wed, 11 Nov 2026 02:06:24 +0530 Subject: [PATCH 1/2] fix(stack): connect functions to the database as postgres --- .../experimental/stack/start/SIDE_EFFECTS.md | 1 + .../commands/functions/serve/SIDE_EFFECTS.md | 3 +- .../functions/serve/serve.stack.e2e.test.ts | 29 ++++++++++++------- .../functions/serve/serve.stack.handler.ts | 2 +- .../serve/serve.stack.integration.test.ts | 6 +++- apps/cli/tests/contract/cases.ts | 13 ++++++++- apps/cli/tests/contract/target.ts | 4 ++- packages/stack/ARCHITECTURE.md | 2 +- packages/stack/README.md | 2 +- packages/stack/src/composition/Supabase.ts | 2 +- .../src/composition/Supabase.unit.test.ts | 8 +++++ packages/stack/src/host/Endpoints.ts | 2 ++ 12 files changed, 55 insertions(+), 19 deletions(-) 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 c116ed34c5..01cce3f078 100644 --- a/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md @@ -149,6 +149,7 @@ 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`. `--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 af1cdf590b..df35987efc 100644 --- a/apps/cli/src/commands/functions/serve/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/functions/serve/SIDE_EFFECTS.md @@ -146,7 +146,8 @@ fails the command. Per-function TOML `env` remains the final override for that function. Multiline values and names the process environment cannot carry reach the runtime through its Functions config. `--debug` 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`. +running REST, Auth, Storage or Realtime service, else the configured `api.port`. Functions receive +`SUPABASE_DB_URL` for the `postgres` role, like legacy serve. Without a configured `import_map`, a function uses its own `deno.json` or `deno.jsonc`; a per-function `import_map.json` is not read and does not fail the command. 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 db9f0dae05..f464ddf7b5 100644 --- a/apps/cli/src/commands/functions/serve/serve.stack.handler.ts +++ b/apps/cli/src/commands/functions/serve/serve.stack.handler.ts @@ -293,7 +293,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 f73156e633..37cdd34cae 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 @@ -221,7 +221,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, @@ -441,6 +444,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 1241ec74bb..da12dd034e 100644 --- a/apps/cli/tests/contract/cases.ts +++ b/apps/cli/tests/contract/cases.ts @@ -317,7 +317,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 = [ @@ -331,6 +331,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 321aa50284..be9651f94a 100644 --- a/apps/cli/tests/contract/target.ts +++ b/apps/cli/tests/contract/target.ts @@ -138,7 +138,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 84c9f08841..d0ffb79828 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 076b6b8bcc..5296becd68 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 ad97e7dfe5..3efd998453 100644 --- a/packages/stack/src/composition/Supabase.ts +++ b/packages/stack/src/composition/Supabase.ts @@ -817,7 +817,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 74745e287f..d8ca4ccaa7 100644 --- a/packages/stack/src/composition/Supabase.unit.test.ts +++ b/packages/stack/src/composition/Supabase.unit.test.ts @@ -150,6 +150,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 4778908b84..7fc5b27f71 100644 --- a/packages/stack/src/host/Endpoints.ts +++ b/packages/stack/src/host/Endpoints.ts @@ -268,6 +268,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"], @@ -316,6 +317,7 @@ export const credentialsFor = Effect.fn("Endpoints.credentialsFor")(function* Date: Wed, 11 Nov 2026 16:39:14 +0530 Subject: [PATCH 2/2] docs(cli): note when older stacks switch to postgres --- .../cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 4417864e86..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,7 +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`. +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