zmdbzero-maintenance data layer
Docs Benchmarks Anti-patterns OpenAPI
Docs / Ecosystem integrations

Field MiddlewareNot planned

Not planned. This capability had a frozen design and will not be built — the page stays so the answer is findable, and so is what to reach for instead. out of scope — a Chain binds to a route, and there are no fields to bind to

Not planned. @zmdb/web has no GraphQL field middleware because GraphQL is out of scope. The alternatives below use features that exist today, and the final preSave limitation still applies.

What field middleware is for#

Wrapping the resolution of a single field: masking a value, timing it, caching it, or checking authorisation on it. The zmdb equivalents split by _when_ the concern applies.

Field middleware usezmdb equivalent
Mask or redact a valueSensitive on the column, or select
Authorise a single fielda Chain bound to the field (frozen, below)
Transform on readpostSelect hook
Time or count a field's resolutiona driver wrapper, or an Interceptor in the field's chain

Do not fetch what you will not return#

The strongest version of field-level control, and it is available today:

const { items } = await repo.list({ select: ['id', 'title', 'createdAt'], page: { limit: 20 } });

select narrows both the SQL and the row type. A column that is not selected never leaves the database, so there is no value to mask, nothing in a log, nothing in a heap dump and nothing in an error payload. Field middleware masks _after_ fetching; this is strictly stronger.

The as const on a shared field list is required, or the array widens to string[] and you get the full row type back:

const PUBLIC = ['id', 'title', 'createdAt'] as const;

Sensitive and exactly what it does#

interface User extends Table<'users'> {
  id: number & Sql<'integer'> & Serial & PrimaryKey;
  email: string & Sql<'varchar'> & Length<320>;
  passwordHash: string & Sql<'text'> & Sensitive;
}
⚠️ Warning

Sensitive affects serialization, not queries. The column is still selected, still travels from the database into your process, and still appears in anything that stringifies the raw row — including a debug log or an error dump. It is a serialization marker, not an access control.

Combine it with select for defence in depth: select keeps the value out of the process, Sensitive catches the case where something serialises a row you did fetch.

Per-field authorisation, explicitly#

function toDto(post: Post, viewer: Viewer): PublicPost {
  const canSeeEmail = viewer.id === post.authorId || viewer.role === 'admin';
  const { authorEmail, ...rest } = post;
  return canSeeEmail ? { ...rest, authorEmail } : rest;
}

Verbose, and it has two properties field middleware does not: the rule is a plain function you can unit test without a server, and the compiler tells you when a new sensitive column appears — provided PublicPost is an Omit of the real entity rather than a hand-written interface.

The pattern that scales better is to make the unauthorised data unreachable at the data layer, with a per-request driver that sets a tenant or with row-level security. A control at that level cannot be forgotten by a new field; a per-field check can.

Transform on read#

postSelect is the one true per-field hook in the project:

class UserRepository extends BaseRepository<User> {
  protected override postSelect(row: Entity<User>): Entity<User> {
    return { ...row, email: row.email.toLowerCase() };
  }
}

It runs on every read path through the repository, which is what makes it trustworthy. The asymmetry to know about: there is no matching pre-write hook that covers create, update and the query builder uniformly, so an inbound transform — encrypting a column, say — has to live in your service. See Repository Hooks and Encryption.

Timing a field#

There is no per-field timing, and the driver wrapper gives you something more actionable — which query was slow, not which field was slow:

if (ms > 50) console.warn(JSON.stringify({ ms: Math.round(ms), sql: query.text }));

A slow field is nearly always a slow query or an N+1 pattern. Instrumenting the driver finds both; instrumenting the field tells you where you noticed.

What it would have taken#

Field middleware presupposes field resolution, so it followed the GraphQL layer — which is out of scope, so this is a record rather than a plan. The shape is frozen, in packages/web/src/graphql/SPEC.md §5 and §11, and it is not a new decorator:

const ownerOnly: Chain = { guards: [OwnerOrAdmin], pipes: [], interceptors: [], filters: [] };

registry.register<PostFields>(container.build(PostResolver), {
  post: { validate: raw => assert<{ id: number }>(raw) },
  authorEmail: { chain: ownerOnly },
});

It uses the same Chain, Guard, Pipe, Interceptor, and ExceptionFilter interfaces as an HTTP route, but binds them per field in the registration table. Two consequences follow:

section above gives — a control that a new field can be added without is a control that will be forgotten.

Sensitive still does not stop a resolver returning a value, exactly as the warning above says, so the select advice on this page keeps its force.

Because the binding is a table rather than an annotation, the boot check can be exhaustive: every decorated field must appear in it, and every key in it must be a decorated field. A typo is a boot failure rather than a field that silently resolves with no guard.

Three fields carry a chain and forty do not, in most schemas, and the frozen design is built around that ratio:

registry.register<PostFields>(resolver, bindings, {
  global: { guards: [Authenticated], pipes: [], interceptors: [timing], filters: [] },
  perType: { Post: { guards: [], pipes: [], interceptors: [], filters: [PostErrors] } },
});

Three declared levels, flattened once at registration. A field's own chain is the third, and the concatenation happens at boot, not per request — chainFor('Post', 'author') returns the same object every time. Guards, pipes and interceptors go broadest-first, so a global timer wraps the field's work and a field's pipe sees what the type's pipe produced.

Filters go narrowest-first, because the first filter that returns a response wins: a global catch-all placed first would swallow every error before a field's own filter ran, and every test would still pass.

A field with no chain in any of the three levels is not wrapped. The resolver map holds the bound method itself, so this feature costs a schema that does not use it nothing at all — not a small constant, zero. A field that does carry one allocates its context, plus a piped context only when there are pipes to fold.

The framework-side gap worth closing independently is the missing pre-write counterpart to postSelect — a preSave transform applied uniformly across create, update and the compiler. That would make transparent column encryption and normalisation possible without duplicating the logic in every write path, which is a real, current limitation rather than a GraphQL one.

---

See also: Repository Hooks · Query Performance · Encryption