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) resolves user?: User, user: User | undefined, and aliases with the same meaning. The class is registered as UserCollector by 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 undefined when the parameter says it may be — user?: User, or an alias such as type 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.

Built with Fougere and Nuxt, hosted on GitHub Pages.