@fougere/adapter-graphql 0.5.0-alpha.1 → 0.7.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,25 +1,13 @@
1
1
  import { upperFirst, FieldSet, Role } from '@fougere/schema';
2
- /**
3
- * Auto-register GraphQL types and operations from a fougere App.
4
- *
5
- * Reads scanned entities + handler facades and registers
6
- * types, inputs, queries and mutations automatically.
7
- * Respects handler method-based contracts and surfaces config.
8
- *
9
- * Relations (ref/many) are auto-wired between registered types.
10
- */
2
+ /** Auto-register GraphQL types and operations from a fougere App. */
11
3
  import type SchemaBuilder from '@pothos/core';
12
- import type { Fields, SchemaView, SchemaOrCard } from '@fougere/schema';
13
- import { Anatomy, fieldsOf, } from '@fougere/schema';
4
+ import type { Fields, SchemaView } from '@fougere/schema';
5
+ import { Shapes } from '@fougere/schema';
14
6
  import { registerType, registerOperations, type OperationMeta } from './pothos.js';
15
7
 
16
8
  type HandlerFacade = Record<string, Function>;
17
9
 
18
- /**
19
- * The relation a foreign key points at — `authorId → author`, `user_id → user`.
20
- * Returns undefined when the field carries no id suffix at all: there is nothing to
21
- * derive, and taking the scalar's own name would collide with it.
22
- */
10
+ /** The relation a foreign key points at — `authorId → author`, `user_id → user`. */
23
11
  function relationNameFor(fieldName: string): string | undefined {
24
12
  const stripped = fieldName.replace(/(_id|Id|ID)$/, '');
25
13
  return stripped && stripped !== fieldName ? stripped : undefined;
@@ -41,15 +29,7 @@ interface Batch {
41
29
  /** The batch of ONE direction — the two sides of a relation must not share a read. */
42
30
  const directionKey = (entity: string, field: string) => `${entity}#${field}`;
43
31
 
44
- /**
45
- * How many keys go into one `list` call.
46
- *
47
- * A page has no ceiling, and `list` is the one read the ORM refuses to split (a limit
48
- * and an order do not recompose across statements). So the slicing happens HERE, where
49
- * the answer is a map being assembled and slices merge for free. Below SQL Server's
50
- * 2100 bindings, the lowest of the four engines — this side does not know the dialect,
51
- * so it takes the floor rather than guessing.
52
- */
32
+ /** How many keys go into one `list` call. */
53
33
  const KEYS_PER_READ = 1000;
54
34
 
55
35
  /** Read a key set in slices, merging what each answers. */
@@ -74,24 +54,12 @@ const keepEach = <R>(into: Map<string, R>, from: Map<string, R>) => {
74
54
  /** A key answers a group: slices of the SAME key concatenate. */
75
55
  const concatEach = (into: Map<string, any[]>, from: Map<string, any[]>) => {
76
56
  for (const [key, rows] of from) {
77
- const held = into.get(key);
78
- if (held) held.push(...rows); else into.set(key, rows);
57
+ const bucket = into.get(key);
58
+ if (bucket) bucket.push(...rows); else into.set(key, rows);
79
59
  }
80
60
  };
81
61
 
82
- /**
83
- * The keys asked for during one tick, answered by one read.
84
- *
85
- * graphql-js calls a field resolver once per parent, so a page of 50 rows asked for
86
- * its relation 50 times — measured, with 5 distinct keys behind those 50 calls. The
87
- * keys of a tick are collected and answered together, which is the shape the framework
88
- * already imposes one level up: a presenter receives the PAGE (`egress.ts`).
89
- *
90
- * Scoped by the request's context object, which graphql-js hands identically to every
91
- * resolver of one request and never shares with another — two callers must never be
92
- * answered out of one read. When there is no context (a resolver called directly, as
93
- * the tests do), the scope is a shared object and the tick alone bounds the batch.
94
- */
62
+ /** The keys asked for during one tick, answered by one read. */
95
63
  const batches = new WeakMap<object, Map<string, Batch>>();
96
64
  const NO_CONTEXT: object = {};
97
65
 
@@ -127,12 +95,15 @@ function loadByKey<R>(
127
95
  interface EntityEntry {
128
96
  name: string;
129
97
  /** A live class in-process, a card from a frond whose class never crossed. */
130
- entityClass: SchemaOrCard;
98
+ entityClass: SchemaView;
131
99
  exposed?: boolean;
132
100
  }
133
101
 
134
102
  interface HandlerEntry {
135
- /** The name the door answers to — `PostHandler` → `post`. NOT an entity name: a handler may carry none. */
103
+ /**
104
+ * The name the door answers to — `PostHandler` → `post`. NOT an entity name: a handler may carry
105
+ * none.
106
+ */
136
107
  address: string;
137
108
  operations: Map<string, OperationMeta>;
138
109
  surface?: string;
@@ -174,32 +145,22 @@ interface AppLike {
174
145
  facadeFor(entity: string, surface?: string): Record<string, Function> | undefined;
175
146
  /** Canonical operation table produced by core. */
176
147
  operationsFor(entity: string, surface?: string): Map<string, OperationMeta> | undefined;
177
- /**
178
- * The presenter of an entity — `undefined` when none. Asked for rather than
179
- * resolved by a key spelled here: this adapter used to build `${Name}Presenter`
180
- * itself, and a convention respelled in two places drifts silently on the day it
181
- * changes, exactly as `facadeFor` exists to prevent for doors.
182
- */
148
+ /** The presenter of an entity — `undefined` when none. */
183
149
  presenterFor(entity: string): unknown | undefined;
184
150
  }
185
151
 
186
152
  // ─── Helpers ────────────────────────────────────
187
153
 
188
154
  /**
189
- * The key an entity is filed under — case-folded, because the same entity is spelled
190
- * differently depending on where its name came from: the scan yields the registration name
191
- * (`authorUser`), while a card's relation target is fully lowercased by `describe`
192
- * (`authoruser`). Folding both is what lets one registry serve both sources.
155
+ * The key an entity is filed under — case-folded, because the same entity is spelled differently
156
+ * depending on where its name came from: the scan yields the registration name (`authorUser`),
157
+ * while a card's relation target is fully lowercased by `describe` (`authoruser`).
193
158
  */
194
159
  function registryKey(entityName: string): string {
195
160
  return entityName.toLowerCase();
196
161
  }
197
162
 
198
- /**
199
- * The key a relation points at. A live entity class answers with its class name; a target
200
- * rebuilt from a lone card is a `{ name }` stand-in and answers with the name `describe`
201
- * wrote. Both are names, which is the whole reason this resolves by name.
202
- */
163
+ /** The key a relation points at. */
203
164
  function targetKey(target: unknown): string {
204
165
  return registryKey(String((target as { name?: string } | undefined)?.name ?? ''));
205
166
  }
@@ -213,20 +174,8 @@ export interface RegisterAllOptions {
213
174
  surface?: string;
214
175
  }
215
176
 
216
- /**
217
- * Auto-register GraphQL types and operations for all entities
218
- * in the app that have a matching handler facade.
219
- *
220
- * Operations are driven by parsed handler signatures (from the scanner).
221
- * Relations (ref/many) are auto-wired between registered entity types.
222
- */
223
- /**
224
- * The GraphQL type of a declared presenter view, built once per view class.
225
- *
226
- * Named after the field that emits it (`OrderItems`, `OrderUser`) rather than after the view
227
- * class, so two fields sharing one view still land on the same type and a view used twice is
228
- * registered once — Pothos refuses a duplicate type name and would take the schema down.
229
- */
177
+ /** Auto-register GraphQL types and operations from a fougere App. */
178
+ /** The GraphQL type of a declared presenter view, built once per view class. */
230
179
  const viewTypes = new WeakMap<object, any>();
231
180
  function viewTypeOf(
232
181
  builder: InstanceType<typeof SchemaBuilder>,
@@ -248,7 +197,7 @@ export function registerAll(
248
197
  // Collect registered types across all fronds for relation wiring, keyed by entity NAME.
249
198
  //
250
199
  // The name is the identity everywhere else in the system — `facadeFor(entity)`,
251
- // `ormFor(entity)`, `schemaFor(entity)` all take one, and the table, the GraphQL type
200
+ // `storageFor(entity)`, `schemaFor(entity)` all take one, and the table, the GraphQL type
252
201
  // and the DI match are all derived from it. This registry keyed by class OBJECT was the
253
202
  // lone dissent, and it cost a silent failure: a relation target that is not the very
254
203
  // object registered (an entity rebuilt from a card, whose `to()` leaves a `{ name }`
@@ -313,7 +262,7 @@ export function registerAll(
313
262
  typeRegistry.set(registryKey(entity.name), {
314
263
  name: typeName, type, facade,
315
264
  presenterFields: new Set(presenterMeta?.fields ?? []),
316
- fields: fieldsOf(entity.entityClass),
265
+ fields: entity.entityClass.getFields(),
317
266
  });
318
267
 
319
268
  const opOverrides = frond.operationsOverrides;
@@ -364,7 +313,7 @@ export function registerAll(
364
313
  // a presenter's computed field: the author named it, the author wins. Deriving
365
314
  // over it would either crash the build or shadow what they wrote.
366
315
  if (!relationName || relationName in fields || presenterFields.has(relationName)) continue;
367
- const nullable = Anatomy.isNullable(field.shape);
316
+ const nullable = Shapes.isNullable(field.shape);
368
317
 
369
318
  const targetKeyName = primaryNameOf(targetEntry.fields);
370
319
  const targetList = targetEntry.facade.list;
@@ -431,8 +380,8 @@ export function registerAll(
431
380
  const grouped = new Map<string, any[]>();
432
381
  for (const row of rows) {
433
382
  const key = String(row?.[reverseFkName]);
434
- const held = grouped.get(key);
435
- if (held) held.push(row); else grouped.set(key, [row]);
383
+ const bucket = grouped.get(key);
384
+ if (bucket) bucket.push(row); else grouped.set(key, [row]);
436
385
  }
437
386
  return grouped;
438
387
  }, concatEach),
package/src/index.ts CHANGED
@@ -1,12 +1,4 @@
1
- /**
2
- * The GraphQL surface, in two calls: derive the schema, then mount it.
3
- *
4
- * The Pothos primitives `registerAll` stands on live one import away, under
5
- * `@fougere/adapter-graphql/pothos`. They are a complement — a field the projection cannot
6
- * derive — never a second way to build what it already gives. Offering them at the same
7
- * rank made that hierarchy invisible: an agent looking for "how do I declare a type" found
8
- * three doors and rebuilt two hundred lines by hand (measured 2026-08-02).
9
- */
1
+ /** The GraphQL surface, in two calls. */
10
2
  export { registerAll } from './auto-register.js';
11
3
  export type { RegisterAllOptions } from './auto-register.js';
12
4
  export { registerGraphQL } from './serve.js';
@@ -1,9 +1,3 @@
1
- /**
2
- * The Pothos primitives — for what `registerAll` cannot derive, never to replace it.
3
- *
4
- * Reach for these to add a field the projection has no way to know about. Rebuilding
5
- * types, inputs and operations with them reimplements `registerAll` by hand and drops
6
- * what it wires for free: relations, and a presenter's computed fields.
7
- */
1
+ /** The Pothos primitives — for what `registerAll` cannot derive, never to replace it. */
8
2
  export { registerType, registerInput, registerOperations, registerObjectType } from './pothos.js';
9
3
  export type { TypeConfig, InputConfig, OperationsConfig, ObjectFieldDef } from './pothos.js';
package/src/pothos.ts CHANGED
@@ -3,9 +3,9 @@ import { upperFirst, Role } from '@fougere/schema';
3
3
  * @fougere/adapter-graphql — Pothos types derived from Fougere entities
4
4
  */
5
5
  import type SchemaBuilder from '@pothos/core';
6
- import { Anatomy, Schema, type Shape } from '@fougere/schema';
7
- import type { Field, Fields, SchemaView, SchemaOrCard } from '@fougere/schema';
8
- import { Boundary, Card, Lifecycle, schemaOf, Visibility } from '@fougere/schema';
6
+ import { Shapes, Schema, type Shape } from '@fougere/schema';
7
+ import type { Field, Fields, SchemaView } from '@fougere/schema';
8
+ import { Boundary, Card, Lifecycle, Visibility } from '@fougere/schema';
9
9
 
10
10
  // ─── Types ─────────────────────────────────────────
11
11
 
@@ -19,7 +19,7 @@ export interface TypeConfig {
19
19
  name: string;
20
20
  /** Entity source */
21
21
  /** The schema whose fields become the type — a live class, or a card that travelled. */
22
- entity: SchemaOrCard;
22
+ entity: SchemaView;
23
23
  /** Champs à exclure du type GraphQL */
24
24
  exclude?: string[];
25
25
  /** Relations à résoudre */
@@ -31,9 +31,8 @@ export interface TypeConfig {
31
31
  /** Per-field type metadata from source parsing. */
32
32
  presenterFieldMeta?: { name: string; returnType?: string; list?: boolean; nullable?: boolean }[];
33
33
  /**
34
- * The view a computed field emits, when the presenter declared one — the object type
35
- * to build for it. Without a declaration the scan reads a scalar or nothing, and an
36
- * object-valued field can only be serialized.
34
+ * The view a computed field emits, when the presenter declared one — the object type to build
35
+ * for it.
37
36
  */
38
37
  presenterViews?: Record<string, EntityClass | [EntityClass]>;
39
38
  /** Builds (or reuses) the GraphQL object type for a declared view. */
@@ -69,7 +68,7 @@ export interface OperationBinding {
69
68
  source:
70
69
  | { kind: 'collector' | 'context' | 'fact' }
71
70
  | { kind: 'param'; name: string }
72
- | { kind: 'body' | 'query' };
71
+ | { kind: 'input' | 'query' };
73
72
  }
74
73
 
75
74
  /** The projection-facing subset of core's EffectiveOperation. */
@@ -104,11 +103,7 @@ export interface OperationsConfig {
104
103
  * one root field. Absent when a caller builds a type by hand.
105
104
  */
106
105
  origin?: string;
107
- /**
108
- * The GraphQL type for a schema an operation declares as its return. The caller owns
109
- * this because it alone knows whether the schema IS the entity's (then: the type
110
- * already registered) or something else (then: a new named type).
111
- */
106
+ /** The GraphQL type for a schema an operation declares as its return. */
112
107
  viewType?: (view: SchemaView, opName: string) => any;
113
108
  }
114
109
 
@@ -135,13 +130,7 @@ const PRIMITIVES: Record<string, (t: any, required: boolean) => any> = {
135
130
  boolean: (t, r) => t.arg.boolean({ required: r }),
136
131
  };
137
132
 
138
- /**
139
- * Parameter types no GraphQL argument stands for. `ListOptions` is NOT one of
140
- * them: it has its own branch below that turns it into the six pagination
141
- * arguments. Listing it here classified it first — the skip branch runs before
142
- * the pagination one — so `kind: 'pagination'` was never assigned and every
143
- * `list(options?: ListOptions)` op reached GraphQL with no arguments at all.
144
- */
133
+ /** Parameter types no GraphQL argument stands for. */
145
134
  const SKIP_TYPES = new Set(['InvocationContext']);
146
135
 
147
136
  // ─── Helpers ───────────────────────────────────────
@@ -154,7 +143,7 @@ function fieldToGraphQL(
154
143
  ): any {
155
144
  // Dispatch on the BASE type via anatomy — `shape.type` may be the nullable
156
145
  // `[T,'null']` union, a direct comparison would fail silently on it.
157
- const { base: shape, nullable } = Anatomy.of(field.shape);
146
+ const { base: shape, nullable } = Shapes.of(field.shape);
158
147
 
159
148
  // Before the type switch: a bounded set is its own GraphQL type whatever its base type
160
149
  // carries. `oneOf` fed the form's `select` and the DDL's `CHECK` from the day it was
@@ -199,7 +188,7 @@ function fieldToGraphQL(
199
188
  case 'array': {
200
189
  // A value list (`list(text())`) becomes a GraphQL list of the item scalar; a list
201
190
  // of objects becomes a list of JSON strings — the same rule 'object' follows.
202
- const items = Anatomy.of(shape.items).base;
191
+ const items = Shapes.of(shape.items).base;
203
192
  const resolve = (parent: any) => parent[fieldName] ?? (nullable ? null : []);
204
193
  switch (items?.type) {
205
194
  case 'integer': return t.intList({ nullable, resolve });
@@ -240,14 +229,7 @@ function fieldToGraphQL(
240
229
  }
241
230
  }
242
231
 
243
- /**
244
- * A JSON-Schema object shape, turned into a GraphQL input type.
245
- *
246
- * `list(json(OrderLine))` inlines the line's shape as nested `properties` — good enough for
247
- * the judge, invisible to GraphQL until now: the `array` case fell through to `stringList`,
248
- * so `items` reached the schema as `[String!]!` and a client had to hand-encode every line
249
- * as JSON. The mutation was unusable (measured 2026-08-02).
250
- */
232
+ /** A JSON-Schema object shape, turned into a GraphQL input type. */
251
233
  function nestedInputType(
252
234
  builder: InstanceType<typeof SchemaBuilder>,
253
235
  shape: Shape,
@@ -266,7 +248,7 @@ function nestedInputType(
266
248
  const out: Record<string, any> = {};
267
249
  for (const [key, prop] of Object.entries(properties)) {
268
250
  const isRequired = required.has(key);
269
- switch (Anatomy.of(prop).base?.type) {
251
+ switch (Shapes.of(prop).base?.type) {
270
252
  case 'integer': out[key] = t.int({ required: isRequired }); break;
271
253
  case 'number': out[key] = t.float({ required: isRequired }); break;
272
254
  case 'boolean': out[key] = t.boolean({ required: isRequired }); break;
@@ -281,9 +263,9 @@ function nestedInputType(
281
263
  }
282
264
 
283
265
  /**
284
- * One input type per name, PER BUILDER — Pothos refuses a duplicate name, and a ref built on
285
- * one builder is unknown to the next: a global cache handed a stale ref to the second schema
286
- * ("InputObjectRef has not been implemented"). The builder owns its types, so it owns the map.
266
+ * One input type per name, PER BUILDER — Pothos refuses a duplicate name, and a ref built on one
267
+ * builder is unknown to the next: a global cache handed a stale ref to the second schema
268
+ * ("InputObjectRef has not been implemented").
287
269
  */
288
270
  const nestedInputs = new WeakMap<object, Map<string, any>>();
289
271
 
@@ -291,9 +273,8 @@ const nestedInputs = new WeakMap<object, Map<string, any>>();
291
273
  const enumTypes = new WeakMap<object, Map<string, { ref: any; values: string[] }>>();
292
274
 
293
275
  /**
294
- * A GraphQL enum value is an IDENTIFIER, not a string: `in-progress` or `à valider` cannot
295
- * be spelled in a query. `oneOf` is a JSON Schema keyword and accepts any string, so a set
296
- * that will not fit stays a `String` — the judge still refuses what is not in it.
276
+ * A GraphQL enum value is an IDENTIFIER, not a string: `in-progress` or `à valider` cannot be
277
+ * spelled in a query.
297
278
  */
298
279
  const GRAPHQL_NAME = /^[_A-Za-z][_0-9A-Za-z]*$/;
299
280
 
@@ -307,10 +288,6 @@ function enumValuesOf(shape: Shape | undefined): string[] | undefined {
307
288
  /**
308
289
  * The enum type for one field's value set — one per NAME per builder, so `Post.status` and
309
290
  * `CreatePostInput.status` are the same `PostStatus` and a value read can be written back.
310
- *
311
- * A second field claiming the name with a different set falls back to `String` rather than
312
- * being served the first one: two different sets under one name would let a client send a
313
- * value this field never declared, which is the opposite of what the enum is for.
314
291
  */
315
292
  function enumTypeFor(
316
293
  builder: InstanceType<typeof SchemaBuilder>,
@@ -346,7 +323,7 @@ function fieldToInput(
346
323
  // Required = the presence axis, projected onto GraphQL's single knob: the
347
324
  // caller must supply it (no `lifecycle.create` rule answers absence), null is
348
325
  // not legal, and the view is not in patch mode (a patch omits freely).
349
- const { base: shape, nullable } = Anatomy.of(field.shape);
326
+ const { base: shape, nullable } = Shapes.of(field.shape);
350
327
  const required = !patch && !nullable && Lifecycle.of(field).requiredAtCreate;
351
328
 
352
329
  // The dual of the output side, and it must be the SAME type: an input left as `String`
@@ -369,7 +346,7 @@ function fieldToInput(
369
346
  return t.boolean({ required });
370
347
 
371
348
  case 'array': {
372
- const items = Anatomy.of(shape.items).base;
349
+ const items = Shapes.of(shape.items).base;
373
350
  switch (items?.type) {
374
351
  case 'integer': return t.intList({ required });
375
352
  case 'number': return t.floatList({ required });
@@ -416,21 +393,7 @@ export interface ObjectFieldDef {
416
393
  resolve?: (parent: any) => any;
417
394
  }
418
395
 
419
- /**
420
- * Register a GraphQL object type from a declarative field map.
421
- *
422
- * Each field auto-resolves from `parent[key]` unless a custom `resolve` is provided.
423
- * Works for wrapper types, result types, or any structural type.
424
- *
425
- * ```ts
426
- * const PostList = registerObjectType(builder, 'PostList', {
427
- * items: { type: [PostType] },
428
- * total: { type: 'int', nullable: true, resolve: async (p) => lazyCount(p) },
429
- * hasMore: { type: 'boolean', nullable: true },
430
- * endCursor: { type: 'string', nullable: true },
431
- * });
432
- * ```
433
- */
396
+ /** Register a GraphQL object type from a declarative field map. */
434
397
  export function registerObjectType(
435
398
  builder: InstanceType<typeof SchemaBuilder>,
436
399
  name: string,
@@ -476,26 +439,10 @@ export function registerObjectType(
476
439
 
477
440
  // ─── Public API ────────────────────────────────────
478
441
 
479
- /**
480
- * Enregistre un type GraphQL (lecture) depuis une entité fougere.
481
- *
482
- * ```ts
483
- * const ProductType = registerType(builder, {
484
- * name: 'Product',
485
- * entity: Product,
486
- * exclude: ['categoryId'],
487
- * relations: {
488
- * category: {
489
- * type: CategoryType,
490
- * resolve: (parent) => db.select()...
491
- * },
492
- * },
493
- * });
494
- * ```
495
- */
442
+ /** Enregistre un type GraphQL (lecture) depuis une entité fougere. */
496
443
  export function registerType(builder: InstanceType<typeof SchemaBuilder>, config: TypeConfig): any {
497
444
  // A live class or a card — an adapter needs the fields, never the constructor.
498
- const schema = schemaOf(config.entity);
445
+ const schema = config.entity;
499
446
  const fields = schema.getFields();
500
447
  const exclude = new Set(config.exclude ?? []);
501
448
  // Who owns the enum names: the schema a view came from, so `PostCard.status` and
@@ -621,26 +568,7 @@ export function registerType(builder: InstanceType<typeof SchemaBuilder>, config
621
568
  });
622
569
  }
623
570
 
624
- /**
625
- * The GraphQL input for EXACTLY this view — or none, when the view asks for nothing.
626
- *
627
- * The caller derives the view and this projects it; it holds no policy of its own. What
628
- * a client may supply at CREATION is `Visibility.input`, which the op path applies for
629
- * create/update alone — `publish(input: Post)` must still name the post.
630
- *
631
- * Two things are dropped here because GraphQL cannot carry them, not because of any
632
- * rule: a collection has no column to send, and `boundary in: 'closed'` is refused from
633
- * every client. A view left with nothing after that gets `undefined` rather than an
634
- * input object with zero fields, which is invalid GraphQL and takes the WHOLE schema
635
- * down — every other type included.
636
- *
637
- * ```ts
638
- * const CreateProductInput = registerInput(builder, {
639
- * name: 'CreateProductInput',
640
- * schema: CreateProduct,
641
- * });
642
- * ```
643
- */
571
+ /** The GraphQL input for EXACTLY this view — or none, when the view asks for nothing. */
644
572
  export function registerInput(builder: InstanceType<typeof SchemaBuilder>, config: InputConfig): any {
645
573
  const fields = Object.fromEntries(
646
574
  Object.entries(config.schema.getFields())
@@ -676,25 +604,13 @@ export function registerInput(builder: InstanceType<typeof SchemaBuilder>, confi
676
604
 
677
605
  // ─── GraphQL field naming ────────────────────────
678
606
 
679
- /**
680
- * Who holds each root field — a GraphQL root is FLAT, and two ops can want one name.
681
- *
682
- * Refused here rather than left to Pothos: it answers `Duplicate field ofBook on
683
- * Mutation` with no file, no handler and no remedy, and it takes the whole schema down
684
- * — every other type included. The five CRUD names weave the entity in (`createBook`),
685
- * so they never meet this; a custom op keeps its method name, which is the author's and
686
- * says nothing about its subject. Four handlers named `ofBook` in one measured app.
687
- *
688
- * Nothing is renamed automatically: `chapterOfBook` would be this package's choice of
689
- * the app's public vocabulary, and adding an entity in some other frond would silently
690
- * rename a field already published.
691
- */
607
+ /** Who holds each root field — a GraphQL root is FLAT, and two ops can want one name. */
692
608
  const claimed = new WeakMap<object, Map<string, string>>();
693
609
  function claimRootField(builder: object, fieldName: string, origin: string): void {
694
- let held = claimed.get(builder);
695
- if (!held) { held = new Map(); claimed.set(builder, held); }
610
+ let perField = claimed.get(builder);
611
+ if (!perField) { perField = new Map(); claimed.set(builder, perField); }
696
612
 
697
- const first = held.get(fieldName);
613
+ const first = perField.get(fieldName);
698
614
  if (first !== undefined && first !== origin) {
699
615
  const opName = origin.split('.').pop();
700
616
  // `operations:` is keyed by op name PER FROND, so it cannot tell two handlers of the
@@ -710,7 +626,7 @@ function claimRootField(builder: object, fieldName: string, origin: string): voi
710
626
  + `A root field is global, so one of them has to give. ${remedy}`,
711
627
  );
712
628
  }
713
- held.set(fieldName, origin);
629
+ perField.set(fieldName, origin);
714
630
  }
715
631
 
716
632
  function graphqlFieldName(opName: string, entityName: string): string {
@@ -733,7 +649,7 @@ function graphqlFieldName(opName: string, entityName: string): string {
733
649
 
734
650
  interface ArgsResult {
735
651
  argsDef: (t: any) => Record<string, any>;
736
- buildInvocation: (args: any, gqlCtx: any) => { params: Record<string, any>; query: Record<string, any>; body: unknown; state: Record<string, any> };
652
+ buildInvocation: (args: any, gqlCtx: any) => { params: Record<string, any>; query: Record<string, any>; input: unknown; state: Record<string, any> };
737
653
  }
738
654
 
739
655
  function buildArgsFromSignature(
@@ -748,7 +664,7 @@ function buildArgsFromSignature(
748
664
  // invocation context, and publishing it would let callers impersonate that value.
749
665
  const paramPlan: {
750
666
  name: string;
751
- kind: 'primitive' | 'body' | 'skip' | 'pagination';
667
+ kind: 'primitive' | 'input' | 'skip' | 'pagination';
752
668
  typeName: string;
753
669
  optional: boolean;
754
670
  nullable: boolean;
@@ -772,8 +688,8 @@ function buildArgsFromSignature(
772
688
  case 'param':
773
689
  paramPlan.push({ name: param.name, kind: 'primitive', typeName, optional: binding.optional, nullable: param.type.nullable === true });
774
690
  continue;
775
- case 'body':
776
- paramPlan.push({ name: param.name, kind: 'body', typeName, optional: binding.optional, nullable: param.type.nullable === true });
691
+ case 'input':
692
+ paramPlan.push({ name: param.name, kind: 'input', typeName, optional: binding.optional, nullable: param.type.nullable === true });
777
693
  continue;
778
694
  }
779
695
  }
@@ -794,14 +710,14 @@ function buildArgsFromSignature(
794
710
  continue;
795
711
  }
796
712
 
797
- // Object/entity param → body
798
- paramPlan.push({ name: param.name, kind: 'body', typeName, optional: param.optional ?? false, nullable: param.type.nullable === true });
713
+ // Object/entity param → input
714
+ paramPlan.push({ name: param.name, kind: 'input', typeName, optional: param.optional ?? false, nullable: param.type.nullable === true });
799
715
  }
800
716
 
801
717
  // Register input type if needed
802
718
  let inputRef: any;
803
- const bodyParam = paramPlan.find((p) => p.kind === 'body');
804
- if (bodyParam && meta.input) {
719
+ const inputParam = paramPlan.find((p) => p.kind === 'input');
720
+ if (inputParam && meta.input) {
805
721
  // Only strip non-client fields for create/update — other ops may legitimately use them (e.g. publish(id))
806
722
  const isMutation = opName === 'create' || opName === 'update';
807
723
  const opInputFields = isMutation ? Visibility.of(meta.input.getFields()).input : meta.input.getFields();
@@ -831,8 +747,8 @@ function buildArgsFromSignature(
831
747
  }
832
748
  }
833
749
 
834
- if (bodyParam && inputRef) {
835
- args.input = t.arg({ type: inputRef, required: !bodyParam.optional && !bodyParam.nullable });
750
+ if (inputParam && inputRef) {
751
+ args.input = t.arg({ type: inputRef, required: !inputParam.optional && !inputParam.nullable });
836
752
  }
837
753
 
838
754
  if (hasPagination) {
@@ -849,7 +765,7 @@ function buildArgsFromSignature(
849
765
 
850
766
  const buildInvocation = (args: any, gqlCtx: any) => {
851
767
  const params: Record<string, any> = {};
852
- let body: unknown = undefined;
768
+ let input: unknown = undefined;
853
769
 
854
770
  for (const p of paramPlan) {
855
771
  if (p.kind === 'primitive') {
@@ -857,19 +773,19 @@ function buildArgsFromSignature(
857
773
  // null. Test undefined alone: `!= null` erased the second case and made
858
774
  // `foo?: T | null` indistinguishable from `foo?: T`.
859
775
  if (args[p.name] !== undefined) params[p.name] = args[p.name];
860
- } else if (p.kind === 'body') {
861
- body = args.input;
776
+ } else if (p.kind === 'input') {
777
+ input = args.input;
862
778
  } else if (p.kind === 'pagination') {
863
- // Collect pagination args into body (ListOptions)
779
+ // Collect pagination args into input (ListOptions)
864
780
  const options: Record<string, any> = {};
865
781
  for (const key of ['limit', 'offset', 'page', 'after', 'orderBy', 'order']) {
866
782
  if (args[key] !== undefined) options[key] = args[key];
867
783
  }
868
- body = options;
784
+ input = options;
869
785
  }
870
786
  }
871
787
 
872
- return { params, query: {}, body, state: gqlCtx?.state ?? {} };
788
+ return { params, query: {}, input, state: gqlCtx?.state ?? {} };
873
789
  };
874
790
 
875
791
  return { argsDef, buildInvocation };
@@ -895,17 +811,7 @@ function resolveOutputType(
895
811
  return { type: 'list-wrapper', isList: true, nullable: false };
896
812
  }
897
813
 
898
- /**
899
- * The type the operation SAYS it returns.
900
- *
901
- * `async stats(): Promise<StatsOutput[]>` is a declaration, and the scan already
902
- * resolved it into a live schema class. Until now this threw it away and announced the
903
- * entity's type instead, so a schema built from a handler with such an op was simply
904
- * wrong: its own fields were not queryable, and the entity's came back null.
905
- *
906
- * Falls back to the entity when nothing is declared, or when what is declared IS the
907
- * entity — `publish(): Promise<Post>` must not mint a second Post type.
908
- */
814
+ /** The type the operation SAYS it returns. */
909
815
  const declared = meta?.output && opName && config.viewType
910
816
  ? config.viewType(meta.output, opName)
911
817
  : undefined;
@@ -919,19 +825,7 @@ function resolveOutputType(
919
825
 
920
826
  // ─── registerOperations ──────────────────────────
921
827
 
922
- /**
923
- * Register all GraphQL operations for an entity from parsed handler signatures.
924
- *
925
- * Each operation in the map is registered as a Query or Mutation field
926
- * based on naming convention (list*, find*, get*, search* → Query, else → Mutation).
927
- *
928
- * Args are generated from the parsed method signature:
929
- * - Primitives (string, number) → scalar args
930
- * - Entity/object params → input types (derived from meta.input)
931
- * - Partial<T> wrapper → all input fields nullable
932
- * - ListOptions → standard pagination args
933
- * - InvocationContext → skipped (injected by resolver)
934
- */
828
+ /** Register all GraphQL operations for an entity from parsed handler signatures. */
935
829
  export function registerOperations(builder: InstanceType<typeof SchemaBuilder>, config: OperationsConfig): void {
936
830
  // Pre-register list wrapper type if list op exists
937
831
  let listWrapperType: any;
@@ -992,7 +886,7 @@ export function registerOperations(builder: InstanceType<typeof SchemaBuilder>,
992
886
  hasMore: result?.hasMore,
993
887
  _count: () => config.facade[opName]({
994
888
  ...invocation,
995
- body: { ...(invocation.body as any ?? {}), count: true, limit: 1 },
889
+ input: { ...(invocation.input as any ?? {}), count: true, limit: 1 },
996
890
  }),
997
891
  };
998
892
  }