albertonline.org docs

Architecture

Effect v4 RPC as the single API boundary, Drizzle + Supabase for data, Better Auth for sessions, and how a request flows end to end.

The system is built around a small number of load-bearing decisions: Effect v4 for services and error handling, one RPC boundary between client and server, Drizzle + Supabase for data with row-level security, and Better Auth for sessions.

The API is Effect v4 RPC (not tRPC)

The API surface is Effect v4 RPC. Schemas and handlers live in packages/api/src/rpc/ (*-schema.ts for the request/response schemas, handlers/ for the implementations). The web app consumes them through the Effect RPC client exported by @repo/api — there is no trpc.ts.

  • Every payload is a Schema. A request is decoded on the way in and a response is encoded on the way out, so the wire is validated at both ends and the handler only ever sees decoded, typed values.
  • Errors are typed. Handlers fail with Schema.TaggedError values that the client can discriminate — no thrown Errors escaping an Effect.

Services and layers

Shared behaviour is packaged as Effect services. The house shape:

export class MyService extends Context.Service<MyService>()("@scope/MyService", {
  make: Effect.gen(function* () {
    /* deps + impl */
  }),
}) {
  static readonly layer = Layer.effect(this, this.make);
}

Consumers compose with MyService.layer.pipe(Layer.provide(<deps>)). Layer order matters: a service yielded inside a layer's make must be provided outside that layer. Layer.mergeAll only merges siblings whose deps are already satisfied — it does not cross-wire them.

The canonical Effect v4 reference is the vendored tools/effect-smol git subtree (pinned to the same beta as the catalog) plus its MIGRATION.md — trust it over public docs and blog posts, which describe v3.

Data — Drizzle + Supabase, with RLS

  • Schema lives in packages/db/src/schema/ (Drizzle). RLS policies live in packages/db/src/rls.ts.
  • The DB connects to Supabase Postgres through the Supavisor pooler. The Effect DB layer (packages/db/src/effect/layer.ts) reads its connection string via Config / Config.redacted — service code never touches process.env directly.
  • Migrations are generated with pnpm db:generate and applied with pnpm db:migrate. pnpm db:push pushes schema directly (dev only) — never push to reconcile a prod migration state, because push drops RLS policies and Supabase system views that the Drizzle schema doesn't model.

Auth — Better Auth

  • The server auth instance (trusted origins, providers) is in packages/auth/src/auth-instance.ts; the Effect-wrapped server in packages/auth/src/server.ts. The web client is apps/web/src/lib/auth-client.ts.
  • Never call auth.api.getSession(...) directly. It can throw when the Supavisor pooler drops a connection. All session resolution goes through the blessed wrapper in packages/auth/src/request.ts (getSessionFromHeaders / getSessionFromRequest), which retries on dropped pooler connections. A guard script (check:no-raw-getsession) enforces this in CI.

Request flow (web)

React component
  → @repo/api RPC client        (typed call, Schema-encoded request)
    → Next.js route / handler
      → auth: getSessionFromHeaders  (retry-safe session)
        → RPC handler (packages/api/src/rpc/handlers)
          → Effect service (Context.Service + layer)
            → @repo/db repo (Drizzle) → Supabase Postgres (RLS)
          ← typed success | Schema.TaggedError
      ← Schema-encoded response

Determinism

Wall-clock and RNG are side effects and are read through Effect's default services, never directly (enforced by effect/no-date + effect/no-math-random):

  • Timeyield* Clock.currentTimeMillis / DateTime.now.
  • Randomnessyield* Random.next / Random.nextIntBetween(a, b); outside Effect use @repo/utils secureId() / randomInt() (crypto-backed). Never Math.random().
  • Tests drive time with TestClock and seed RNG with Random.withSeed.

See the packages reference for the libraries named here, and local development to run it.

On this page