Collectors
A collector resolves a handler parameter by its type from the invocation context. Operations declaring that parameter receive the value automatically.
import { Collector } from '@fougere/core';
import type { InvocationContext } from '@fougere/core';
import User from '../../user/entities/User.js';
/**
* The auth middleware puts the session user on ctx.state.user —
* this surfaces it to any handler that declares `user?: User`.
*/
export default class CurrentUserCollector extends Collector(User) {
async collect(ctx: InvocationContext) {
return ctx.state.user as User | undefined;
}
}
From then on, in any handler of the Frond:
async mine(user?: User): Promise<Post[]> { … } // nobody signed in: user is undefined
async publish(id: Post['id'], user: User): Promise<Post> { … } // nobody signed in: refused
A required parameter nothing filled
Without ?, the absence is a refusal, and the handler is never called. The collector says it in
its own words with missing(), which throws:
export default class CurrentUserCollector extends Collector(User) {
async collect(ctx: InvocationContext) {
return ctx.state.user as User | undefined;
}
missing(ctx: InvocationContext): never {
throw new FougereError({ code: ErrorCode.UNAUTHORIZED, message: 'Sign in first' });
}
}
missing() is handed the call, so it can tell one absence from another — nobody signed in, a
session expired. A collector with no missing() refuses with 400 user: Required, like a path
parameter left out. What missing() throws is read by the scan, through the helpers it calls, and
joins the refusals of every operation that requires the collector: the
handler no longer writes if (!user) throw …, and the contract still promises the 401.
Resolution rules
- The match is by checked type:
Collector(User)resolvesuser?: User,user: User | undefined, and aliases with the same meaning. The class is registered asUserCollectorby convention. - The target needs no schema. Nothing reads its fields, so an ordinary class works —
Collector(Ability)for a value you build from state at every call. - It runs for a call that comes through a door — the page, REST, GraphQL. Code calling
another Frond's
Facade<T>already holds the value and passes it:publish(id, user). A value left out is not collected. - Absence is
undefinedwhen the parameter says it may be —user?: User, or an alias such astype CurrentUser = User | undefined, which the type checker resolves. Without it, the absence is refused (above). - Collectors are DI-injectable classes — declare constructor dependencies as usual.
State and the gradient
ctx.state is the request state built by the consuming application, for example from the
session. During a remote call, this state is sent with the invocation, so the collector
receives the same ctx.state.user as it would locally.
What it may hold is declared, by the extensions of the process, with fields:
extensions: [{ name: 'tenancy', state: { tenant: text() } }]
betterAuth() declares user and session — the session without its token: a process
trusts the one that called it, never the cookie of whoever is behind it.
The state is judged where it entered — in the process whose host filled it, or whose middleware wrote a member — and nowhere after:
- a member nobody declares there is refused —
state.tenant: Unknown field — this process declares user, session; - two extensions declaring one member refuse the boot, naming both;
- a process that receives the call declares nothing. What crossed was judged where it entered, and the envelope proves which process wrote it.
What a handler READS is read by the type it asks for: a collector written Collector(User)
hands on what it collected as a User, judged and rebuilt. state.user.since is a Date in
process and across the wire, where it used to arrive as the string JSON made of it.
A collector does not cross a Frond boundary
Keep the collector in each Frond that consumes it, or in the Frond they all extend. The binding is decided at boot, from the collectors of that Frond and of the one it extends — the same reach its scope has — not at the split, and not per call:
- a collector for that type in another Frond is not used: the parameter is read from the
request, and the boot warns
collector-in-another-frond, with both ways out — move the collector into this Frond, or say the request is meant; - no collector for that type anywhere makes it an ordinary input: it falls into the fourth
binding rule,
body.
Saying the request is meant is a binding in frond.config.ts, and it names that parameter only —
the others keep what Fougere derives:
// fronds/admin/frond.config.ts — the invited user comes from the request
export default {
operations: { 'invite.inviteUser': { binding: [{ name: 'user', source: { kind: 'input' } }] } },
};
Put plainly: user?: User in a project that declares no UserCollector at all receives
whatever the client sent. A handler validating on user.role validates a caller-supplied value.
Next: Errors — typed errors across layers.