albertonline.org docs
Learn

How a request flows

One real request traced end to end — RPC client, Next route, session, handler, Effect repo, Postgres RLS, and the typed result home.

This guide follows one real request all the way down and back up, using the audio feature as the running example. Every hop below is quoted from the code in this repo — nothing is invented. By the end you should be able to point at any file in the stack and say what it does and why it is written that way.

The whole round trip, top to bottom:

  1. A React atom calls the typed RPC client — client["audio/list"]().
  2. The client Schema-encodes the request and POSTs it to /api/rpc.
  3. The Next.js route hands the request to an Effect RpcServer.
  4. The session middleware resolves + decodes the caller's session.
  5. The handler gates on admin, reads the Session, calls the repo.
  6. The repo (an Effect service over Drizzle) runs an owner-scoped query.
  7. Supabase Postgres runs it under Row-Level Security.
  8. The result comes home as a typed success or a Schema.TaggedError.

The two ideas that make this whole thing tick: a Schema is the contract at every boundary (decode what comes in, encode what goes out), and the error channel is typed end to end — a failure is a value the client can pattern match, not a thrown exception.

Hop 0 — the schema is the contract

Before any code runs, the request and response shapes are declared as Effect Schemas. This one file is the single source of truth shared by the DB repo and the RPC surface — packages/api/src/rpc/audio-schema.ts:

const Id = Schema.Number.check(Schema.isInt());

/** The persisted audio row, as returned by reads + mutations. */
export const Audio = Schema.Struct({
  id: Id,
  userId: Schema.NonEmptyString,
  filename: Schema.String,
  path: Schema.String,
  originalName: Schema.String,
  mimeType: Schema.String,
  size: Id,
  /** Length in seconds; null until probed. */
  duration: Schema.NullOr(Id),
  createdAt: Schema.Date,
  updatedAt: Schema.Date,
});
// fallow-ignore-next-line unused-type -- public schema-derived type
export type Audio = typeof Audio.Type;

Two things to internalise here:

  • Audio is a runtime value (a Schema.Struct), not just a type. That value can validate data at runtime. The TypeScript type is derived from it with type Audio = typeof Audio.Type — so the type can never drift from the validator. The file's own header says it: "Derive types via type X = typeof X.Type so the runtime schema stays authoritative." You never hand-write the interface.
  • The input schema is a separate, smaller struct. CreateAudioInput omits userId, id, and the timestamps — the comment explains why: "userId comes from the boundary." The client is never trusted to say who it is; that comes from the session on the server.

A Schema is bidirectional. The client uses it to encode an outgoing payload and decode the response; the server uses the same shapes to decode the incoming payload and encode the reply. Because both sides import the same *-schema.ts, a shape change is a compile error on whichever side falls behind — the wire format can't silently drift.

The schema becomes an actual RPC endpoint in packages/api/src/rpc/schema.ts, where each method pairs a payload (request) with a success and an error:

const AudioRpc = RpcGroup.make(
  Rpc.make("list", {
    success: Schema.Array(Audio),
    error: RpcError,
  }),
  Rpc.make("get", {
    payload: AudioIdInput,
    success: Audio,
    error: RpcError,
  }),
  Rpc.make("create", {
    payload: CreateAudioInput,
    success: Audio,
    error: RpcError,
  })
  // ...delete, transcribe
).prefix("audio/");

.prefix("audio/") is why the client method is called client["audio/list"]. Note that list has no payload (nothing to send) and its success is Schema.Array(Audio). Every method declares the same error: RpcError — a union we'll meet on the way back up. Finally, the group is wired into the app with its middleware:

export const ApiRpc = PublicRpc.merge(
  PrivateRpc,
  TodoRpc.middleware(SessionMiddleware),
  // ...
  AudioRpc.middleware(SessionMiddleware)
  // ...
);

.middleware(SessionMiddleware) is the declaration that says "every audio/* call must first pass through session resolution." Hold that thought — it fires at hop 3.

Hop 1 — the atom calls the client

On the browser, the client is built once from that same ApiRpc definition. packages/api/src/client/atom-runtime.ts assembles the transport layer:

return RpcClient.layerProtocolHttp({ url: options.url }).pipe(
  Layer.provide(httpClientLayer),
  Layer.provide(RpcSerialization.layerJson),
  Layer.provide(requestInitLayer)
);

That's the whole client protocol: HTTP transport + JSON serialization, pointed at a URL. RpcSerialization.layerJson is the counterpart to the server's RpcSerialization.layerJson — both ends agree on JSON, so the Schema-encoded payload is what crosses the wire.

The app instantiates that runtime against /api/rpc in apps/web/src/utils/rpc.ts, and every read/mutation is an atom that yields the client and calls one method. Here is the live todo list — the audio surface is called the identical way:

const rpcAtomRuntime = makeRpcAtomRuntime({
  url: "/api/rpc",
  requestInit: { credentials: "include" },
});

export const todosAtom = rpcAtomRuntime.factory.withReactivity(["todo/getAll"])(
  rpcAtomRuntime.atom(
    Effect.gen(function* () {
      const client = yield* RpcClient.make(ApiRpc);
      return yield* client["todo/getAll"]();
    }).pipe(Effect.scoped)
  )
);

Line by line:

  • RpcClient.make(ApiRpc) builds a fully typed client from the schema. client["audio/list"] exists, is typed to take no argument, and returns Effect<readonly Audio[], RpcError>. A typo or a wrong payload shape is a compile error — the schema is the client's type.
  • yield* client["todo/getAll"]() performs the call. Under the hood the client encodes the payload with the method's payload schema, POSTs it, then decodes the JSON reply against the success schema (or the error schema) before handing it back. You get a decoded Audio[], never a raw unknown.
  • requestInit: { credentials: "include" } is what ships the auth cookie with the request — the server needs it to resolve the session.
  • withReactivity([...]) + Effect.scoped are the reactivity/lifecycle plumbing: the atom re-runs when its key is invalidated (a mutation elsewhere), and the scope tears the client fiber down cleanly.

The audio browser page actually uses a sibling REST client (AudioClient over /api/audio) rather than this RPC atom — a deliberate exception, not the norm. The audio/* RPC surface is real and typed all the same: it's driven end to end through the server layer by packages/api/src/rpc/__tests__/router.audio-video.unit.test.ts. For a new feature, reach for the RPC atom pattern above (todo, deals, plan, telemetry all use it) unless you have a reason like binary upload.

Hop 2 — the Next.js route

The request lands at apps/web/src/app/api/rpc/route.ts. This file is thin on purpose — it wires an Effect RpcServer into a plain Next POST handler:

export async function POST(request: Request) {
  const program = Effect.gen(function* () {
    const httpEffect = yield* RpcServer.toHttpEffect(ApiRpc).pipe(Effect.provide(ServerLayer));
    const response = yield* httpEffect;
    return HttpServerResponse.toWeb(response);
  }).pipe(
    Effect.provideService(HttpServerRequest.HttpServerRequest, HttpServerRequest.fromWeb(request)),
    Effect.scoped,
    Effect.catchCause((cause) => /* log "rpc envelope failure" + 500 */),
  );

  return runDbLive(program);
}

What each piece does:

  • RpcServer.toHttpEffect(ApiRpc) turns the same ApiRpc definition into an HTTP handler that knows how to route audio/list to its handler, decode the payload, and encode the reply. Client and server are generated from one definition — that's the tRPC-style end-to-end typing, done with Effect RPC.
  • Effect.provide(ServerLayer) supplies everything the handlers need — the middleware, the JSON serialization, and (transitively) every repo. More on that layer below.
  • runDbLive(program) runs the whole thing against the live DB runtime. Its comment captures a subtle self-healing property: it "reads the runtime via getRuntime() each call, so a pool rebuilt by healStalePool takes effect on the next request — the page self-heals on reload instead of needing a server restart."

The ServerLayer is where the session middleware gets its real implementation:

const ServerLayer = Layer.provideMerge(
  ApiRpcLive,
  Layer.mergeAll(
    SessionMiddlewareLive(auth.api),
    ServiceTokenMiddlewareLive,
    RpcSerialization.layerJson,
    Metric.enableRuntimeMetricsLayer,
    TranslationCacheUpstashLive
  )
);

SessionMiddlewareLive(auth.api) hands the middleware the real Better Auth instance. That's the seam between the abstract SessionMiddleware tag (declared in the schema, safe to share with the browser bundle) and the concrete server-only auth call.

Hop 3 — session resolution

Because AudioRpc declared .middleware(SessionMiddleware), the server runs resolveSession before the handler. This is the first DB touch of every authenticated request — packages/api/src/rpc/middleware.ts:

export const resolveSession = (
  auth: AuthLike,
  headers: Readonly<Record<string, string>>
): Effect.Effect<SessionShape, Unauthorized> =>
  Effect.gen(function* () {
    const webHeaders = new globalThis.Headers();
    for (const [key, value] of Object.entries(headers)) {
      webHeaders.set(key, value);
    }
    const raw = yield* Effect.tryPromise({
      try: () => auth.getSession({ headers: webHeaders }),
      catch: (cause) => cause,
    }).pipe(
      retryOnStaleConnection,
      Effect.mapError(() => new Unauthorized({ message: "auth lookup failed" }))
    );
    if (!raw) {
      return yield* new Unauthorized({ message: "no session" });
    }
    // Boundary rule: validate the better-auth payload before it becomes the
    // Session service; unknown fields are stripped by the decode.
    const session = yield* Schema.decodeUnknownEffect(SessionSchema)(raw).pipe(
      Effect.mapError(() => new Unauthorized({ message: "malformed session" }))
    );
    return session;
  }).pipe(/* tapError logging + */ Effect.withSpan("rpc.auth", { kind: "server" }));

The four load-bearing lines:

  • Effect.tryPromise({ try, catch }) wraps Better Auth's Promise-based getSession into an Effect, converting any rejection into a value on the error channel. This is the house rule: a Promise that can reject never enters Effect via Effect.promise — always tryPromise.
  • retryOnStaleConnection re-attempts the lookup on a dropped Supavisor pooler connection. The comment explains the bug it fixes: a connection drop here "used to collapse straight to Unauthorized, failing the whole call" — e.g. the Plans board's "Couldn't load plans — retry." Only connection-class faults retry; a bad cookie fails immediately.
  • Schema.decodeUnknownEffect(SessionSchema)(raw) is the boundary decode. The raw better-auth payload is unknown until this line; decoding both validates it and strips every field not in the allowlist (SessionSchema in session.ts keeps only id, email, role, a few UI prefs). Everything downstream sees the narrow, trusted SessionShape.
  • Effect.withSpan("rpc.auth", ...) gives auth its own span in the trace waterfall, so its latency (and any retry) is visible instead of hidden.

The successfully decoded session is then provided as a service so the handler can read it:

export const SessionMiddlewareLive = (auth: AuthLike) =>
  Layer.succeed(SessionMiddleware)(
    SessionMiddleware.of((effect, options) =>
      Effect.gen(function* () {
        const session = yield* resolveSession(auth, options.headers);
        yield* Metric.modify(activeSessions, 1);
        return yield* Effect.provideService(effect, Session, session).pipe(
          Effect.ensuring(Metric.modify(activeSessions, -1))
        );
      })
    )
  );

Effect.provideService(effect, Session, session) is the key move: it injects the decoded session into the handler's context, so the handler can later write yield* Session and get exactly this value.

Notice the middleware calls auth.getSession through the injected AuthLike, wrapped in retryOnStaleConnection. On the SSR / page-loader boundary the same call is made through the blessed wrapper getSessionFromHeaders in packages/auth/src/request.ts — which wraps auth.api.getSession in the very same bounded retry (retrySessionLookup). Both exist for the same reason: never call raw auth.api.getSession directly. A guard script (check:no-raw-getsession, in ci:precheck) fails the build if you do, because a raw call throws on a pooler drop and 500s the page. Route session reads through the middleware (RPC) or getSessionFromHeaders (SSR).

Hop 4 — the handler

Now the handler runs with a Session in context. packages/api/src/rpc/handlers/audio.ts:

export const audioHandlers = (audioRepo: AudioRepoShape): AudioSlice => ({
  "audio/list": () =>
    adminHandler("audio/list")(
      Effect.gen(function* () {
        const session = yield* Session;
        return yield* audioRepo.listForUser(session.user.id);
      })
    ),

  "audio/get": (input) =>
    adminHandler("audio/get")(
      Effect.gen(function* () {
        const session = yield* Session;
        const row = yield* audioRepo.getById(input.id, session.user.id);
        if (row === undefined) {
          return yield* new NotFound({ resource: "audio", id: input.id });
        }
        return row;
      })
    ),
  // ...create, delete
});

The handler body is deliberately tiny — read the Session, call the repo, guard undefined. Everything cross-cutting lives in the adminHandler wrapper from handlers/shared.ts:

export const adminHandler =
  (method: string) =>
  <A, E, R>(body: Effect.Effect<A, E, R>) =>
    Effect.gen(function* () {
      yield* requireAdmin;
      return yield* body.pipe(failureToInternal(method));
    }).pipe(withRpcSpan(method));

Three responsibilities, composed once so the method name is written a single time:

  • requireAdmin reads the Session and fails with Forbidden unless the user's role is admin. It sits outside failureToInternal, so a denial stays a 403 and is never rewritten into a 500.
  • failureToInternal(method) catches any leftover repo failure and maps it to Internal with a redacted detail — but re-fails an already-typed NotFound/Internal/Forbidden untouched, so the handler's own NotFound (above) wins. This is where the raw DB error stops and a client-safe error begins.
  • withRpcSpan(method) wraps the whole thing in the rpc.<method> span, a request counter, a latency histogram, and an error counter — tagged with the method only. Its comment warns why: never tag a metric with a user/session id, because "each attribute set is a permanent series in the process registry (an unbounded leak)."

Notice session.user.id is passed into every repo call. That's the userId the schema deliberately kept off CreateAudioInput — supplied by the server from the trusted session, never by the client.

Hop 5 — the Effect repo (Drizzle)

The repo is an Effect service over Drizzle. packages/db/src/effect/repos/audio-repo.ts:

export class AudioRepo extends Context.Service<AudioRepo>()("@repo/db/AudioRepo", {
  make: Effect.gen(function* () {
    const db = yield* PgDrizzle;
    const fail = repoFail(AudioRepoError);

    const listForUser = (userId: string) =>
      retryOnStaleConnection(
        db
          .select()
          .from(audio)
          .where(eq(audio.userId, userId))
          .orderBy(desc(audio.updatedAt))
          .pipe(Effect.mapError(fail("listForUser")))
      );

    const getById = (id: number, userId: string) =>
      Effect.gen(function* () {
        const rows = yield* db
          .select()
          .from(audio)
          .where(and(eq(audio.id, id), eq(audio.userId, userId)))
          .limit(1)
          .pipe(Effect.mapError(fail("getById")));
        return rows[0];
      }).pipe(retryOnStaleConnection);

    return { listForUser, getById, create, deleteById } as const;
  }),
}) {
  static readonly layerWith = pgRepoLayerWith(this);
  static readonly layer = this.layerWith(PgDrizzle.layer);
}

The canonical service shape of this monorepo, in miniature:

  • Context.Service<AudioRepo>()("@repo/db/AudioRepo", { make }) declares a service with a string tag and a make effect. make yields PgDrizzle — the repo's only dependency — and returns the record of query functions.
  • Every query is owner-scoped. getById filters on and(eq(audio.id, id), eq(audio.userId, userId)), so a non-owner gets undefined back, which the handler turns into NotFound — never another user's row. The class comment states the rule: "the userId is part of the WHERE clause, so a non-owner gets undefined (HTTP 404 at the edge) instead of another user's data."
  • Effect.mapError(fail("listForUser")) converts any Drizzle failure into a typed AudioRepoError tagged with the operation name. The repo never leaks a raw pg error upward — it speaks one typed error.
  • retryOnStaleConnection wraps the query with the same connection-drop retry the auth lookup used. Pooler drops are a fact of life on Supavisor; this is the app-wide answer.
  • static readonly layer = this.layerWith(PgDrizzle.layer) builds the service's Layer by providing it PgDrizzle. pgRepoLayerWith is a shared factory — the scaffold was byte-identical across every repo, so it lives once.

That layer is exactly what the router provides when it assembles the handlers. packages/api/src/rpc/router.ts:

export const ApiRpcHandlers = ApiRpc.toLayer(
  Effect.gen(function* () {
    const audioRepo = yield* AudioRepo; // (and every other repo)
    return ApiRpc.of({
      ...audioHandlers(audioRepo),
      // ...
    });
  })
);

export const ApiRpcLive = ApiRpcHandlers.pipe(
  Layer.provide(AudioRepo.layer) // supply the repo the handlers asked for
  // ...
);

ApiRpcHandlers asks for AudioRepo (via yield* AudioRepo); ApiRpcLive provides it (Layer.provide(AudioRepo.layer)). That is Effect's dependency injection: the handler declares what it needs in its type, and the layer graph — not manual wiring — satisfies it. Provide the repo's fake instead and the same handlers run in a unit test with no database.

Hop 6 — Postgres, under RLS

db.select().from(audio) becomes a parameterized SQL query executed against Supabase Postgres. Ownership is guarded in two independent layers:

  1. The app-level WHERE audio.user_id = $userId you just saw in the repo.
  2. Row-Level Security in the database itself — the policies live in packages/db/src/rls.ts. Even if a query forgot its WHERE, RLS scopes rows to the authenticated role, so a bug in application code can't hand one user another's data.

Belt and braces on purpose: the repo comment ("owner-scoped") and RLS are two answers to the same question, and you want both. The row(s) come back up through Drizzle as plain objects matching the audio table columns.

Hop 7 — the typed result comes home

Now everything unwinds. The success value flows back encoded through the schema: the handler returns Audio rows, RpcServer encodes them with the method's success schema (Schema.Array(Audio)), and the client decodes them against the same schema before the atom resolves. The React component receives a typed readonly Audio[] — no cast, no any.

If anything failed, it comes home as a member of the RpcError union declared in packages/api/src/rpc/errors.ts — each one a Schema.TaggedError, so it survives the JSON round trip as a typed value:

export class Unauthorized extends Schema.TaggedErrorClass<Unauthorized>()("Unauthorized", {
  message: Schema.String,
}) {}

export class NotFound extends Schema.TaggedErrorClass<NotFound>()("NotFound", {
  resource: Schema.String,
  id: Schema.Union([Schema.String, Schema.Number]),
}) {}

export class Internal extends Schema.TaggedErrorClass<Internal>()("Internal", {
  operation: Schema.String,
  detail: Schema.String,
}) {}

export const RpcError = Schema.Union([Unauthorized, Forbidden, NotFound, Internal]);

Trace each failure back to where it was born:

  • No / bad session → resolveSession fails Unauthorized at hop 3. The request never reaches the handler.
  • Signed in but not admin → requireAdmin fails Forbidden at hop 4.
  • Row not found / not owned → the handler's own NotFound guard at hop 4 (the repo returned undefined).
  • DB blew up → the repo's AudioRepoError at hop 5 is caught by failureToInternal and mapped to Internal with a redacted detail (REDACTED_DB_DETAIL), so raw internals never cross the wire — but the real cause is still logged server-side for you.

Because these are tagged, the client can match on _tag — NotFound renders an empty state, Forbidden a "not allowed," Internal a retry toast — instead of inspecting an opaque HTTP status. The error is a first-class value the whole way up.

Where decode/encode happens, in one glance: the client encodes the request and decodes the reply (against success or error); the session middleware decodes the better-auth payload into SessionShape; the RPC server decodes the incoming payload and encodes the handler's result. Three Schema boundaries, all fed by the same *-schema.ts files — which is exactly why a shape change can't silently break the wire.

The shape, once more

  • *-schema.ts — the contract. Runtime Schemas; types derived from them.
  • atom + RpcClient.make(ApiRpc) — the browser call, fully typed from the schema.
  • /api/rpc/route.ts — RpcServer.toHttpEffect + ServerLayer + runDbLive.
  • SessionMiddleware / resolveSession — first DB touch; decode the session, provide it as a service; the twin of getSessionFromHeaders.
  • handlers/*.ts + adminHandler — gate, read Session, call the repo; cross-cutting concerns wrapped once.
  • repos/*.ts — Context.Service over PgDrizzle, owner-scoped queries, typed repo errors, connection retry.
  • Postgres + rls.ts — the second, database-enforced owner check.
  • RpcError union — typed failures that survive JSON.

Once you see this spine, every feature in the app is the same seven hops with different nouns. To add one, start at hop 0 — write the Schema — and let the types pull you down the stack.

See also: The Effect RPC API layer for how to add a new endpoint, and Authentication for the session wrapper and why raw getSession is banned.

On this page