diff --git a/packages/stack/src/services/AnalyticsImgproxy.integration.test.ts b/packages/stack/src/services/AnalyticsImgproxy.integration.test.ts index ed9085b1c4..efce61ce18 100644 --- a/packages/stack/src/services/AnalyticsImgproxy.integration.test.ts +++ b/packages/stack/src/services/AnalyticsImgproxy.integration.test.ts @@ -1,6 +1,6 @@ import { NodeHttpClient, NodeServices } from "@effect/platform-node"; -import { describe, expect, it } from "@effect/vitest"; -import { Effect, FileSystem, Layer, Redacted } from "effect"; +import { expect, layer } from "@effect/vitest"; +import { Context, Effect, FileSystem, Layer, Redacted, Scope } from "effect"; import { HttpClient, HttpClientRequest } from "effect/unstable/http"; import { makeStandaloneService } from "../../tests/standalone-service.ts"; import { makeServiceRecipe } from "./Catalog.ts"; @@ -23,88 +23,115 @@ const dockerOptions = (root: string) => ({ engineTarget, }); -describe("service catalog", () => { - it.live( - "serves Analytics and Imgproxy with their real endpoints", - () => - Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const client = yield* HttpClient.HttpClient; - const root = yield* makeDockerDatabaseRoot("catalog-optional-data-"); - // Ownership is by location: Imgproxy's served directory is a caller path and must live - // outside the stack's data root, not merely outside the service's own instance root. - const callerRoot = yield* fs.makeTempDirectoryScoped({ - prefix: "catalog-optional-data-caller-", - }); - const secret = "catalog-optional-data-secret-with-at-least-32-chars"; - const databaseRecipe = yield* makeServiceRecipe( - { - service: "database", - config: { - version: "17", - databasePassword: Redacted.make("postgres"), - jwtSecret: Redacted.make(secret), - jwtExpiry: 3600, - }, - }, - dockerOptions(root), - ); - const database = yield* makeStandaloneService(databaseRecipe.definition, { - id: "database", - config: databaseRecipe.creation, - }); - yield* database.start; - yield* database.ready; - const databaseRelay = yield* makeDockerTcpRelay(databaseRecipe.endpoint("sql")); - const databaseUrl = `postgresql://supabase_admin:postgres@${databaseRelay.host}:${databaseRelay.port}/postgres`; +interface SharedDatabase { + readonly root: string; + readonly databaseUrl: string; +} + +/** The one owned database shared by every case below; started once, stopped once. */ +class CatalogDatabase extends Context.Service()( + "AnalyticsImgproxy.integration.CatalogDatabase", +) {} - const analyticsRecipe = yield* makeServiceRecipe( - { - service: "analytics", - config: { databaseUrl, backend: "postgres", apiKey: "catalog-analytics" }, - }, - dockerOptions(root), - ); - const analytics = yield* makeStandaloneService(analyticsRecipe.definition, { - id: "analytics", - config: analyticsRecipe.creation, - }); - yield* analytics.start; - yield* analytics.ready; - const analyticsEndpoint = yield* analyticsRecipe.endpoint("http"); - const analyticsResponse = yield* client.execute( - HttpClientRequest.get( - `http://${httpHost(analyticsEndpoint)}:${analyticsEndpoint.port}/health`, - ), - ); - expect(analyticsResponse.status).toBe(200); +const databaseLayer = Layer.effect( + CatalogDatabase, + Effect.gen(function* () { + const root = yield* makeDockerDatabaseRoot("catalog-optional-data-"); + const secret = "catalog-optional-data-secret-with-at-least-32-chars"; + const databaseRecipe = yield* makeServiceRecipe( + { + service: "database", + config: { + version: "17", + databasePassword: Redacted.make("postgres"), + jwtSecret: Redacted.make(secret), + jwtExpiry: 3600, + }, + }, + dockerOptions(root), + ); + // Forked so the graceful stop below runs before the service's own scope closes. + const serviceScope = yield* Scope.fork(yield* Effect.scope, "sequential"); + const database = yield* makeStandaloneService(databaseRecipe.definition, { + id: "database", + config: databaseRecipe.creation, + }).pipe(Scope.provide(serviceScope)); + yield* database.start; + yield* database.ready; + yield* Effect.addFinalizer(() => database.stop.pipe(Effect.orDie)); + const databaseRelay = yield* makeDockerTcpRelay(databaseRecipe.endpoint("sql")); + return { + root, + databaseUrl: `postgresql://supabase_admin:postgres@${databaseRelay.host}:${databaseRelay.port}/postgres`, + }; + }), +).pipe(Layer.provideMerge(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))); - const imageRoot = `${callerRoot}/images`; - yield* fs.makeDirectory(imageRoot, { recursive: true }); - const imgproxyRecipe = yield* makeServiceRecipe( - { service: "imgproxy", config: { filePath: imageRoot } }, - dockerOptions(root), - ); - const imgproxy = yield* makeStandaloneService(imgproxyRecipe.definition, { - id: "imgproxy", - config: imgproxyRecipe.creation, - }); - yield* imgproxy.start; - yield* imgproxy.ready; - const imgproxyEndpoint = yield* imgproxyRecipe.endpoint("http"); - const imgproxyResponse = yield* client.execute( - HttpClientRequest.get( - `http://${httpHost(imgproxyEndpoint)}:${imgproxyEndpoint.port}/health`, - ), - ); - expect(imgproxyResponse.status).toBe(200); +layer(databaseLayer, { excludeTestServices: true })("service catalog", (it) => { + it.effect( + "serves Analytics with its real endpoint", + () => + Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + const { root, databaseUrl } = yield* CatalogDatabase; + const analyticsRecipe = yield* makeServiceRecipe( + { + service: "analytics", + config: { databaseUrl, backend: "postgres", apiKey: "catalog-analytics" }, + }, + dockerOptions(root), + ); + const analytics = yield* makeStandaloneService(analyticsRecipe.definition, { + id: "analytics", + config: analyticsRecipe.creation, + }); + yield* analytics.start; + yield* analytics.ready; + const analyticsEndpoint = yield* analyticsRecipe.endpoint("http"); + const analyticsResponse = yield* client.execute( + HttpClientRequest.get( + `http://${httpHost(analyticsEndpoint)}:${analyticsEndpoint.port}/health`, + ), + ); + expect(analyticsResponse.status).toBe(200); + yield* analytics.stop; + }), + { timeout: 120_000 }, + ); - yield* imgproxy.stop; - yield* analytics.stop; - yield* database.stop; - }), - ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), + it.effect( + "serves Imgproxy with its real endpoint", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const client = yield* HttpClient.HttpClient; + const { root } = yield* CatalogDatabase; + // Ownership is by location: Imgproxy's served directory is a caller path and must live + // outside the stack's data root, not merely outside the service's own instance root. + const callerRoot = yield* fs.makeTempDirectoryScoped({ + prefix: "catalog-optional-data-caller-", + }); + const imageRoot = `${callerRoot}/images`; + yield* fs.makeDirectory(imageRoot, { recursive: true }); + const imgproxyRecipe = yield* makeServiceRecipe( + { service: "imgproxy", config: { filePath: imageRoot } }, + dockerOptions(root), + ); + const imgproxy = yield* makeStandaloneService(imgproxyRecipe.definition, { + id: "imgproxy", + config: imgproxyRecipe.creation, + }); + yield* imgproxy.start; + yield* imgproxy.ready; + const imgproxyEndpoint = yield* imgproxyRecipe.endpoint("http"); + const imgproxyResponse = yield* client.execute( + HttpClientRequest.get( + `http://${httpHost(imgproxyEndpoint)}:${imgproxyEndpoint.port}/health`, + ), + ); + expect(imgproxyResponse.status).toBe(200); + yield* imgproxy.stop; + }), { timeout: 120_000 }, ); }); diff --git a/packages/stack/src/services/Realtime.integration.test.ts b/packages/stack/src/services/Realtime.integration.test.ts index 1daa650085..176c4a3cd9 100644 --- a/packages/stack/src/services/Realtime.integration.test.ts +++ b/packages/stack/src/services/Realtime.integration.test.ts @@ -1,6 +1,6 @@ import { NodeHttpClient, NodeServices } from "@effect/platform-node"; -import { describe, expect, it } from "@effect/vitest"; -import { Effect, Layer, Redacted } from "effect"; +import { expect, layer } from "@effect/vitest"; +import { Context, Effect, Layer, Redacted, Scope } from "effect"; import { HttpClient, HttpClientRequest } from "effect/unstable/http"; import { makeStandaloneService } from "../../tests/standalone-service.ts"; import { makeServiceRecipe } from "./Catalog.ts"; @@ -23,115 +23,163 @@ const dockerOptions = (root: string) => ({ engineTarget, }); -describe("service catalog", () => { - it.live( - "serves Realtime, pg-meta, and Studio against the owned database", +interface SharedDatabase { + readonly root: string; + readonly secret: string; + readonly databaseUrl: string; +} + +/** The one owned database shared by every case below; started once, stopped once. */ +class CatalogDatabase extends Context.Service()( + "Realtime.integration.CatalogDatabase", +) {} + +const databaseLayer = Layer.effect( + CatalogDatabase, + Effect.gen(function* () { + const root = yield* makeDockerDatabaseRoot("catalog-optional-api-"); + const secret = "catalog-optional-api-secret-with-at-least-32-chars"; + const databaseRecipe = yield* makeServiceRecipe( + { + service: "database", + config: { + version: "17", + databasePassword: Redacted.make("postgres"), + jwtSecret: Redacted.make(secret), + jwtExpiry: 3600, + }, + }, + dockerOptions(root), + ); + // Forked so the graceful stop below runs before the service's own scope closes. + const serviceScope = yield* Scope.fork(yield* Effect.scope, "sequential"); + const database = yield* makeStandaloneService(databaseRecipe.definition, { + id: "database", + config: databaseRecipe.creation, + }).pipe(Scope.provide(serviceScope)); + yield* database.start; + yield* database.ready; + yield* Effect.addFinalizer(() => database.stop.pipe(Effect.orDie)); + const databaseRelay = yield* makeDockerTcpRelay(databaseRecipe.endpoint("sql")); + return { + root, + secret, + databaseUrl: `postgresql://supabase_admin:postgres@${databaseRelay.host}:${databaseRelay.port}/postgres`, + }; + }), +).pipe(Layer.provideMerge(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))); + +const startPgmeta = (root: string, databaseUrl: string) => + Effect.gen(function* () { + const pgmetaRecipe = yield* makeServiceRecipe( + { service: "pgmeta", config: { databaseUrl } }, + dockerOptions(root), + ); + const pgmeta = yield* makeStandaloneService(pgmetaRecipe.definition, { + id: "pgmeta", + config: pgmetaRecipe.creation, + }); + yield* pgmeta.start; + yield* pgmeta.ready; + return { pgmeta, pgmetaRecipe }; + }); + +layer(databaseLayer, { excludeTestServices: true })("service catalog", (it) => { + it.effect( + "serves Realtime against the owned database", () => - Effect.scoped( - Effect.gen(function* () { - const client = yield* HttpClient.HttpClient; - const root = yield* makeDockerDatabaseRoot("catalog-optional-api-"); - const secret = "catalog-optional-api-secret-with-at-least-32-chars"; - const databaseRecipe = yield* makeServiceRecipe( - { - service: "database", - config: { - version: "17", - databasePassword: Redacted.make("postgres"), - jwtSecret: Redacted.make(secret), - jwtExpiry: 3600, - }, - }, - dockerOptions(root), - ); - const database = yield* makeStandaloneService(databaseRecipe.definition, { - id: "database", - config: databaseRecipe.creation, - }); - yield* database.start; - yield* database.ready; - const databaseRelay = yield* makeDockerTcpRelay(databaseRecipe.endpoint("sql")); - const databaseUrl = `postgresql://supabase_admin:postgres@${databaseRelay.host}:${databaseRelay.port}/postgres`; + Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + const { root, secret, databaseUrl } = yield* CatalogDatabase; + const realtimeRecipe = yield* makeServiceRecipe( + { service: "realtime", config: { databaseUrl, jwtSecret: secret } }, + dockerOptions(root), + ); + const realtime = yield* makeStandaloneService(realtimeRecipe.definition, { + id: "realtime", + config: realtimeRecipe.creation, + }); + yield* realtime.start; + yield* realtime.ready; + const realtimeEndpoint = yield* realtimeRecipe.endpoint("http"); + const realtimeResponse = yield* client.execute( + HttpClientRequest.get( + `http://${httpHost(realtimeEndpoint)}:${realtimeEndpoint.port}/healthcheck`, + ), + ); + expect(realtimeResponse.status).toBe(200); + yield* realtime.stop; + }), + { timeout: 120_000 }, + ); - const realtimeRecipe = yield* makeServiceRecipe( - { service: "realtime", config: { databaseUrl, jwtSecret: secret } }, - dockerOptions(root), - ); - const realtime = yield* makeStandaloneService(realtimeRecipe.definition, { - id: "realtime", - config: realtimeRecipe.creation, - }); - yield* realtime.start; - yield* realtime.ready; - const realtimeEndpoint = yield* realtimeRecipe.endpoint("http"); - const realtimeResponse = yield* client.execute( - HttpClientRequest.get( - `http://${httpHost(realtimeEndpoint)}:${realtimeEndpoint.port}/healthcheck`, - ), - ); - expect(realtimeResponse.status).toBe(200); + it.effect( + "serves pg-meta against the owned database", + () => + Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + const { root, databaseUrl } = yield* CatalogDatabase; + const { pgmeta, pgmetaRecipe } = yield* startPgmeta(root, databaseUrl); + const pgmetaEndpoint = yield* pgmetaRecipe.endpoint("http"); + const schemasResponse = yield* client.execute( + HttpClientRequest.get( + `http://${httpHost(pgmetaEndpoint)}:${pgmetaEndpoint.port}/schemas`, + ), + ); + expect(schemasResponse.status).toBe(200); + expect(yield* schemasResponse.text).toContain("public"); + yield* pgmeta.stop; + }), + { timeout: 120_000 }, + ); - const pgmetaRecipe = yield* makeServiceRecipe( - { service: "pgmeta", config: { databaseUrl } }, - dockerOptions(root), - ); - const pgmeta = yield* makeStandaloneService(pgmetaRecipe.definition, { - id: "pgmeta", - config: pgmetaRecipe.creation, - }); - yield* pgmeta.start; - yield* pgmeta.ready; - const pgmetaEndpoint = yield* pgmetaRecipe.endpoint("http"); - const pgmetaRelay = yield* makeDockerHttpRelay(pgmetaRecipe.endpoint("http")); - const schemasResponse = yield* client.execute( - HttpClientRequest.get( - `http://${httpHost(pgmetaEndpoint)}:${pgmetaEndpoint.port}/schemas`, - ), - ); - expect(schemasResponse.status).toBe(200); - expect(yield* schemasResponse.text).toContain("public"); + it.effect( + "serves Studio backed by pg-meta against the owned database", + () => + Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + const { root, secret, databaseUrl } = yield* CatalogDatabase; + const { pgmeta, pgmetaRecipe } = yield* startPgmeta(root, databaseUrl); + const pgmetaRelay = yield* makeDockerHttpRelay(pgmetaRecipe.endpoint("http")); - const studioRecipe = yield* makeServiceRecipe( - { - service: "studio", - config: { - databaseUrl, - pgmetaUrl: `http://${pgmetaRelay.host}:${pgmetaRelay.port}`, - analyticsApiKey: "catalog-analytics-key", - apiUrl: "http://localhost:8000", - publicApiUrl: "http://localhost:8000", - jwtSecret: secret, - }, + const studioRecipe = yield* makeServiceRecipe( + { + service: "studio", + config: { + databaseUrl, + pgmetaUrl: `http://${pgmetaRelay.host}:${pgmetaRelay.port}`, + analyticsApiKey: "catalog-analytics-key", + apiUrl: "http://localhost:8000", + publicApiUrl: "http://localhost:8000", + jwtSecret: secret, }, - dockerOptions(root), - ); - const studio = yield* makeStandaloneService(studioRecipe.definition, { - id: "studio", - config: studioRecipe.creation, - }); - yield* studio.start; - yield* studio.ready; - const studioEndpoint = yield* studioRecipe.endpoint("http"); - const profileResponse = yield* client.execute( - HttpClientRequest.get( - `http://${httpHost(studioEndpoint)}:${studioEndpoint.port}/api/platform/profile`, - ), - ); - expect(profileResponse.status).toBe(200); - const queryResponse = yield* client.execute( - HttpClientRequest.post( - `http://${httpHost(studioEndpoint)}:${studioEndpoint.port}/api/platform/pg-meta/default/query`, - ).pipe(HttpClientRequest.bodyJsonUnsafe({ query: "select current_user" })), - ); - expect(queryResponse.status).toBe(200); - expect(yield* queryResponse.text).toContain('"current_user":"postgres"'); + }, + dockerOptions(root), + ); + const studio = yield* makeStandaloneService(studioRecipe.definition, { + id: "studio", + config: studioRecipe.creation, + }); + yield* studio.start; + yield* studio.ready; + const studioEndpoint = yield* studioRecipe.endpoint("http"); + const profileResponse = yield* client.execute( + HttpClientRequest.get( + `http://${httpHost(studioEndpoint)}:${studioEndpoint.port}/api/platform/profile`, + ), + ); + expect(profileResponse.status).toBe(200); + const queryResponse = yield* client.execute( + HttpClientRequest.post( + `http://${httpHost(studioEndpoint)}:${studioEndpoint.port}/api/platform/pg-meta/default/query`, + ).pipe(HttpClientRequest.bodyJsonUnsafe({ query: "select current_user" })), + ); + expect(queryResponse.status).toBe(200); + expect(yield* queryResponse.text).toContain('"current_user":"postgres"'); - yield* studio.stop; - yield* pgmeta.stop; - yield* realtime.stop; - yield* database.stop; - }), - ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), + yield* studio.stop; + yield* pgmeta.stop; + }), { timeout: 120_000 }, ); });