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:
- A React atom calls the typed RPC client —
client["audio/list"](). - The client Schema-encodes the request and
POSTs it to/api/rpc. - The Next.js route hands the request to an Effect
RpcServer. - The session middleware resolves + decodes the caller's session.
- The handler gates on admin, reads the
Session, calls the repo. - The repo (an Effect service over Drizzle) runs an owner-scoped query.
- Supabase Postgres runs it under Row-Level Security.
- 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:
Audiois a runtime value (aSchema.Struct), not just a type. That value can validate data at runtime. The TypeScript type is derived from it withtype Audio = typeof Audio.Type— so the type can never drift from the validator. The file's own header says it: "Derive types viatype X = typeof X.Typeso the runtime schema stays authoritative." You never hand-write the interface.- The input schema is a separate, smaller struct.
CreateAudioInputomitsuserId,id, and the timestamps — the comment explains why: "userIdcomes 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 returnsEffect<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'spayloadschema,POSTs it, then decodes the JSON reply against thesuccessschema (or theerrorschema) before handing it back. You get a decodedAudio[], never a rawunknown.requestInit: { credentials: "include" }is what ships the auth cookie with the request — the server needs it to resolve the session.withReactivity([...])+Effect.scopedare 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 sameApiRpcdefinition into an HTTP handler that knows how to routeaudio/listto 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 viagetRuntime()each call, so a pool rebuilt byhealStalePooltakes 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-basedgetSessioninto 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 viaEffect.promise— alwaystryPromise.retryOnStaleConnectionre-attempts the lookup on a dropped Supavisor pooler connection. The comment explains the bug it fixes: a connection drop here "used to collapse straight toUnauthorized, 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. Therawbetter-auth payload isunknownuntil this line; decoding both validates it and strips every field not in the allowlist (SessionSchemainsession.tskeeps onlyid,email,role, a few UI prefs). Everything downstream sees the narrow, trustedSessionShape.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:
requireAdminreads theSessionand fails withForbiddenunless the user's role is admin. It sits outsidefailureToInternal, so a denial stays a 403 and is never rewritten into a 500.failureToInternal(method)catches any leftover repo failure and maps it toInternalwith a redacted detail — but re-fails an already-typedNotFound/Internal/Forbiddenuntouched, so the handler's ownNotFound(above) wins. This is where the raw DB error stops and a client-safe error begins.withRpcSpan(method)wraps the whole thing in therpc.<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 amakeeffect.makeyieldsPgDrizzle— the repo's only dependency — and returns the record of query functions.- Every query is owner-scoped.
getByIdfilters onand(eq(audio.id, id), eq(audio.userId, userId)), so a non-owner getsundefinedback, which the handler turns intoNotFound— never another user's row. The class comment states the rule: "theuserIdis part of theWHEREclause, so a non-owner getsundefined(HTTP 404 at the edge) instead of another user's data." Effect.mapError(fail("listForUser"))converts any Drizzle failure into a typedAudioRepoErrortagged with the operation name. The repo never leaks a rawpgerror upward — it speaks one typed error.retryOnStaleConnectionwraps 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'sLayerby providing itPgDrizzle.pgRepoLayerWithis 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:
- The app-level
WHERE audio.user_id = $userIdyou just saw in the repo. - Row-Level Security in the database itself — the policies live in
packages/db/src/rls.ts. Even if a query forgot itsWHERE, 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 →
resolveSessionfailsUnauthorizedat hop 3. The request never reaches the handler. - Signed in but not admin →
requireAdminfailsForbiddenat hop 4. - Row not found / not owned → the handler's own
NotFoundguard at hop 4 (the repo returnedundefined). - DB blew up → the repo's
AudioRepoErrorat hop 5 is caught byfailureToInternaland mapped toInternalwith 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 ofgetSessionFromHeaders.handlers/*.ts+adminHandler— gate, readSession, call the repo; cross-cutting concerns wrapped once.repos/*.ts—Context.ServiceoverPgDrizzle, owner-scoped queries, typed repo errors, connection retry.- Postgres +
rls.ts— the second, database-enforced owner check. RpcErrorunion — 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.
The Effect RPC API layer
How the Effect v4 RPC surface replaced tRPC — schema groups, Effect handlers, the router layer, the @repo/api client, and how to add a new RPC.
Authentication (Better Auth)
How Better Auth is wired here — the server instance, the Effect-wrapped AuthService, the client, and the retry-guarded session lookup.