From 882f95157120bc8df7a79f1a6291ae8e45596d67 Mon Sep 17 00:00:00 2001 From: Pavel Shirshov Date: Wed, 13 May 2026 19:51:33 +0100 Subject: [PATCH 1/7] wip --- .gitignore | 10 + README.md | 44 ++- .../leaderboard/sim/SimulationMain.scala | 88 +++++ .../src/main/resources/common-reference.conf | 0 .../jvm/src/main/resources/webapp/index.html | 149 ++++++++ .../scala/leaderboard/LeaderboardRole.scala | 0 .../leaderboard/config/PostgresCfg.scala | 0 .../leaderboard/config/PostgresPortCfg.scala | 0 .../scala/leaderboard/http/HttpServer.scala | 53 +++ .../plugins/LeaderboardPlugin.scala | 38 +- .../plugins/PostgresDockerPlugin.scala | 0 .../leaderboard/repo/LadderPostgres.scala | 42 +++ .../leaderboard/repo/ProfilesPostgres.scala | 47 +++ .../src/main/scala/leaderboard/sql/SQL.scala | 0 .../leaderboard/sql/TransactorResource.scala | 0 .../src/test/scala/leaderboard/Rnd.scala | 0 .../test/scala/leaderboard/WiringTest.scala | 0 .../src/test/scala/leaderboard/tests.scala | 0 .../src/test/scala/leaderboard/zioenv.scala | 0 .../main/scala/leaderboard/api/HttpApi.scala | 0 .../scala/leaderboard/api/LadderApi.scala | 0 .../scala/leaderboard/api/ProfileApi.scala | 0 .../dispatch/LocalDispatcher.scala | 45 +++ .../leaderboard/model/QueryFailure.scala | 0 .../leaderboard/model/RankedProfile.scala | 0 .../scala/leaderboard/model/UserProfile.scala | 0 .../scala/leaderboard/model/package.scala | 0 .../plugins/LeaderboardCoreModule.scala | 46 +++ .../main/scala/leaderboard/repo/Ladder.scala | 26 ++ .../scala/leaderboard/repo/Profiles.scala | 26 ++ .../scala/leaderboard/services/Ranks.scala | 0 .../scala/leaderboard/http/HttpServer.scala | 35 -- .../main/scala/leaderboard/repo/Ladder.scala | 60 ---- .../scala/leaderboard/repo/Profiles.scala | 65 ---- build.sbt | 161 +++++++-- ...60513-1757-questions-scalajs-simulation.md | 337 ++++++++++++++++++ launch-sim | 12 + launcher | 2 +- project/plugins.sbt | 2 + 39 files changed, 1055 insertions(+), 233 deletions(-) create mode 100644 bifunctor-tagless/js/src/main/scala/leaderboard/sim/SimulationMain.scala rename bifunctor-tagless/{ => jvm}/src/main/resources/common-reference.conf (100%) create mode 100644 bifunctor-tagless/jvm/src/main/resources/webapp/index.html rename bifunctor-tagless/{ => jvm}/src/main/scala/leaderboard/LeaderboardRole.scala (100%) rename bifunctor-tagless/{ => jvm}/src/main/scala/leaderboard/config/PostgresCfg.scala (100%) rename bifunctor-tagless/{ => jvm}/src/main/scala/leaderboard/config/PostgresPortCfg.scala (100%) create mode 100644 bifunctor-tagless/jvm/src/main/scala/leaderboard/http/HttpServer.scala rename bifunctor-tagless/{ => jvm}/src/main/scala/leaderboard/plugins/LeaderboardPlugin.scala (66%) rename bifunctor-tagless/{ => jvm}/src/main/scala/leaderboard/plugins/PostgresDockerPlugin.scala (100%) create mode 100644 bifunctor-tagless/jvm/src/main/scala/leaderboard/repo/LadderPostgres.scala create mode 100644 bifunctor-tagless/jvm/src/main/scala/leaderboard/repo/ProfilesPostgres.scala rename bifunctor-tagless/{ => jvm}/src/main/scala/leaderboard/sql/SQL.scala (100%) rename bifunctor-tagless/{ => jvm}/src/main/scala/leaderboard/sql/TransactorResource.scala (100%) rename bifunctor-tagless/{ => jvm}/src/test/scala/leaderboard/Rnd.scala (100%) rename bifunctor-tagless/{ => jvm}/src/test/scala/leaderboard/WiringTest.scala (100%) rename bifunctor-tagless/{ => jvm}/src/test/scala/leaderboard/tests.scala (100%) rename bifunctor-tagless/{ => jvm}/src/test/scala/leaderboard/zioenv.scala (100%) rename bifunctor-tagless/{ => shared}/src/main/scala/leaderboard/api/HttpApi.scala (100%) rename bifunctor-tagless/{ => shared}/src/main/scala/leaderboard/api/LadderApi.scala (100%) rename bifunctor-tagless/{ => shared}/src/main/scala/leaderboard/api/ProfileApi.scala (100%) create mode 100644 bifunctor-tagless/shared/src/main/scala/leaderboard/dispatch/LocalDispatcher.scala rename bifunctor-tagless/{ => shared}/src/main/scala/leaderboard/model/QueryFailure.scala (100%) rename bifunctor-tagless/{ => shared}/src/main/scala/leaderboard/model/RankedProfile.scala (100%) rename bifunctor-tagless/{ => shared}/src/main/scala/leaderboard/model/UserProfile.scala (100%) rename bifunctor-tagless/{ => shared}/src/main/scala/leaderboard/model/package.scala (100%) create mode 100644 bifunctor-tagless/shared/src/main/scala/leaderboard/plugins/LeaderboardCoreModule.scala create mode 100644 bifunctor-tagless/shared/src/main/scala/leaderboard/repo/Ladder.scala create mode 100644 bifunctor-tagless/shared/src/main/scala/leaderboard/repo/Profiles.scala rename bifunctor-tagless/{ => shared}/src/main/scala/leaderboard/services/Ranks.scala (100%) delete mode 100644 bifunctor-tagless/src/main/scala/leaderboard/http/HttpServer.scala delete mode 100644 bifunctor-tagless/src/main/scala/leaderboard/repo/Ladder.scala delete mode 100644 bifunctor-tagless/src/main/scala/leaderboard/repo/Profiles.scala create mode 100644 docs/drafts/20260513-1757-questions-scalajs-simulation.md create mode 100755 launch-sim diff --git a/.gitignore b/.gitignore index 7c003ed..a8b5168 100644 --- a/.gitignore +++ b/.gitignore @@ -126,3 +126,13 @@ hs_err_pid* ### Scala template *.class *.log + +# Project-local sbt/coursier/ivy caches used by the containerised dev loop. +.cache/ + +# Scala.js bundle copied here by the `copySimJs` sbt task — regenerated on +# demand from bifunctor-tagless-js. Not checked in. +bifunctor-tagless/jvm/src/main/resources/webapp/main.js +bifunctor-tagless/jvm/src/main/resources/webapp/*.js.map +bifunctor-tagless/jvm/src/main/resources/webapp/internal-*.js + diff --git a/README.md b/README.md index 93fa7f2..3bc7428 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,44 @@ curl -X GET http://localhost:8080/ladder curl -X GET http://localhost:8080/profile/50753a00-5e2e-4a2f-94b0-e6721b0a3cc4 ``` +### Perfect simulation in the browser (`bifunctor-tagless` only) + +The `bifunctor-tagless` variant is cross-built for the JVM and Scala.js. The +same `LadderApi`/`ProfileApi` http4s routes that the JVM server exposes are +also assembled in the browser into an in-process `LocalDispatcher`, which a +`@JSExportTopLevel("LeaderboardSim")` object surfaces to JavaScript. The JS +graph is configured with `Repo -> Dummy`, so it uses the same in-memory +repositories that the JVM tests use — no network, no postgres, no docker. + +A small demo UI in `bifunctor-tagless/jvm/src/main/resources/webapp/` lets you +call each endpoint with a radio toggle between **production** (real HTTP) and +**simulation** (the in-page Scala.js build). + +To use it, run the single convenience script — it builds the Scala.js bundle, +copies it next to the UI, and starts the server in dummy mode: + +```bash +./launch-sim +``` + +Then open . You can also open +`bifunctor-tagless/jvm/src/main/resources/webapp/index.html` directly via +`file://` (CORS on the server allows the `null` origin used by `file://`). + +If you prefer the steps separately: + +```bash +sbt copySimJs # build + copy the Scala.js bundle +./launcher -u repo:dummy :leaderboard # start the server +``` + +The "Production" radio talks to `http://localhost:8080`; the "Simulation" +radio calls `window.LeaderboardSim.call(method, path, body)`, which runs the +exact same request through the in-browser http4s routes. State only persists +within each mode — flipping back and forth is itself a useful demonstration +that the simulation is a clean process that knows nothing about the real +server's state. + #### Note If `./launcher` command fails for you with some cryptic stack trace, there's most likely an issue with your Docker. First of all, check that you have `docker` and `contrainerd` daemons running. If you're using something else than Ubuntu, please stick to the relevant [installation page](https://docs.docker.com/engine/install/): @@ -61,7 +99,7 @@ Both of them should have `Active: active (running)` status. If your problem isn' Use `sbt` to build a native Linux binary with GraalVM NativeImage under Docker: ```bash -sbt bifunctor-tagless/GraalVMNativeImage/packageBin +sbt bifunctor-taglessJVM/GraalVMNativeImage/packageBin ``` If you want to build the app using local `native-image` executable (e.g. on a Mac), comment out the `graalVMNativeImageGraalVersion` key in `build.sbt` first. @@ -69,13 +107,13 @@ If you want to build the app using local `native-image` executable (e.g. on a Ma To test the native app with dummy repositories run: ```bash -./bifunctor-tagless/target/graalvm-native-image/bifunctor-tagless -u scene:managed -u repo:dummy :leaderboard +./bifunctor-tagless/jvm/target/graalvm-native-image/bifunctor-tagless -u scene:managed -u repo:dummy :leaderboard ``` To test the native app with production repositories in Docker run: ```bash -./bifunctor-tagless/target/graalvm-native-image/bifunctor-tagless -u scene:managed -u repo:prod :leaderboard +./bifunctor-tagless/jvm/target/graalvm-native-image/bifunctor-tagless -u scene:managed -u repo:prod :leaderboard ``` Notes: diff --git a/bifunctor-tagless/js/src/main/scala/leaderboard/sim/SimulationMain.scala b/bifunctor-tagless/js/src/main/scala/leaderboard/sim/SimulationMain.scala new file mode 100644 index 0000000..f37eff3 --- /dev/null +++ b/bifunctor-tagless/js/src/main/scala/leaderboard/sim/SimulationMain.scala @@ -0,0 +1,88 @@ +package leaderboard.sim + +import distage.StandardAxis.Repo +import distage.{Activation, Injector, ModuleDef, Roots} +import izumi.distage.modules.DefaultModule2 +import izumi.logstage.distage.LogIO2Module +import leaderboard.dispatch.LocalDispatcher +import leaderboard.plugins.LeaderboardCoreModule +import zio.{IO, Promise as ZPromise, Runtime, Unsafe, ZIO} + +import scala.concurrent.ExecutionContext +import scala.scalajs.concurrent.JSExecutionContext +import scala.scalajs.js +import scala.scalajs.js.JSConverters.* +import scala.scalajs.js.annotation.{JSExport, JSExportTopLevel} + +/** + * Entrypoint for the in-browser "perfect simulation" of the leaderboard + * backend. Boots the same distage object graph as the JVM application but + * with `Repo` axis pinned to `Dummy` (so all repository calls hit the + * in-memory implementations), then exposes the resulting [[LocalDispatcher]] + * to JavaScript as `window.LeaderboardSim.call(method, path, body)`. + * + * The dispatcher invokes the exact same `HttpRoutes` the JVM server uses, + * so the front-end can address it with the same method/path/body it would + * send via fetch() to the real backend. + */ +@JSExportTopLevel("LeaderboardSim") +object SimulationMain { + + private type G[A] = IO[Throwable, A] + + // A zio runtime — fine on JS since the default runtime does not assume any + // JVM-specific scheduler. + private val runtime: Runtime[Any] = Runtime.default + + // JS event loop — used only to materialize the cats Future returned by + // `runtime.unsafe.runToFuture` into a JavaScript Promise. + private implicit val ec: ExecutionContext = JSExecutionContext.queue + + // We resolve the dispatcher asynchronously (distage produce returns a + // resource) and surface it through this promise. Every JS `call(...)` + // awaits this before dispatching, so the front-end never sees an + // uninitialised state. + private val dispatcherReady: ZPromise[Throwable, LocalDispatcher[IO]] = + Unsafe.unsafe(implicit u => runtime.unsafe.run(ZPromise.make[Throwable, LocalDispatcher[IO]]).getOrThrow()) + + locally { + val module = new ModuleDef { + include(LeaderboardCoreModule.api[IO]) + include(LeaderboardCoreModule.repoDummy[IO]) + // LogIO2[IO] needed by ProfileApi. + include(LogIO2Module[IO]()) + // BIO typeclass instances (IO2, Async2, Primitives2, ...) used by the + // dummy repos and the dispatcher. + include(DefaultModule2[IO]) + } + + // Build the object graph and keep the resource open for the lifetime of + // the page. We never finalize — the in-memory dummy state should live as + // long as the JS module is loaded. + val program: G[Nothing] = + Injector.NoProxies[G]() + .produce(module, Roots.target[LocalDispatcher[IO]], Activation(Repo -> Repo.Dummy)) + .use { + locator => + dispatcherReady.succeed(locator.get[LocalDispatcher[IO]]) *> ZIO.never + } + .catchAll(err => dispatcherReady.fail(err) *> ZIO.never) + + Unsafe.unsafe(implicit u => runtime.unsafe.fork(program)) + } + + /** + * Dispatch a request to the simulated backend. + * + * @return a JS promise resolving to `{ status: Int, body: String }`. + */ + @JSExport + def call(method: String, path: String, body: String): js.Promise[js.Dynamic] = { + val program: G[js.Dynamic] = for { + dispatcher <- dispatcherReady.await + response <- dispatcher.call(method, path, body) + } yield js.Dynamic.literal(status = response.status, body = response.body) + + Unsafe.unsafe(implicit u => runtime.unsafe.runToFuture(program).toJSPromise) + } +} diff --git a/bifunctor-tagless/src/main/resources/common-reference.conf b/bifunctor-tagless/jvm/src/main/resources/common-reference.conf similarity index 100% rename from bifunctor-tagless/src/main/resources/common-reference.conf rename to bifunctor-tagless/jvm/src/main/resources/common-reference.conf diff --git a/bifunctor-tagless/jvm/src/main/resources/webapp/index.html b/bifunctor-tagless/jvm/src/main/resources/webapp/index.html new file mode 100644 index 0000000..0e7fc7d --- /dev/null +++ b/bifunctor-tagless/jvm/src/main/resources/webapp/index.html @@ -0,0 +1,149 @@ + + + + + distage-example leaderboard demo + + + +

distage-example leaderboard

+

+ Hit the same endpoints the README's curl snippets call. + Toggle between the real HTTP backend and an in-browser + Scala.js simulation built from the same distage object graph with + Repo → Dummy. +

+ +
+ Backend + + +

simulation: loading…

+
+ +
+
+ Submit score — POST /ladder/{user}/{score} +
+
+ +
+ +
+ Get ladder — GET /ladder + +
+ +
+ Set profile — POST /profile/{user} +
+
+
+ +
+ +
+ Get profile — GET /profile/{user} +
+ +
+
+ +

Response

+
(no calls yet)
+ + + + diff --git a/bifunctor-tagless/src/main/scala/leaderboard/LeaderboardRole.scala b/bifunctor-tagless/jvm/src/main/scala/leaderboard/LeaderboardRole.scala similarity index 100% rename from bifunctor-tagless/src/main/scala/leaderboard/LeaderboardRole.scala rename to bifunctor-tagless/jvm/src/main/scala/leaderboard/LeaderboardRole.scala diff --git a/bifunctor-tagless/src/main/scala/leaderboard/config/PostgresCfg.scala b/bifunctor-tagless/jvm/src/main/scala/leaderboard/config/PostgresCfg.scala similarity index 100% rename from bifunctor-tagless/src/main/scala/leaderboard/config/PostgresCfg.scala rename to bifunctor-tagless/jvm/src/main/scala/leaderboard/config/PostgresCfg.scala diff --git a/bifunctor-tagless/src/main/scala/leaderboard/config/PostgresPortCfg.scala b/bifunctor-tagless/jvm/src/main/scala/leaderboard/config/PostgresPortCfg.scala similarity index 100% rename from bifunctor-tagless/src/main/scala/leaderboard/config/PostgresPortCfg.scala rename to bifunctor-tagless/jvm/src/main/scala/leaderboard/config/PostgresPortCfg.scala diff --git a/bifunctor-tagless/jvm/src/main/scala/leaderboard/http/HttpServer.scala b/bifunctor-tagless/jvm/src/main/scala/leaderboard/http/HttpServer.scala new file mode 100644 index 0000000..2e1ceaa --- /dev/null +++ b/bifunctor-tagless/jvm/src/main/scala/leaderboard/http/HttpServer.scala @@ -0,0 +1,53 @@ +package leaderboard.http + +import cats.effect.Async +import cats.syntax.all.* +import com.comcast.ip4s.Port +import fs2.io.net.Network +import izumi.distage.model.definition.Lifecycle +import leaderboard.api.HttpApi +import org.http4s.HttpRoutes +import org.http4s.ember.server.EmberServerBuilder +import org.http4s.server.Server +import org.http4s.server.middleware.CORS +import org.http4s.server.staticcontent.resourceServiceBuilder + +final case class HttpServer( + server: Server +) + +object HttpServer { + + final class Impl[F[+_, +_]]( + allHttpApis: Set[HttpApi[F]] + )(implicit + async: Async[F[Throwable, _]] + ) extends Lifecycle.Of[F[Throwable, _], HttpServer]( + Lifecycle.fromCats { + type G[A] = F[Throwable, A] + + // The same HttpRoutes the LocalDispatcher invokes in-process. + val apiRoutes: HttpRoutes[G] = allHttpApis.map(_.http).toList.foldK + + // Serve /webapp resources at the root, so the demo UI is available + // at e.g. http://localhost:8080/ alongside the API endpoints. + val staticRoutes: HttpRoutes[G] = + resourceServiceBuilder[G]("/webapp").toRoutes + + // Permissive CORS so the same UI also works when opened from file:// + // (its origin is "null" in that case). Demo-grade — do not copy + // verbatim into a real production service. + val app = CORS.policy.withAllowOriginAll( + (apiRoutes <+> staticRoutes).orNotFound + ) + + EmberServerBuilder + .default(using async, Network.forAsync) + .withHttpApp(app) + .withPort(Port.fromInt(8080).get) + .build + .map(HttpServer(_)) + } + ) + +} diff --git a/bifunctor-tagless/src/main/scala/leaderboard/plugins/LeaderboardPlugin.scala b/bifunctor-tagless/jvm/src/main/scala/leaderboard/plugins/LeaderboardPlugin.scala similarity index 66% rename from bifunctor-tagless/src/main/scala/leaderboard/plugins/LeaderboardPlugin.scala rename to bifunctor-tagless/jvm/src/main/scala/leaderboard/plugins/LeaderboardPlugin.scala index 18dda71..7ff56e2 100644 --- a/bifunctor-tagless/src/main/scala/leaderboard/plugins/LeaderboardPlugin.scala +++ b/bifunctor-tagless/jvm/src/main/scala/leaderboard/plugins/LeaderboardPlugin.scala @@ -9,23 +9,22 @@ import izumi.distage.roles.bundled.BundledRolesModule import izumi.distage.roles.model.definition.RoleModuleDef import izumi.fundamentals.platform.integration.PortCheck import izumi.fundamentals.platform.versions.Version -import leaderboard.api.{HttpApi, LadderApi, ProfileApi} import leaderboard.config.{PostgresCfg, PostgresPortCfg} import leaderboard.http.HttpServer -import leaderboard.repo.{Ladder, Profiles} -import leaderboard.services.Ranks +import leaderboard.repo.{Ladder, LadderPostgres, Profiles, ProfilesPostgres} import leaderboard.sql.{SQL, TransactorResource} import leaderboard.{LadderRole, LeaderboardRole, ProfileRole} -import org.http4s.dsl.Http4sDsl import zio.IO import scala.concurrent.duration.* object LeaderboardPlugin extends PluginDef { include(modules.roles[IO]) - include(modules.api[IO]) - include(modules.repoDummy[IO]) + // Shared API + dummy repos wiring — same code that runs in the browser. + include(LeaderboardCoreModule.api[IO]) + include(LeaderboardCoreModule.repoDummy[IO]) include(modules.repoProd[IO]) + include(modules.server[IO]) include(modules.configs) include(modules.prodConfigs) @@ -44,36 +43,15 @@ object LeaderboardPlugin extends PluginDef { include(BundledRolesModule[F[Throwable, _]](version = Version.parse("1.0.0"))) } - def api[F[+_, +_]: TagKK]: ModuleDef = new ModuleDef { - // The `ladder` API - make[LadderApi[F]] - // The `profile` API - make[ProfileApi[F]] - - // A set of all APIs - many[HttpApi[F]] - .weak[LadderApi[F]] // add ladder API as a _weak reference_ - .weak[ProfileApi[F]] // add profiles API as a _weak reference_ - + def server[F[+_, +_]: TagKK]: ModuleDef = new ModuleDef { make[HttpServer].fromResource[HttpServer.Impl[F]] - - make[Ranks[F]].from[Ranks.Impl[F]] - - makeTrait[Http4sDsl[F[Throwable, _]]] - } - - def repoDummy[F[+_, +_]: TagKK]: ModuleDef = new ModuleDef { - tag(Repo.Dummy) - - make[Ladder[F]].fromResource[Ladder.Dummy[F]] - make[Profiles[F]].fromResource[Profiles.Dummy[F]] } def repoProd[F[+_, +_]: TagKK]: ModuleDef = new ModuleDef { tag(Repo.Prod) - make[Ladder[F]].fromResource[Ladder.Postgres[F]] - make[Profiles[F]].fromResource[Profiles.Postgres[F]] + make[Ladder[F]].fromResource[LadderPostgres[F]] + make[Profiles[F]].fromResource[ProfilesPostgres[F]] make[SQL[F]].from[SQL.Impl[F]] diff --git a/bifunctor-tagless/src/main/scala/leaderboard/plugins/PostgresDockerPlugin.scala b/bifunctor-tagless/jvm/src/main/scala/leaderboard/plugins/PostgresDockerPlugin.scala similarity index 100% rename from bifunctor-tagless/src/main/scala/leaderboard/plugins/PostgresDockerPlugin.scala rename to bifunctor-tagless/jvm/src/main/scala/leaderboard/plugins/PostgresDockerPlugin.scala diff --git a/bifunctor-tagless/jvm/src/main/scala/leaderboard/repo/LadderPostgres.scala b/bifunctor-tagless/jvm/src/main/scala/leaderboard/repo/LadderPostgres.scala new file mode 100644 index 0000000..e21900c --- /dev/null +++ b/bifunctor-tagless/jvm/src/main/scala/leaderboard/repo/LadderPostgres.scala @@ -0,0 +1,42 @@ +package leaderboard.repo + +import distage.Lifecycle +import doobie.postgres.implicits.* +import doobie.syntax.string.* +import izumi.functional.bio.Monad2 +import leaderboard.model.{QueryFailure, Score, UserId} +import leaderboard.sql.SQL +import logstage.LogIO2 + +// Postgres-backed implementation of [[Ladder]]. Lives JVM-side because doobie +// is not cross-built to scala.js. +final class LadderPostgres[F[+_, +_]: Monad2]( + sql: SQL[F], + log: LogIO2[F], +) extends Lifecycle.LiftF[F[Throwable, _], Ladder[F]](for { + _ <- log.info(s"Creating Ladder table") + _ <- sql.execute("ladder-ddl") { + sql"""create table if not exists ladder ( + | user_id uuid not null, + | score bigint not null, + | primary key (user_id) + |) without oids + |""".stripMargin.update.run + } + res = new Ladder[F] { + override def submitScore(userId: UserId, score: Score): F[QueryFailure, Unit] = + sql + .execute("submit-score") { + sql"""insert into ladder (user_id, score) values ($userId, $score) + |on conflict (user_id) do update set + | score = excluded.score + |""".stripMargin.update.run + }.void + + override val getScores: F[QueryFailure, List[(UserId, Score)]] = + sql.execute("get-leaderboard") { + sql"""select user_id, score from ladder order by score DESC + |""".stripMargin.query[(UserId, Score)].to[List] + } + } + } yield res) diff --git a/bifunctor-tagless/jvm/src/main/scala/leaderboard/repo/ProfilesPostgres.scala b/bifunctor-tagless/jvm/src/main/scala/leaderboard/repo/ProfilesPostgres.scala new file mode 100644 index 0000000..23b2c1a --- /dev/null +++ b/bifunctor-tagless/jvm/src/main/scala/leaderboard/repo/ProfilesPostgres.scala @@ -0,0 +1,47 @@ +package leaderboard.repo + +import distage.Lifecycle +import doobie.postgres.implicits.* +import doobie.implicits.* +import izumi.functional.bio.Monad2 +import leaderboard.model.{QueryFailure, UserId, UserProfile} +import leaderboard.sql.SQL +import logstage.LogIO2 + +// Postgres-backed implementation of [[Profiles]]. Lives JVM-side because doobie +// is not cross-built to scala.js. +final class ProfilesPostgres[F[+_, +_]: Monad2]( + sql: SQL[F], + log: LogIO2[F], +) extends Lifecycle.LiftF[F[Throwable, _], Profiles[F]](for { + _ <- log.info("Creating Profile table") + _ <- sql.execute("ddl-profiles") { + sql"""create table if not exists profiles ( + | user_id uuid not null, + | name text not null, + | description text not null, + | primary key (user_id) + |) without oids + |""".stripMargin.update.run + } + } yield new Profiles[F] { + override def setProfile(userId: UserId, profile: UserProfile): F[QueryFailure, Unit] = { + sql + .execute("set-profile") { + sql"""insert into profiles (user_id, name, description) + |values ($userId, ${profile.name}, ${profile.description}) + |on conflict (user_id) do update set + | name = excluded.name, + | description = excluded.description + |""".stripMargin.update.run + }.void + } + + override def getProfile(userId: UserId): F[QueryFailure, Option[UserProfile]] = { + sql.execute("get-profile") { + sql"""select name, description from profiles + |where user_id = $userId + |""".stripMargin.query[UserProfile].option + } + } + }) diff --git a/bifunctor-tagless/src/main/scala/leaderboard/sql/SQL.scala b/bifunctor-tagless/jvm/src/main/scala/leaderboard/sql/SQL.scala similarity index 100% rename from bifunctor-tagless/src/main/scala/leaderboard/sql/SQL.scala rename to bifunctor-tagless/jvm/src/main/scala/leaderboard/sql/SQL.scala diff --git a/bifunctor-tagless/src/main/scala/leaderboard/sql/TransactorResource.scala b/bifunctor-tagless/jvm/src/main/scala/leaderboard/sql/TransactorResource.scala similarity index 100% rename from bifunctor-tagless/src/main/scala/leaderboard/sql/TransactorResource.scala rename to bifunctor-tagless/jvm/src/main/scala/leaderboard/sql/TransactorResource.scala diff --git a/bifunctor-tagless/src/test/scala/leaderboard/Rnd.scala b/bifunctor-tagless/jvm/src/test/scala/leaderboard/Rnd.scala similarity index 100% rename from bifunctor-tagless/src/test/scala/leaderboard/Rnd.scala rename to bifunctor-tagless/jvm/src/test/scala/leaderboard/Rnd.scala diff --git a/bifunctor-tagless/src/test/scala/leaderboard/WiringTest.scala b/bifunctor-tagless/jvm/src/test/scala/leaderboard/WiringTest.scala similarity index 100% rename from bifunctor-tagless/src/test/scala/leaderboard/WiringTest.scala rename to bifunctor-tagless/jvm/src/test/scala/leaderboard/WiringTest.scala diff --git a/bifunctor-tagless/src/test/scala/leaderboard/tests.scala b/bifunctor-tagless/jvm/src/test/scala/leaderboard/tests.scala similarity index 100% rename from bifunctor-tagless/src/test/scala/leaderboard/tests.scala rename to bifunctor-tagless/jvm/src/test/scala/leaderboard/tests.scala diff --git a/bifunctor-tagless/src/test/scala/leaderboard/zioenv.scala b/bifunctor-tagless/jvm/src/test/scala/leaderboard/zioenv.scala similarity index 100% rename from bifunctor-tagless/src/test/scala/leaderboard/zioenv.scala rename to bifunctor-tagless/jvm/src/test/scala/leaderboard/zioenv.scala diff --git a/bifunctor-tagless/src/main/scala/leaderboard/api/HttpApi.scala b/bifunctor-tagless/shared/src/main/scala/leaderboard/api/HttpApi.scala similarity index 100% rename from bifunctor-tagless/src/main/scala/leaderboard/api/HttpApi.scala rename to bifunctor-tagless/shared/src/main/scala/leaderboard/api/HttpApi.scala diff --git a/bifunctor-tagless/src/main/scala/leaderboard/api/LadderApi.scala b/bifunctor-tagless/shared/src/main/scala/leaderboard/api/LadderApi.scala similarity index 100% rename from bifunctor-tagless/src/main/scala/leaderboard/api/LadderApi.scala rename to bifunctor-tagless/shared/src/main/scala/leaderboard/api/LadderApi.scala diff --git a/bifunctor-tagless/src/main/scala/leaderboard/api/ProfileApi.scala b/bifunctor-tagless/shared/src/main/scala/leaderboard/api/ProfileApi.scala similarity index 100% rename from bifunctor-tagless/src/main/scala/leaderboard/api/ProfileApi.scala rename to bifunctor-tagless/shared/src/main/scala/leaderboard/api/ProfileApi.scala diff --git a/bifunctor-tagless/shared/src/main/scala/leaderboard/dispatch/LocalDispatcher.scala b/bifunctor-tagless/shared/src/main/scala/leaderboard/dispatch/LocalDispatcher.scala new file mode 100644 index 0000000..2658404 --- /dev/null +++ b/bifunctor-tagless/shared/src/main/scala/leaderboard/dispatch/LocalDispatcher.scala @@ -0,0 +1,45 @@ +package leaderboard.dispatch + +import cats.effect.Async +import cats.syntax.all.* +import leaderboard.api.HttpApi +import leaderboard.dispatch.LocalDispatcher.Response +import org.http4s.{HttpApp, Method, Request, Uri} + +import scala.annotation.unused + +/** + * Invokes the same `HttpRoutes` that the http4s server exposes, but without + * a network hop. Used by the http4s server on the JVM as the actual handler + * for incoming requests, and by the `@JSExportTopLevel` entrypoint in the + * browser so the front-end can call the simulated backend exactly like it + * would call the real one. + */ +trait LocalDispatcher[F[_, _]] { + def call(method: String, path: String, body: String): F[Throwable, Response] +} + +object LocalDispatcher { + final case class Response(status: Int, body: String) + + final class Impl[F[+_, +_]]( + @unused apis: Set[HttpApi[F]] + )(implicit + async: Async[F[Throwable, _]] + ) extends LocalDispatcher[F] { + private type G[A] = F[Throwable, A] + + // Pre-assembled handler matching what the http4s server runs. + private val httpApp: HttpApp[G] = apis.map(_.http).toList.foldK.orNotFound + + override def call(method: String, path: String, body: String): F[Throwable, Response] = { + val req = Request[G]( + method = Method.fromString(method).getOrElse(Method.GET), + uri = Uri.unsafeFromString(path), + ).withEntity(body) + httpApp.run(req).flatMap { resp => + resp.as[String].map(text => Response(resp.status.code, text)) + } + } + } +} diff --git a/bifunctor-tagless/src/main/scala/leaderboard/model/QueryFailure.scala b/bifunctor-tagless/shared/src/main/scala/leaderboard/model/QueryFailure.scala similarity index 100% rename from bifunctor-tagless/src/main/scala/leaderboard/model/QueryFailure.scala rename to bifunctor-tagless/shared/src/main/scala/leaderboard/model/QueryFailure.scala diff --git a/bifunctor-tagless/src/main/scala/leaderboard/model/RankedProfile.scala b/bifunctor-tagless/shared/src/main/scala/leaderboard/model/RankedProfile.scala similarity index 100% rename from bifunctor-tagless/src/main/scala/leaderboard/model/RankedProfile.scala rename to bifunctor-tagless/shared/src/main/scala/leaderboard/model/RankedProfile.scala diff --git a/bifunctor-tagless/src/main/scala/leaderboard/model/UserProfile.scala b/bifunctor-tagless/shared/src/main/scala/leaderboard/model/UserProfile.scala similarity index 100% rename from bifunctor-tagless/src/main/scala/leaderboard/model/UserProfile.scala rename to bifunctor-tagless/shared/src/main/scala/leaderboard/model/UserProfile.scala diff --git a/bifunctor-tagless/src/main/scala/leaderboard/model/package.scala b/bifunctor-tagless/shared/src/main/scala/leaderboard/model/package.scala similarity index 100% rename from bifunctor-tagless/src/main/scala/leaderboard/model/package.scala rename to bifunctor-tagless/shared/src/main/scala/leaderboard/model/package.scala diff --git a/bifunctor-tagless/shared/src/main/scala/leaderboard/plugins/LeaderboardCoreModule.scala b/bifunctor-tagless/shared/src/main/scala/leaderboard/plugins/LeaderboardCoreModule.scala new file mode 100644 index 0000000..0981fc7 --- /dev/null +++ b/bifunctor-tagless/shared/src/main/scala/leaderboard/plugins/LeaderboardCoreModule.scala @@ -0,0 +1,46 @@ +package leaderboard.plugins + +import distage.StandardAxis.Repo +import distage.{ModuleDef, TagKK} +import leaderboard.api.{HttpApi, LadderApi, ProfileApi} +import leaderboard.dispatch.LocalDispatcher +import leaderboard.repo.{Ladder, Profiles} +import leaderboard.services.Ranks +import org.http4s.dsl.Http4sDsl + +/** + * Cross-compilable subset of the application wiring. Holds everything that + * doesn't depend on JVM-only libraries (http4s ember server, doobie, JNA, + * roles), so the same module can be assembled into a real http server on the + * JVM and into an in-browser scala.js simulation. + */ +object LeaderboardCoreModule { + + def api[F[+_, +_]: TagKK]: ModuleDef = new ModuleDef { + // The `ladder` API + make[LadderApi[F]] + // The `profile` API + make[ProfileApi[F]] + + // A set of all APIs + many[HttpApi[F]] + .weak[LadderApi[F]] // add ladder API as a _weak reference_ + .weak[ProfileApi[F]] // add profiles API as a _weak reference_ + + make[Ranks[F]].from[Ranks.Impl[F]] + + makeTrait[Http4sDsl[F[Throwable, _]]] + + // Wraps the assembled HttpRoutes for direct in-process calls. Used both by + // the http4s adapter on JVM and by the @JSExportTopLevel entrypoint in the + // browser simulation. + make[LocalDispatcher[F]].from[LocalDispatcher.Impl[F]] + } + + def repoDummy[F[+_, +_]: TagKK]: ModuleDef = new ModuleDef { + tag(Repo.Dummy) + + make[Ladder[F]].fromResource[Ladder.Dummy[F]] + make[Profiles[F]].fromResource[Profiles.Dummy[F]] + } +} diff --git a/bifunctor-tagless/shared/src/main/scala/leaderboard/repo/Ladder.scala b/bifunctor-tagless/shared/src/main/scala/leaderboard/repo/Ladder.scala new file mode 100644 index 0000000..52155af --- /dev/null +++ b/bifunctor-tagless/shared/src/main/scala/leaderboard/repo/Ladder.scala @@ -0,0 +1,26 @@ +package leaderboard.repo + +import distage.Lifecycle +import izumi.functional.bio.{Applicative2, F, Primitives2} +import leaderboard.model.{QueryFailure, Score, UserId} + +trait Ladder[F[_, _]] { + def submitScore(userId: UserId, score: Score): F[QueryFailure, Unit] + def getScores: F[QueryFailure, List[(UserId, Score)]] +} + +object Ladder { + // In-memory dummy used by both JVM tests and the in-browser simulation. + final class Dummy[F[+_, +_]: Applicative2: Primitives2] + extends Lifecycle.LiftF[F[Nothing, _], Ladder[F]](for { + state <- F.mkRef(Map.empty[UserId, Score]) + } yield { + new Ladder[F] { + override def submitScore(userId: UserId, score: Score): F[Nothing, Unit] = + state.update_(_ + (userId -> score)) + + override def getScores: F[Nothing, List[(UserId, Score)]] = + state.get.map(_.toList.sortBy(_._2)(Ordering[Score].reverse)) + } + }) +} diff --git a/bifunctor-tagless/shared/src/main/scala/leaderboard/repo/Profiles.scala b/bifunctor-tagless/shared/src/main/scala/leaderboard/repo/Profiles.scala new file mode 100644 index 0000000..8cfcf8a --- /dev/null +++ b/bifunctor-tagless/shared/src/main/scala/leaderboard/repo/Profiles.scala @@ -0,0 +1,26 @@ +package leaderboard.repo + +import distage.Lifecycle +import izumi.functional.bio.{Applicative2, F, Primitives2} +import leaderboard.model.{QueryFailure, UserId, UserProfile} + +trait Profiles[F[_, _]] { + def setProfile(userId: UserId, profile: UserProfile): F[QueryFailure, Unit] + def getProfile(userId: UserId): F[QueryFailure, Option[UserProfile]] +} + +object Profiles { + // In-memory dummy used by both JVM tests and the in-browser simulation. + final class Dummy[F[+_, +_]: Applicative2: Primitives2] + extends Lifecycle.LiftF[F[Nothing, _], Profiles[F]](for { + state <- F.mkRef(Map.empty[UserId, UserProfile]) + } yield { + new Profiles[F] { + override def setProfile(userId: UserId, profile: UserProfile): F[Nothing, Unit] = + state.update_(_ + (userId -> profile)) + + override def getProfile(userId: UserId): F[Nothing, Option[UserProfile]] = + state.get.map(_.get(userId)) + } + }) +} diff --git a/bifunctor-tagless/src/main/scala/leaderboard/services/Ranks.scala b/bifunctor-tagless/shared/src/main/scala/leaderboard/services/Ranks.scala similarity index 100% rename from bifunctor-tagless/src/main/scala/leaderboard/services/Ranks.scala rename to bifunctor-tagless/shared/src/main/scala/leaderboard/services/Ranks.scala diff --git a/bifunctor-tagless/src/main/scala/leaderboard/http/HttpServer.scala b/bifunctor-tagless/src/main/scala/leaderboard/http/HttpServer.scala deleted file mode 100644 index 6a3cda1..0000000 --- a/bifunctor-tagless/src/main/scala/leaderboard/http/HttpServer.scala +++ /dev/null @@ -1,35 +0,0 @@ -package leaderboard.http - -import cats.effect.Async -import cats.syntax.all.* -import com.comcast.ip4s.Port -import fs2.io.net.Network -import izumi.distage.model.definition.Lifecycle -import leaderboard.api.HttpApi -import org.http4s.ember.server.EmberServerBuilder -import org.http4s.server.Server - -final case class HttpServer( - server: Server -) - -object HttpServer { - - final class Impl[F[+_, +_]]( - allHttpApis: Set[HttpApi[F]] - )(implicit - async: Async[F[Throwable, _]] - ) extends Lifecycle.Of[F[Throwable, _], HttpServer]( - Lifecycle.fromCats { - val combinedApis = allHttpApis.map(_.http).toList.foldK - - EmberServerBuilder - .default(using async, Network.forAsync) - .withHttpApp(combinedApis.orNotFound) - .withPort(Port.fromInt(8080).get) - .build - .map(HttpServer(_)) - } - ) - -} diff --git a/bifunctor-tagless/src/main/scala/leaderboard/repo/Ladder.scala b/bifunctor-tagless/src/main/scala/leaderboard/repo/Ladder.scala deleted file mode 100644 index 105adaf..0000000 --- a/bifunctor-tagless/src/main/scala/leaderboard/repo/Ladder.scala +++ /dev/null @@ -1,60 +0,0 @@ -package leaderboard.repo - -import distage.Lifecycle -import doobie.postgres.implicits.* -import doobie.syntax.string.* -import izumi.functional.bio.{Applicative2, F, Monad2, Primitives2} -import leaderboard.model.{QueryFailure, Score, UserId} -import leaderboard.sql.SQL -import logstage.LogIO2 - -trait Ladder[F[_, _]] { - def submitScore(userId: UserId, score: Score): F[QueryFailure, Unit] - def getScores: F[QueryFailure, List[(UserId, Score)]] -} - -object Ladder { - final class Dummy[F[+_, +_]: Applicative2: Primitives2] - extends Lifecycle.LiftF[F[Nothing, _], Ladder[F]](for { - state <- F.mkRef(Map.empty[UserId, Score]) - } yield { - new Ladder[F] { - override def submitScore(userId: UserId, score: Score): F[Nothing, Unit] = - state.update_(_ + (userId -> score)) - - override def getScores: F[Nothing, List[(UserId, Score)]] = - state.get.map(_.toList.sortBy(_._2)(Ordering[Score].reverse)) - } - }) - - final class Postgres[F[+_, +_]: Monad2]( - sql: SQL[F], - log: LogIO2[F], - ) extends Lifecycle.LiftF[F[Throwable, _], Ladder[F]](for { - _ <- log.info(s"Creating Ladder table") - _ <- sql.execute("ladder-ddl") { - sql"""create table if not exists ladder ( - | user_id uuid not null, - | score bigint not null, - | primary key (user_id) - |) without oids - |""".stripMargin.update.run - } - res = new Ladder[F] { - override def submitScore(userId: UserId, score: Score): F[QueryFailure, Unit] = - sql - .execute("submit-score") { - sql"""insert into ladder (user_id, score) values ($userId, $score) - |on conflict (user_id) do update set - | score = excluded.score - |""".stripMargin.update.run - }.void - - override val getScores: F[QueryFailure, List[(UserId, Score)]] = - sql.execute("get-leaderboard") { - sql"""select user_id, score from ladder order by score DESC - |""".stripMargin.query[(UserId, Score)].to[List] - } - } - } yield res) -} diff --git a/bifunctor-tagless/src/main/scala/leaderboard/repo/Profiles.scala b/bifunctor-tagless/src/main/scala/leaderboard/repo/Profiles.scala deleted file mode 100644 index 96b64a7..0000000 --- a/bifunctor-tagless/src/main/scala/leaderboard/repo/Profiles.scala +++ /dev/null @@ -1,65 +0,0 @@ -package leaderboard.repo - -import distage.Lifecycle -import doobie.postgres.implicits.* -import doobie.implicits.* -import izumi.functional.bio.{Applicative2, F, Monad2, Primitives2} -import leaderboard.model.{QueryFailure, UserId, UserProfile} -import leaderboard.sql.SQL -import logstage.LogIO2 - -trait Profiles[F[_, _]] { - def setProfile(userId: UserId, profile: UserProfile): F[QueryFailure, Unit] - def getProfile(userId: UserId): F[QueryFailure, Option[UserProfile]] -} - -object Profiles { - final class Dummy[F[+_, +_]: Applicative2: Primitives2] - extends Lifecycle.LiftF[F[Nothing, _], Profiles[F]](for { - state <- F.mkRef(Map.empty[UserId, UserProfile]) - } yield { - new Profiles[F] { - override def setProfile(userId: UserId, profile: UserProfile): F[Nothing, Unit] = - state.update_(_ + (userId -> profile)) - - override def getProfile(userId: UserId): F[Nothing, Option[UserProfile]] = - state.get.map(_.get(userId)) - } - }) - - final class Postgres[F[+_, +_]: Monad2]( - sql: SQL[F], - log: LogIO2[F], - ) extends Lifecycle.LiftF[F[Throwable, _], Profiles[F]](for { - _ <- log.info("Creating Profile table") - _ <- sql.execute("ddl-profiles") { - sql"""create table if not exists profiles ( - | user_id uuid not null, - | name text not null, - | description text not null, - | primary key (user_id) - |) without oids - |""".stripMargin.update.run - } - } yield new Profiles[F] { - override def setProfile(userId: UserId, profile: UserProfile): F[QueryFailure, Unit] = { - sql - .execute("set-profile") { - sql"""insert into profiles (user_id, name, description) - |values ($userId, ${profile.name}, ${profile.description}) - |on conflict (user_id) do update set - | name = excluded.name, - | description = excluded.description - |""".stripMargin.update.run - }.void - } - - override def getProfile(userId: UserId): F[QueryFailure, Option[UserProfile]] = { - sql.execute("get-profile") { - sql"""select name, description from profiles - |where user_id = $userId - |""".stripMargin.query[UserProfile].option - } - } - }) -} diff --git a/build.sbt b/build.sbt index 1667b06..4f3a061 100644 --- a/build.sbt +++ b/build.sbt @@ -1,3 +1,6 @@ +import org.scalajs.linker.interface.{ModuleKind, OutputPatterns} +import sbtcrossproject.CrossPlugin.autoImport.{CrossType, crossProject} + import scala.util.chaining.scalaUtilChainingOps val V = new { @@ -9,7 +12,7 @@ val V = new { val doobie = "1.0.0-RC12" val catsCore = "2.13.0" val zio = "2.1.25" - val zioCats = "23.0.0.8" + val zioCats = "23.1.0.13" val kindProjector = "0.13.4" val circeGeneric = "0.14.15" val graalMetadata = "0.11.5" @@ -47,6 +50,8 @@ val Deps = new { val graalMetadata = "org.graalvm.buildtools" % "graalvm-reachability-metadata" % V.graalMetadata + // Standard set of deps for JVM-only sub-projects (monomorphic-cats, + // monofunctor-tagless, and the JVM half of bifunctor-tagless). val CoreDeps = Seq( distageCore, distageRoles, @@ -79,9 +84,73 @@ inThisBuild( ) ) -lazy val `bifunctor-tagless` = project +// ----------------------------------------------------------------------------- +// bifunctor-tagless: a cross-built project (JVM + Scala.js) +// +// The shared half holds everything that compiles to both targets: the model, +// the API classes (which return http4s `HttpRoutes`), the repository traits +// and their in-memory dummy implementations, and the `LocalDispatcher` that +// invokes the assembled routes in-process. The JVM half adds the postgres +// repositories, the http4s ember server with CORS + static-file serving, and +// the role/launcher infrastructure. The JS half adds a `@JSExportTopLevel` +// entrypoint that boots the same distage graph with `Repo -> Dummy` and +// surfaces the dispatcher to the browser as `window.LeaderboardSim`. +// ----------------------------------------------------------------------------- + +lazy val `bifunctor-tagless` = crossProject(JVMPlatform, JSPlatform) + .crossType(CrossType.Full) .in(file("bifunctor-tagless")) - .pipe(sharedSettings(Seq(Deps.zio, Deps.zioCats))) + .settings( + sharedScalaSettings, + libraryDependencies ++= Seq( + "io.7mind.izumi" %%% "distage-core" % V.distage, + "io.7mind.izumi" %%% "distage-extension-plugins" % V.distage, + "io.7mind.izumi" %%% "distage-extension-logstage" % V.distage, + "io.7mind.izumi" %%% "logstage-core" % V.logstage, + "org.http4s" %%% "http4s-dsl" % V.http4s, + "org.http4s" %%% "http4s-circe" % V.http4s, + "io.circe" %%% "circe-generic" % V.circeGeneric, + "org.typelevel" %%% "cats-core" % V.catsCore, + "dev.zio" %%% "zio" % V.zio, + // Intentionally NOT depending on zio-interop-cats here — its + // `ZManagedMonadError` references zio 1.x types that no longer link + // under scala.js. The bifunctor-tagless code uses + // `izumi.functional.bio.catz` for its cats-effect bridge instead. The + // JVM half still pulls in zio-interop-cats via distage-testkit / + // monofunctor-tagless transitively if needed. + ), + ) + .jvmConfigure(_.pipe(jvmSharedSettings(Seq(Deps.zio, Deps.zioCats)))) + .jsSettings( + scalaJSUseMainModuleInitializer := false, + // Emit ES module output so the front-end can import it via + // +