From 4186e6c0d57aca3fc04e4fa97a611489f81a786f Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 1 Oct 2026 14:53:01 +0200 Subject: [PATCH 1/4] test(stack): split chained catalog smoke tests into independent cases Pooler, Realtime, and AnalyticsVectorImgproxy each chained several real service start/ready cycles inside one it.live, so the critical path was the sum of multiple container cold starts against a shared 120s timeout. Share one owned database per file via beforeAll/afterAll and give each downstream service its own independent it.live case. --- ...nalyticsVectorImgproxy.integration.test.ts | 123 +++++++++++++----- .../src/services/Pooler.integration.test.ts | 109 ++++++++++------ .../src/services/Realtime.integration.test.ts | 113 +++++++++++----- 3 files changed, 239 insertions(+), 106 deletions(-) diff --git a/packages/stack/src/services/AnalyticsVectorImgproxy.integration.test.ts b/packages/stack/src/services/AnalyticsVectorImgproxy.integration.test.ts index de79a2f854..e2e0845830 100644 --- a/packages/stack/src/services/AnalyticsVectorImgproxy.integration.test.ts +++ b/packages/stack/src/services/AnalyticsVectorImgproxy.integration.test.ts @@ -1,8 +1,8 @@ import { NodeHttpClient, NodeServices } from "@effect/platform-node"; -import { describe, expect, it } from "@effect/vitest"; -import { Effect, FileSystem, Layer, Redacted } from "effect"; +import { afterAll, beforeAll, describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, FileSystem, Layer, Redacted, Scope } from "effect"; import { HttpClient, HttpClientRequest } from "effect/unstable/http"; -import { makeService } from "../Service.ts"; +import { makeService, type ServiceInstance } from "../Service.ts"; import { makeServiceRecipe } from "./Catalog.ts"; import { makeDockerHttpRelay, makeDockerTcpRelay } from "../../tests/docker-relay.ts"; import { makeDockerDatabaseRoot } from "../../tests/docker-fixture.ts"; @@ -20,37 +20,58 @@ const dockerOptions = (root: string) => ({ runtime: "docker" as const, }); +const secret = "catalog-optional-data-secret-with-at-least-32-chars"; + +let scope: Scope.Closeable; +let root: string; +let database: ServiceInstance; +let databaseUrl: string; + describe("service catalog", () => { + beforeAll(() => { + scope = Scope.makeUnsafe(); + return Effect.gen(function* () { + const databaseRoot = yield* makeDockerDatabaseRoot("catalog-optional-data-"); + const databaseRecipe = yield* makeServiceRecipe( + { + service: "database", + config: { + version: "17", + databasePassword: Redacted.make("postgres"), + jwtSecret: Redacted.make(secret), + jwtExpiry: 3600, + }, + }, + dockerOptions(databaseRoot), + ); + const databaseService = yield* makeService(databaseRecipe.definition, { + id: "database", + config: databaseRecipe.creation, + }); + yield* databaseService.start; + yield* databaseService.ready; + const databaseRelay = yield* makeDockerTcpRelay(databaseRecipe.endpoint("sql")); + root = databaseRoot; + database = databaseService; + databaseUrl = `postgresql://supabase_admin:postgres@${databaseRelay.host}:${databaseRelay.port}/postgres`; + }).pipe( + Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp)), + Scope.provide(scope), + Effect.runPromise, + ); + }, 120_000); + + afterAll( + () => database.stop.pipe(Effect.andThen(Scope.close(scope, Exit.void)), Effect.runPromise), + 60_000, + ); + it.live( - "serves Analytics, Vector, and Imgproxy with their real endpoints", + "serves Analytics with its real endpoint", () => Effect.scoped( Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; const client = yield* HttpClient.HttpClient; - 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), - ); - const database = yield* makeService(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`; - const analyticsRecipe = yield* makeServiceRecipe( { service: "analytics", @@ -65,13 +86,39 @@ describe("service catalog", () => { yield* analytics.start; yield* analytics.ready; const analyticsEndpoint = yield* analyticsRecipe.endpoint("http"); - const analyticsRelay = yield* makeDockerHttpRelay(analyticsRecipe.endpoint("http")); const analyticsResponse = yield* client.execute( HttpClientRequest.get( `http://${analyticsEndpoint.host}:${analyticsEndpoint.port}/health`, ), ); expect(analyticsResponse.status).toBe(200); + yield* analytics.stop; + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), + { timeout: 120_000 }, + ); + + it.live( + "serves Vector against the owned Analytics endpoint", + () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const client = yield* HttpClient.HttpClient; + const analyticsRecipe = yield* makeServiceRecipe( + { + service: "analytics", + config: { databaseUrl, backend: "postgres", apiKey: "catalog-analytics" }, + }, + dockerOptions(root), + ); + const analytics = yield* makeService(analyticsRecipe.definition, { + id: "analytics", + config: analyticsRecipe.creation, + }); + yield* analytics.start; + yield* analytics.ready; + const analyticsRelay = yield* makeDockerHttpRelay(analyticsRecipe.endpoint("http")); const vectorRecipe = yield* makeServiceRecipe( { @@ -101,6 +148,20 @@ describe("service catalog", () => { ); expect(vectorResponse.status).toBe(200); + yield* vector.stop; + yield* analytics.stop; + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), + { timeout: 120_000 }, + ); + + it.live( + "serves Imgproxy with its real endpoint", + () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const client = yield* HttpClient.HttpClient; const imageRoot = `${root}/images`; yield* fs.makeDirectory(imageRoot, { recursive: true }); const imgproxyRecipe = yield* makeServiceRecipe( @@ -120,11 +181,7 @@ describe("service catalog", () => { ), ); expect(imgproxyResponse.status).toBe(200); - yield* imgproxy.stop; - yield* vector.stop; - yield* analytics.stop; - yield* database.stop; }), ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), { timeout: 120_000 }, diff --git a/packages/stack/src/services/Pooler.integration.test.ts b/packages/stack/src/services/Pooler.integration.test.ts index d6e0866bc4..5b8b4b7603 100644 --- a/packages/stack/src/services/Pooler.integration.test.ts +++ b/packages/stack/src/services/Pooler.integration.test.ts @@ -1,8 +1,8 @@ import { NodeHttpClient, NodeServices } from "@effect/platform-node"; -import { describe, expect, it } from "@effect/vitest"; -import { Context, Effect, Layer, Redacted } from "effect"; +import { afterAll, beforeAll, describe, expect, it } from "@effect/vitest"; +import { Context, Effect, Exit, Layer, Redacted, Scope } from "effect"; import { PgClient } from "@effect/sql-pg"; -import { makeService } from "../Service.ts"; +import { makeService, type ServiceInstance } from "../Service.ts"; import { ProxyError } from "../Proxy.ts"; import { makeServiceRecipe } from "./Catalog.ts"; import { makeDockerTcpRelay } from "../../tests/docker-relay.ts"; @@ -22,40 +22,64 @@ const dockerOptions = (root: string) => ({ runtime: "docker" as const, }); +const secret = "catalog-pooler-secret-with-at-least-32-chars"; + +let scope: Scope.Closeable; +let root: string; +let database: ServiceInstance; +let nativeDatabaseUrl: string; +let dockerDatabaseUrl: string; + describe("service catalog", () => { - it.live( - "serves Pooler SQL against the owned database", - () => - Effect.scoped( - Effect.gen(function* () { - const root = yield* makeDockerDatabaseRoot("catalog-pooler-"); - const secret = "catalog-pooler-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* makeService(databaseRecipe.definition, { - id: "database", - config: databaseRecipe.creation, - }); - yield* database.start; - yield* database.ready; - const databaseEndpoint = yield* databaseRecipe.endpoint("sql"); - if (databaseEndpoint.kind !== "tcp" || databaseEndpoint.host === undefined) - return yield* new ProxyError({ message: "Docker database did not expose TCP" }); - const databaseRelay = yield* makeDockerTcpRelay(databaseRecipe.endpoint("sql")); - const dockerDatabaseUrl = `postgresql://supabase_admin:postgres@${databaseRelay.host}:${databaseRelay.port}/_supabase`; - const nativeDatabaseUrl = `postgresql://supabase_admin:postgres@${databaseEndpoint.host}:${databaseEndpoint.port}/_supabase`; - for (const runtime of ["native", "docker"] as const) { - for (const poolMode of ["transaction", "session"] as const) { + beforeAll(() => { + scope = Scope.makeUnsafe(); + return Effect.gen(function* () { + const databaseRoot = yield* makeDockerDatabaseRoot("catalog-pooler-"); + const databaseRecipe = yield* makeServiceRecipe( + { + service: "database", + config: { + version: "17", + databasePassword: Redacted.make("postgres"), + jwtSecret: Redacted.make(secret), + jwtExpiry: 3600, + }, + }, + dockerOptions(databaseRoot), + ); + const databaseService = yield* makeService(databaseRecipe.definition, { + id: "database", + config: databaseRecipe.creation, + }); + yield* databaseService.start; + yield* databaseService.ready; + const databaseEndpoint = yield* databaseRecipe.endpoint("sql"); + if (databaseEndpoint.kind !== "tcp" || databaseEndpoint.host === undefined) + return yield* new ProxyError({ message: "Docker database did not expose TCP" }); + const databaseRelay = yield* makeDockerTcpRelay(databaseRecipe.endpoint("sql")); + root = databaseRoot; + database = databaseService; + dockerDatabaseUrl = `postgresql://supabase_admin:postgres@${databaseRelay.host}:${databaseRelay.port}/_supabase`; + nativeDatabaseUrl = `postgresql://supabase_admin:postgres@${databaseEndpoint.host}:${databaseEndpoint.port}/_supabase`; + }).pipe( + Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp)), + Scope.provide(scope), + Effect.runPromise, + ); + }, 120_000); + + afterAll( + () => database.stop.pipe(Effect.andThen(Scope.close(scope, Exit.void)), Effect.runPromise), + 60_000, + ); + + for (const runtime of ["native", "docker"] as const) { + for (const poolMode of ["transaction", "session"] as const) { + it.live( + `serves Pooler SQL against the owned database (${runtime} runtime, ${poolMode} pool mode)`, + () => + Effect.scoped( + Effect.gen(function* () { const tenant = `catalog-${runtime}-${poolMode}`; const poolerRecipe = yield* makeServiceRecipe( { @@ -98,11 +122,10 @@ describe("service catalog", () => { expect(rows[0]?.value).toBe(1); } yield* pooler.stop; - } - } - yield* database.stop; - }), - ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), - { timeout: 120_000 }, - ); + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), + { timeout: 120_000 }, + ); + } + } }); diff --git a/packages/stack/src/services/Realtime.integration.test.ts b/packages/stack/src/services/Realtime.integration.test.ts index 3d495a5ef9..ee42a1b02e 100644 --- a/packages/stack/src/services/Realtime.integration.test.ts +++ b/packages/stack/src/services/Realtime.integration.test.ts @@ -1,8 +1,8 @@ import { NodeHttpClient, NodeServices } from "@effect/platform-node"; -import { describe, expect, it } from "@effect/vitest"; -import { Effect, Layer, Redacted } from "effect"; +import { afterAll, beforeAll, describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Layer, Redacted, Scope } from "effect"; import { HttpClient, HttpClientRequest } from "effect/unstable/http"; -import { makeService } from "../Service.ts"; +import { makeService, type ServiceInstance } from "../Service.ts"; import { makeServiceRecipe } from "./Catalog.ts"; import { makeDockerHttpRelay, makeDockerTcpRelay } from "../../tests/docker-relay.ts"; import { makeDockerDatabaseRoot } from "../../tests/docker-fixture.ts"; @@ -20,36 +20,58 @@ const dockerOptions = (root: string) => ({ runtime: "docker" as const, }); +const secret = "catalog-optional-api-secret-with-at-least-32-chars"; + +let scope: Scope.Closeable; +let root: string; +let database: ServiceInstance; +let databaseUrl: string; + describe("service catalog", () => { + beforeAll(() => { + scope = Scope.makeUnsafe(); + return Effect.gen(function* () { + const databaseRoot = yield* makeDockerDatabaseRoot("catalog-optional-api-"); + const databaseRecipe = yield* makeServiceRecipe( + { + service: "database", + config: { + version: "17", + databasePassword: Redacted.make("postgres"), + jwtSecret: Redacted.make(secret), + jwtExpiry: 3600, + }, + }, + dockerOptions(databaseRoot), + ); + const databaseService = yield* makeService(databaseRecipe.definition, { + id: "database", + config: databaseRecipe.creation, + }); + yield* databaseService.start; + yield* databaseService.ready; + const databaseRelay = yield* makeDockerTcpRelay(databaseRecipe.endpoint("sql")); + root = databaseRoot; + database = databaseService; + databaseUrl = `postgresql://supabase_admin:postgres@${databaseRelay.host}:${databaseRelay.port}/postgres`; + }).pipe( + Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp)), + Scope.provide(scope), + Effect.runPromise, + ); + }, 120_000); + + afterAll( + () => database.stop.pipe(Effect.andThen(Scope.close(scope, Exit.void)), Effect.runPromise), + 60_000, + ); + it.live( - "serves Realtime, pg-meta, and Studio against the owned database", + "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* makeService(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`; - const realtimeRecipe = yield* makeServiceRecipe( { service: "realtime", config: { databaseUrl, jwtSecret: secret } }, dockerOptions(root), @@ -67,7 +89,18 @@ describe("service catalog", () => { ), ); expect(realtimeResponse.status).toBe(200); + yield* realtime.stop; + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), + { timeout: 120_000 }, + ); + it.live( + "serves pg-meta against the owned database", + () => + Effect.scoped( + Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; const pgmetaRecipe = yield* makeServiceRecipe( { service: "pgmeta", config: { databaseUrl } }, dockerOptions(root), @@ -79,12 +112,34 @@ describe("service catalog", () => { 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://${pgmetaEndpoint.host}:${pgmetaEndpoint.port}/schemas`), ); expect(schemasResponse.status).toBe(200); expect(yield* schemasResponse.text).toContain("public"); + yield* pgmeta.stop; + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), + { timeout: 120_000 }, + ); + + it.live( + "serves Studio backed by pg-meta against the owned database", + () => + Effect.scoped( + Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + const pgmetaRecipe = yield* makeServiceRecipe( + { service: "pgmeta", config: { databaseUrl } }, + dockerOptions(root), + ); + const pgmeta = yield* makeService(pgmetaRecipe.definition, { + id: "pgmeta", + config: pgmetaRecipe.creation, + }); + yield* pgmeta.start; + yield* pgmeta.ready; + const pgmetaRelay = yield* makeDockerHttpRelay(pgmetaRecipe.endpoint("http")); const studioRecipe = yield* makeServiceRecipe( { @@ -123,8 +178,6 @@ describe("service catalog", () => { yield* studio.stop; yield* pgmeta.stop; - yield* realtime.stop; - yield* database.stop; }), ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), { timeout: 120_000 }, From 02f9c3ca36acddbfe6118fb8654e9177b7ae7104 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 1 Oct 2026 16:11:17 +0200 Subject: [PATCH 2/4] test(stack): make the shared database fixture teardown unconditional Guard the shared database instance as optional and assign it before start so afterAll can still stop it when a later setup step fails, and close the fixture scope unconditionally via Effect.ensuring so a stop failure never skips cleanup. Hold the setup effect's own fiber and interrupt it from afterAll before closing the scope, so a slow or hung setup cannot keep running or race the teardown. --- ...nalyticsVectorImgproxy.integration.test.ts | 21 +++++++++++++------ .../src/services/Pooler.integration.test.ts | 21 +++++++++++++------ .../src/services/Realtime.integration.test.ts | 21 +++++++++++++------ 3 files changed, 45 insertions(+), 18 deletions(-) diff --git a/packages/stack/src/services/AnalyticsVectorImgproxy.integration.test.ts b/packages/stack/src/services/AnalyticsVectorImgproxy.integration.test.ts index e2e0845830..0cf3a8e654 100644 --- a/packages/stack/src/services/AnalyticsVectorImgproxy.integration.test.ts +++ b/packages/stack/src/services/AnalyticsVectorImgproxy.integration.test.ts @@ -1,6 +1,6 @@ import { NodeHttpClient, NodeServices } from "@effect/platform-node"; import { afterAll, beforeAll, describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, FileSystem, Layer, Redacted, Scope } from "effect"; +import { Effect, Exit, Fiber, FileSystem, Layer, Redacted, Scope } from "effect"; import { HttpClient, HttpClientRequest } from "effect/unstable/http"; import { makeService, type ServiceInstance } from "../Service.ts"; import { makeServiceRecipe } from "./Catalog.ts"; @@ -23,14 +23,15 @@ const dockerOptions = (root: string) => ({ const secret = "catalog-optional-data-secret-with-at-least-32-chars"; let scope: Scope.Closeable; +let setupFiber: Fiber.Fiber; +let database: ServiceInstance | undefined; let root: string; -let database: ServiceInstance; let databaseUrl: string; describe("service catalog", () => { beforeAll(() => { scope = Scope.makeUnsafe(); - return Effect.gen(function* () { + const setup = Effect.gen(function* () { const databaseRoot = yield* makeDockerDatabaseRoot("catalog-optional-data-"); const databaseRecipe = yield* makeServiceRecipe( { @@ -48,21 +49,29 @@ describe("service catalog", () => { id: "database", config: databaseRecipe.creation, }); + // Assigned before start so afterAll can still stop it if a later step fails. + database = databaseService; yield* databaseService.start; yield* databaseService.ready; const databaseRelay = yield* makeDockerTcpRelay(databaseRecipe.endpoint("sql")); root = databaseRoot; - database = databaseService; databaseUrl = `postgresql://supabase_admin:postgres@${databaseRelay.host}:${databaseRelay.port}/postgres`; }).pipe( Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp)), Scope.provide(scope), - Effect.runPromise, + Effect.orDie, ); + setupFiber = Effect.runFork(setup); + return Fiber.join(setupFiber).pipe(Effect.runPromise); }, 120_000); afterAll( - () => database.stop.pipe(Effect.andThen(Scope.close(scope, Exit.void)), Effect.runPromise), + () => + Fiber.interrupt(setupFiber).pipe( + Effect.andThen(() => (database === undefined ? Effect.void : Effect.ignore(database.stop))), + Effect.ensuring(Scope.close(scope, Exit.void)), + Effect.runPromise, + ), 60_000, ); diff --git a/packages/stack/src/services/Pooler.integration.test.ts b/packages/stack/src/services/Pooler.integration.test.ts index 5b8b4b7603..4c3be54acf 100644 --- a/packages/stack/src/services/Pooler.integration.test.ts +++ b/packages/stack/src/services/Pooler.integration.test.ts @@ -1,6 +1,6 @@ import { NodeHttpClient, NodeServices } from "@effect/platform-node"; import { afterAll, beforeAll, describe, expect, it } from "@effect/vitest"; -import { Context, Effect, Exit, Layer, Redacted, Scope } from "effect"; +import { Context, Effect, Exit, Fiber, Layer, Redacted, Scope } from "effect"; import { PgClient } from "@effect/sql-pg"; import { makeService, type ServiceInstance } from "../Service.ts"; import { ProxyError } from "../Proxy.ts"; @@ -25,15 +25,16 @@ const dockerOptions = (root: string) => ({ const secret = "catalog-pooler-secret-with-at-least-32-chars"; let scope: Scope.Closeable; +let setupFiber: Fiber.Fiber; +let database: ServiceInstance | undefined; let root: string; -let database: ServiceInstance; let nativeDatabaseUrl: string; let dockerDatabaseUrl: string; describe("service catalog", () => { beforeAll(() => { scope = Scope.makeUnsafe(); - return Effect.gen(function* () { + const setup = Effect.gen(function* () { const databaseRoot = yield* makeDockerDatabaseRoot("catalog-pooler-"); const databaseRecipe = yield* makeServiceRecipe( { @@ -51,6 +52,8 @@ describe("service catalog", () => { id: "database", config: databaseRecipe.creation, }); + // Assigned before start so afterAll can still stop it if a later step fails. + database = databaseService; yield* databaseService.start; yield* databaseService.ready; const databaseEndpoint = yield* databaseRecipe.endpoint("sql"); @@ -58,18 +61,24 @@ describe("service catalog", () => { return yield* new ProxyError({ message: "Docker database did not expose TCP" }); const databaseRelay = yield* makeDockerTcpRelay(databaseRecipe.endpoint("sql")); root = databaseRoot; - database = databaseService; dockerDatabaseUrl = `postgresql://supabase_admin:postgres@${databaseRelay.host}:${databaseRelay.port}/_supabase`; nativeDatabaseUrl = `postgresql://supabase_admin:postgres@${databaseEndpoint.host}:${databaseEndpoint.port}/_supabase`; }).pipe( Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp)), Scope.provide(scope), - Effect.runPromise, + Effect.orDie, ); + setupFiber = Effect.runFork(setup); + return Fiber.join(setupFiber).pipe(Effect.runPromise); }, 120_000); afterAll( - () => database.stop.pipe(Effect.andThen(Scope.close(scope, Exit.void)), Effect.runPromise), + () => + Fiber.interrupt(setupFiber).pipe( + Effect.andThen(() => (database === undefined ? Effect.void : Effect.ignore(database.stop))), + Effect.ensuring(Scope.close(scope, Exit.void)), + Effect.runPromise, + ), 60_000, ); diff --git a/packages/stack/src/services/Realtime.integration.test.ts b/packages/stack/src/services/Realtime.integration.test.ts index ee42a1b02e..3da029faff 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 { afterAll, beforeAll, describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Layer, Redacted, Scope } from "effect"; +import { Effect, Exit, Fiber, Layer, Redacted, Scope } from "effect"; import { HttpClient, HttpClientRequest } from "effect/unstable/http"; import { makeService, type ServiceInstance } from "../Service.ts"; import { makeServiceRecipe } from "./Catalog.ts"; @@ -23,14 +23,15 @@ const dockerOptions = (root: string) => ({ const secret = "catalog-optional-api-secret-with-at-least-32-chars"; let scope: Scope.Closeable; +let setupFiber: Fiber.Fiber; +let database: ServiceInstance | undefined; let root: string; -let database: ServiceInstance; let databaseUrl: string; describe("service catalog", () => { beforeAll(() => { scope = Scope.makeUnsafe(); - return Effect.gen(function* () { + const setup = Effect.gen(function* () { const databaseRoot = yield* makeDockerDatabaseRoot("catalog-optional-api-"); const databaseRecipe = yield* makeServiceRecipe( { @@ -48,21 +49,29 @@ describe("service catalog", () => { id: "database", config: databaseRecipe.creation, }); + // Assigned before start so afterAll can still stop it if a later step fails. + database = databaseService; yield* databaseService.start; yield* databaseService.ready; const databaseRelay = yield* makeDockerTcpRelay(databaseRecipe.endpoint("sql")); root = databaseRoot; - database = databaseService; databaseUrl = `postgresql://supabase_admin:postgres@${databaseRelay.host}:${databaseRelay.port}/postgres`; }).pipe( Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp)), Scope.provide(scope), - Effect.runPromise, + Effect.orDie, ); + setupFiber = Effect.runFork(setup); + return Fiber.join(setupFiber).pipe(Effect.runPromise); }, 120_000); afterAll( - () => database.stop.pipe(Effect.andThen(Scope.close(scope, Exit.void)), Effect.runPromise), + () => + Fiber.interrupt(setupFiber).pipe( + Effect.andThen(() => (database === undefined ? Effect.void : Effect.ignore(database.stop))), + Effect.ensuring(Scope.close(scope, Exit.void)), + Effect.runPromise, + ), 60_000, ); From 58cc86ebdf53165921c93723d0f257fc1ba09d11 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 1 Oct 2026 16:53:26 +0200 Subject: [PATCH 3/4] test(stack): surface database stop failures in catalog test teardown Stop the shared database directly instead of ignoring its result, so a failed stop surfaces in afterAll the way the original tests did. The fixture scope still closes unconditionally via Effect.ensuring, so cleanup runs whether or not the stop succeeds. --- .../src/services/AnalyticsVectorImgproxy.integration.test.ts | 2 +- packages/stack/src/services/Pooler.integration.test.ts | 2 +- packages/stack/src/services/Realtime.integration.test.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/stack/src/services/AnalyticsVectorImgproxy.integration.test.ts b/packages/stack/src/services/AnalyticsVectorImgproxy.integration.test.ts index 0cf3a8e654..1ccfba28d6 100644 --- a/packages/stack/src/services/AnalyticsVectorImgproxy.integration.test.ts +++ b/packages/stack/src/services/AnalyticsVectorImgproxy.integration.test.ts @@ -68,7 +68,7 @@ describe("service catalog", () => { afterAll( () => Fiber.interrupt(setupFiber).pipe( - Effect.andThen(() => (database === undefined ? Effect.void : Effect.ignore(database.stop))), + Effect.andThen(() => (database === undefined ? Effect.void : database.stop)), Effect.ensuring(Scope.close(scope, Exit.void)), Effect.runPromise, ), diff --git a/packages/stack/src/services/Pooler.integration.test.ts b/packages/stack/src/services/Pooler.integration.test.ts index 4c3be54acf..88c37fb3c0 100644 --- a/packages/stack/src/services/Pooler.integration.test.ts +++ b/packages/stack/src/services/Pooler.integration.test.ts @@ -75,7 +75,7 @@ describe("service catalog", () => { afterAll( () => Fiber.interrupt(setupFiber).pipe( - Effect.andThen(() => (database === undefined ? Effect.void : Effect.ignore(database.stop))), + Effect.andThen(() => (database === undefined ? Effect.void : database.stop)), Effect.ensuring(Scope.close(scope, Exit.void)), Effect.runPromise, ), diff --git a/packages/stack/src/services/Realtime.integration.test.ts b/packages/stack/src/services/Realtime.integration.test.ts index 3da029faff..157c073e92 100644 --- a/packages/stack/src/services/Realtime.integration.test.ts +++ b/packages/stack/src/services/Realtime.integration.test.ts @@ -68,7 +68,7 @@ describe("service catalog", () => { afterAll( () => Fiber.interrupt(setupFiber).pipe( - Effect.andThen(() => (database === undefined ? Effect.void : Effect.ignore(database.stop))), + Effect.andThen(() => (database === undefined ? Effect.void : database.stop)), Effect.ensuring(Scope.close(scope, Exit.void)), Effect.runPromise, ), From edd77b7ceea3db82421e52237d57672922e00188 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Fri, 2 Oct 2026 12:43:40 +0200 Subject: [PATCH 4/4] test(stack): leave the Pooler catalog test to its dedicated split The Pooler integration test is split by runtime and pool mode in its own change, so this one no longer rewrites it. --- .../src/services/Pooler.integration.test.ts | 116 +++++++----------- 1 file changed, 42 insertions(+), 74 deletions(-) diff --git a/packages/stack/src/services/Pooler.integration.test.ts b/packages/stack/src/services/Pooler.integration.test.ts index 88c37fb3c0..d6e0866bc4 100644 --- a/packages/stack/src/services/Pooler.integration.test.ts +++ b/packages/stack/src/services/Pooler.integration.test.ts @@ -1,8 +1,8 @@ import { NodeHttpClient, NodeServices } from "@effect/platform-node"; -import { afterAll, beforeAll, describe, expect, it } from "@effect/vitest"; -import { Context, Effect, Exit, Fiber, Layer, Redacted, Scope } from "effect"; +import { describe, expect, it } from "@effect/vitest"; +import { Context, Effect, Layer, Redacted } from "effect"; import { PgClient } from "@effect/sql-pg"; -import { makeService, type ServiceInstance } from "../Service.ts"; +import { makeService } from "../Service.ts"; import { ProxyError } from "../Proxy.ts"; import { makeServiceRecipe } from "./Catalog.ts"; import { makeDockerTcpRelay } from "../../tests/docker-relay.ts"; @@ -22,73 +22,40 @@ const dockerOptions = (root: string) => ({ runtime: "docker" as const, }); -const secret = "catalog-pooler-secret-with-at-least-32-chars"; - -let scope: Scope.Closeable; -let setupFiber: Fiber.Fiber; -let database: ServiceInstance | undefined; -let root: string; -let nativeDatabaseUrl: string; -let dockerDatabaseUrl: string; - describe("service catalog", () => { - beforeAll(() => { - scope = Scope.makeUnsafe(); - const setup = Effect.gen(function* () { - const databaseRoot = yield* makeDockerDatabaseRoot("catalog-pooler-"); - const databaseRecipe = yield* makeServiceRecipe( - { - service: "database", - config: { - version: "17", - databasePassword: Redacted.make("postgres"), - jwtSecret: Redacted.make(secret), - jwtExpiry: 3600, - }, - }, - dockerOptions(databaseRoot), - ); - const databaseService = yield* makeService(databaseRecipe.definition, { - id: "database", - config: databaseRecipe.creation, - }); - // Assigned before start so afterAll can still stop it if a later step fails. - database = databaseService; - yield* databaseService.start; - yield* databaseService.ready; - const databaseEndpoint = yield* databaseRecipe.endpoint("sql"); - if (databaseEndpoint.kind !== "tcp" || databaseEndpoint.host === undefined) - return yield* new ProxyError({ message: "Docker database did not expose TCP" }); - const databaseRelay = yield* makeDockerTcpRelay(databaseRecipe.endpoint("sql")); - root = databaseRoot; - dockerDatabaseUrl = `postgresql://supabase_admin:postgres@${databaseRelay.host}:${databaseRelay.port}/_supabase`; - nativeDatabaseUrl = `postgresql://supabase_admin:postgres@${databaseEndpoint.host}:${databaseEndpoint.port}/_supabase`; - }).pipe( - Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp)), - Scope.provide(scope), - Effect.orDie, - ); - setupFiber = Effect.runFork(setup); - return Fiber.join(setupFiber).pipe(Effect.runPromise); - }, 120_000); - - afterAll( + it.live( + "serves Pooler SQL against the owned database", () => - Fiber.interrupt(setupFiber).pipe( - Effect.andThen(() => (database === undefined ? Effect.void : database.stop)), - Effect.ensuring(Scope.close(scope, Exit.void)), - Effect.runPromise, - ), - 60_000, - ); - - for (const runtime of ["native", "docker"] as const) { - for (const poolMode of ["transaction", "session"] as const) { - it.live( - `serves Pooler SQL against the owned database (${runtime} runtime, ${poolMode} pool mode)`, - () => - Effect.scoped( - Effect.gen(function* () { + Effect.scoped( + Effect.gen(function* () { + const root = yield* makeDockerDatabaseRoot("catalog-pooler-"); + const secret = "catalog-pooler-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* makeService(databaseRecipe.definition, { + id: "database", + config: databaseRecipe.creation, + }); + yield* database.start; + yield* database.ready; + const databaseEndpoint = yield* databaseRecipe.endpoint("sql"); + if (databaseEndpoint.kind !== "tcp" || databaseEndpoint.host === undefined) + return yield* new ProxyError({ message: "Docker database did not expose TCP" }); + const databaseRelay = yield* makeDockerTcpRelay(databaseRecipe.endpoint("sql")); + const dockerDatabaseUrl = `postgresql://supabase_admin:postgres@${databaseRelay.host}:${databaseRelay.port}/_supabase`; + const nativeDatabaseUrl = `postgresql://supabase_admin:postgres@${databaseEndpoint.host}:${databaseEndpoint.port}/_supabase`; + for (const runtime of ["native", "docker"] as const) { + for (const poolMode of ["transaction", "session"] as const) { const tenant = `catalog-${runtime}-${poolMode}`; const poolerRecipe = yield* makeServiceRecipe( { @@ -131,10 +98,11 @@ describe("service catalog", () => { expect(rows[0]?.value).toBe(1); } yield* pooler.stop; - }), - ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), - { timeout: 120_000 }, - ); - } - } + } + } + yield* database.stop; + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), + { timeout: 120_000 }, + ); });