@fougere/adapter-graphql 0.2.0-alpha.2 → 0.4.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.
package/src/pothos.ts ADDED
@@ -0,0 +1,1011 @@
1
+ import { upperFirst, Role } from '@fougere/schema';
2
+ /**
3
+ * @fougere/adapter-graphql — Pothos types derived from Fougere entities
4
+ */
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';
9
+
10
+ // ─── Types ─────────────────────────────────────────
11
+
12
+ type EntityClass = SchemaView & (abstract new (...args: any[]) => any);
13
+
14
+ /** Presenter instance — each method is a computed field resolver. */
15
+ type PresenterInstance = Record<string, (parent: any) => any>;
16
+
17
+ export interface TypeConfig {
18
+ /** Nom du type GraphQL */
19
+ name: string;
20
+ /** Entity source */
21
+ /** The schema whose fields become the type — a live class, or a card that travelled. */
22
+ entity: SchemaOrCard;
23
+ /** Champs à exclure du type GraphQL */
24
+ exclude?: string[];
25
+ /** Relations à résoudre */
26
+ relations?: Record<string, RelationConfig>;
27
+ /** Presenter instance — adds computed fields as resolveFields on this type. */
28
+ presenter?: PresenterInstance;
29
+ /** Presenter field names (methods to expose). If absent, all methods are exposed. */
30
+ presenterFields?: string[];
31
+ /** Per-field type metadata from source parsing. */
32
+ presenterFieldMeta?: { name: string; returnType?: string; list?: boolean; nullable?: boolean }[];
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.
37
+ */
38
+ presenterViews?: Record<string, EntityClass | [EntityClass]>;
39
+ /** Builds (or reuses) the GraphQL object type for a declared view. */
40
+ viewType?: (view: EntityClass, fieldName: string) => any;
41
+ }
42
+
43
+ export interface RelationConfig {
44
+ /** Type GraphQL cible (retourné par registerType) */
45
+ type: any;
46
+ /** Est-ce une liste ? */
47
+ list?: boolean;
48
+ /** Résolveur personnalisé */
49
+ resolve: (parent: any) => any;
50
+ }
51
+
52
+ export interface InputConfig {
53
+ /** The GraphQL type name. */
54
+ name: string;
55
+ /** The view to project — derive it (`pick`/`omit`/`partial`) before handing it over. */
56
+ schema: SchemaView;
57
+ }
58
+
59
+ /** Parsed method signature (mirrors core OperationMeta.signature). */
60
+ interface ParsedSignature {
61
+ name: string;
62
+ params: { name: string; type: { raw: string; name: string; array?: boolean; nullable?: boolean; undefined?: boolean; generics?: ParsedSignature['params'][0]['type'][] }; optional?: boolean }[];
63
+ returnType?: { raw: string; name: string; array?: boolean; nullable?: boolean; undefined?: boolean; generics?: ParsedSignature['params'][0]['type'][] };
64
+ }
65
+
66
+ interface OperationBinding {
67
+ name: string;
68
+ optional: boolean;
69
+ source:
70
+ | { kind: 'collector' | 'context' | 'fact' }
71
+ | { kind: 'param'; name: string }
72
+ | { kind: 'body' | 'query' };
73
+ }
74
+
75
+ /** The projection-facing subset of core's EffectiveOperation. */
76
+ interface OperationMeta {
77
+ input?: SchemaView;
78
+ output?: SchemaView;
79
+ /** Canonical kind from core's EffectiveOperation. */
80
+ kind: 'query' | 'command';
81
+ signature?: ParsedSignature;
82
+ /** The façade's effective answer to where every parameter comes from. */
83
+ binding?: OperationBinding[];
84
+ /**
85
+ * The operation in words. It reaches here through core's EffectiveOperation table;
86
+ * this narrowed view simply carries it to the GraphQL field.
87
+ */
88
+ description?: string;
89
+ }
90
+
91
+ export interface OperationsConfig {
92
+ /** Entity name (PascalCase). */
93
+ name: string;
94
+ /** GraphQL entity type ref (from registerType). */
95
+ type: any;
96
+ /** Handler facade — each op receives InvocationContext. */
97
+ facade: Record<string, Function>;
98
+ /** Operations metadata from scanner (signature + resolved schemas). */
99
+ operations: Map<string, OperationMeta>;
100
+ /** Per-op kind overrides from frond.config.ts (optional). */
101
+ operationsOverrides?: Record<string, { kind?: 'query' | 'command'; graphql?: string }>;
102
+ /**
103
+ * Who is registering — `catalog/ChapterHandler`, for the message when two ops claim
104
+ * one root field. Absent when a caller builds a type by hand.
105
+ */
106
+ 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
+ */
112
+ viewType?: (view: SchemaView, opName: string) => any;
113
+ }
114
+
115
+ function operationIsQuery(
116
+ name: string,
117
+ resolved?: OperationMeta['kind'],
118
+ ): boolean {
119
+ if (!resolved) {
120
+ throw new Error(
121
+ `GraphQL cannot project '${name}' without its resolved operation kind. `
122
+ + 'Pass the EffectiveOperation table produced by core.',
123
+ );
124
+ }
125
+ return resolved === 'query';
126
+ }
127
+
128
+ function pluralize(name: string): string {
129
+ return name.endsWith('y') ? name.slice(0, -1) + 'ies' : name + 's';
130
+ }
131
+
132
+ const PRIMITIVES: Record<string, (t: any, required: boolean) => any> = {
133
+ string: (t, r) => t.arg.string({ required: r }),
134
+ number: (t, r) => t.arg.int({ required: r }),
135
+ boolean: (t, r) => t.arg.boolean({ required: r }),
136
+ };
137
+
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
+ */
145
+ const SKIP_TYPES = new Set(['InvocationContext']);
146
+
147
+ // ─── Helpers ───────────────────────────────────────
148
+
149
+ function fieldToGraphQL(
150
+ t: any,
151
+ field: Field,
152
+ fieldName: string,
153
+ enumFor?: (values: string[]) => any | undefined,
154
+ ): any {
155
+ // Dispatch on the BASE type via anatomy — `shape.type` may be the nullable
156
+ // `[T,'null']` union, a direct comparison would fail silently on it.
157
+ const { base: shape, nullable } = Anatomy.of(field.shape);
158
+
159
+ // Before the type switch: a bounded set is its own GraphQL type whatever its base type
160
+ // carries. `oneOf` fed the form's `select` and the DDL's `CHECK` from the day it was
161
+ // written; here it fell through to `String`, so a schema explorer showed nothing of the
162
+ // set and a generated client could not narrow the union.
163
+ // Only the TYPE changes here. `nullable` is passed exactly where the `String` branch below
164
+ // passes it and omitted where that branch calls `exposeString` — spelling `nullable: false`
165
+ // instead would emit `PostStatus!` next to a `title: String` on the same type, so a bounded
166
+ // set would carry a stricter contract than a plain field for no reason of its own.
167
+ const values = enumFor && enumValuesOf(shape);
168
+ if (values) {
169
+ const ref = enumFor(values);
170
+ if (ref) {
171
+ const resolve = (parent: any) => parent[fieldName] ?? null;
172
+ return nullable ? t.field({ type: ref, nullable: true, resolve }) : t.field({ type: ref, resolve });
173
+ }
174
+ }
175
+
176
+ switch (shape?.type) {
177
+ case 'integer':
178
+ return nullable ? t.int({ nullable: true, resolve: (parent: any) => parent[fieldName] ?? null })
179
+ : t.exposeInt(fieldName);
180
+
181
+ case 'number':
182
+ return nullable ? t.float({ nullable: true, resolve: (parent: any) => parent[fieldName] ?? null })
183
+ : t.exposeFloat(fieldName);
184
+
185
+ case 'boolean':
186
+ return nullable ? t.boolean({ nullable: true, resolve: (parent: any) => parent[fieldName] ?? null })
187
+ : t.exposeBoolean(fieldName);
188
+
189
+ case 'object':
190
+ // JSON → String sérialisé
191
+ return t.string({
192
+ nullable,
193
+ resolve: (parent: any) => {
194
+ const val = parent[fieldName];
195
+ return val != null ? JSON.stringify(val) : null;
196
+ },
197
+ });
198
+
199
+ case 'array': {
200
+ // A value list (`list(text())`) becomes a GraphQL list of the item scalar; a list
201
+ // of objects becomes a list of JSON strings — the same rule 'object' follows.
202
+ const items = Anatomy.of(shape.items).base;
203
+ const resolve = (parent: any) => parent[fieldName] ?? (nullable ? null : []);
204
+ switch (items?.type) {
205
+ case 'integer': return t.intList({ nullable, resolve });
206
+ case 'number': return t.floatList({ nullable, resolve });
207
+ case 'boolean': return t.booleanList({ nullable, resolve });
208
+ case 'object':
209
+ return t.stringList({
210
+ nullable,
211
+ resolve: (parent: any) => {
212
+ const val = parent[fieldName];
213
+ return val != null ? val.map((v: unknown) => JSON.stringify(v)) : (nullable ? null : []);
214
+ },
215
+ });
216
+ default: return t.stringList({ nullable, resolve });
217
+ }
218
+ }
219
+
220
+ case 'string':
221
+ // date-time → String GraphQL, encoded on egress via the field's boundary (Date → ISO).
222
+ // Exposing the raw Date would let Pothos coerce it to a non-ISO `String(date)`.
223
+ if (shape.format === 'date-time') {
224
+ return t.string({
225
+ nullable,
226
+ resolve: (parent: any) => {
227
+ const val = parent[fieldName];
228
+ return val != null ? Boundary.of(field).encode(val) : null;
229
+ },
230
+ });
231
+ }
232
+ // string (id, texte, enum, ref) → String GraphQL
233
+ return nullable ? t.string({ nullable: true, resolve: (parent: any) => parent[fieldName] ?? null })
234
+ : t.exposeString(fieldName);
235
+
236
+ default:
237
+ // pas de shape (relation many) → String GraphQL
238
+ return nullable ? t.string({ nullable: true, resolve: (parent: any) => parent[fieldName] ?? null })
239
+ : t.exposeString(fieldName);
240
+ }
241
+ }
242
+
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
+ */
251
+ function nestedInputType(
252
+ builder: InstanceType<typeof SchemaBuilder>,
253
+ shape: Shape,
254
+ name: string,
255
+ ): any {
256
+ let perBuilder = nestedInputs.get(builder as object);
257
+ if (!perBuilder) nestedInputs.set(builder as object, (perBuilder = new Map()));
258
+ const known = perBuilder.get(name);
259
+ if (known) return known;
260
+
261
+ // Only an object shape has these two, and only an object shape reaches here.
262
+ const properties: Record<string, Shape> = 'properties' in shape ? (shape.properties ?? {}) as Record<string, Shape> : {};
263
+ const required = new Set<string>('required' in shape ? shape.required ?? [] : []);
264
+ const type = (builder as any).inputType(name, {
265
+ fields: (t: any) => {
266
+ const out: Record<string, any> = {};
267
+ for (const [key, prop] of Object.entries(properties)) {
268
+ const isRequired = required.has(key);
269
+ switch (Anatomy.of(prop).base?.type) {
270
+ case 'integer': out[key] = t.int({ required: isRequired }); break;
271
+ case 'number': out[key] = t.float({ required: isRequired }); break;
272
+ case 'boolean': out[key] = t.boolean({ required: isRequired }); break;
273
+ default: out[key] = t.string({ required: isRequired }); break;
274
+ }
275
+ }
276
+ return out;
277
+ },
278
+ });
279
+ perBuilder.set(name, type);
280
+ return type;
281
+ }
282
+
283
+ /**
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.
287
+ */
288
+ const nestedInputs = new WeakMap<object, Map<string, any>>();
289
+
290
+ /** Same rule as {@link nestedInputs}, for the enum types — with the values, see below. */
291
+ const enumTypes = new WeakMap<object, Map<string, { ref: any; values: string[] }>>();
292
+
293
+ /**
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.
297
+ */
298
+ const GRAPHQL_NAME = /^[_A-Za-z][_0-9A-Za-z]*$/;
299
+
300
+ function enumValuesOf(shape: Shape | undefined): string[] | undefined {
301
+ const values = shape && 'enum' in shape ? shape.enum : undefined;
302
+ if (!Array.isArray(values) || values.length === 0) return undefined;
303
+ if (!values.every((v) => typeof v === 'string' && GRAPHQL_NAME.test(v))) return undefined;
304
+ return values as string[];
305
+ }
306
+
307
+ /**
308
+ * The enum type for one field's value set — one per NAME per builder, so `Post.status` and
309
+ * `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
+ */
315
+ function enumTypeFor(
316
+ builder: InstanceType<typeof SchemaBuilder>,
317
+ name: string,
318
+ values: string[],
319
+ ): any | undefined {
320
+ let perBuilder = enumTypes.get(builder as object);
321
+ if (!perBuilder) enumTypes.set(builder as object, (perBuilder = new Map()));
322
+
323
+ const known = perBuilder.get(name);
324
+ if (known) {
325
+ const same = known.values.length === values.length && known.values.every((v, i) => v === values[i]);
326
+ return same ? known.ref : undefined;
327
+ }
328
+
329
+ const ref = (builder as any).enumType(name, { values });
330
+ perBuilder.set(name, { ref, values });
331
+ return ref;
332
+ }
333
+
334
+ /** `Post` + `status` → `PostStatus`. Undefined when the schema has no name to build on. */
335
+ function enumNameFor(owner: string | undefined, fieldName: string): string | undefined {
336
+ return owner ? `${owner}${upperFirst(fieldName)}` : undefined;
337
+ }
338
+
339
+ function fieldToInput(
340
+ t: any,
341
+ field: Field,
342
+ patch: boolean,
343
+ nested?: (shape: Shape, suffix: string) => any,
344
+ enumFor?: (values: string[]) => any | undefined,
345
+ ): any {
346
+ // Required = the presence axis, projected onto GraphQL's single knob: the
347
+ // caller must supply it (no `lifecycle.create` rule answers absence), null is
348
+ // not legal, and the view is not in patch mode (a patch omits freely).
349
+ const { base: shape, nullable } = Anatomy.of(field.shape);
350
+ const required = !patch && !nullable && Lifecycle.of(field).requiredAtCreate;
351
+
352
+ // The dual of the output side, and it must be the SAME type: an input left as `String`
353
+ // would refuse nothing the enum refuses, and a client could not hand back the value a
354
+ // query just gave it.
355
+ const values = enumFor && enumValuesOf(shape);
356
+ if (values) {
357
+ const ref = enumFor(values);
358
+ if (ref) return t.field({ type: ref, required });
359
+ }
360
+
361
+ switch (shape?.type) {
362
+ case 'integer':
363
+ return t.int({ required });
364
+
365
+ case 'number':
366
+ return t.float({ required });
367
+
368
+ case 'boolean':
369
+ return t.boolean({ required });
370
+
371
+ case 'array': {
372
+ const items = Anatomy.of(shape.items).base;
373
+ switch (items?.type) {
374
+ case 'integer': return t.intList({ required });
375
+ case 'number': return t.floatList({ required });
376
+ case 'boolean': return t.booleanList({ required });
377
+ case 'object': {
378
+ // A nested shape IS a type — serializing it would make the caller encode JSON by hand.
379
+ const built = nested?.(items, 'Item');
380
+ return built ? t.field({ type: [built], required }) : t.stringList({ required });
381
+ }
382
+ default: return t.stringList({ required });
383
+ }
384
+ }
385
+
386
+ case 'string':
387
+ case 'object':
388
+ default:
389
+ return t.string({ required });
390
+ }
391
+ }
392
+
393
+ // ─── Scalars ──────────────────────────────────────
394
+
395
+ type ScalarName = 'string' | 'int' | 'float' | 'boolean';
396
+
397
+ const SCALARS: Record<ScalarName, (t: any, opts: any) => any> = {
398
+ string: (t, o) => o.nullable ? t.string(o) : t.string(o),
399
+ int: (t, o) => o.nullable ? t.int(o) : t.int(o),
400
+ float: (t, o) => o.nullable ? t.float(o) : t.float(o),
401
+ boolean: (t, o) => o.nullable ? t.boolean(o) : t.boolean(o),
402
+ };
403
+
404
+ function isScalar(type: unknown): type is ScalarName {
405
+ return typeof type === 'string' && type in SCALARS;
406
+ }
407
+
408
+ // ─── registerObjectType ───────────────────────────
409
+
410
+ /** Declarative field definition for registerObjectType. */
411
+ export interface ObjectFieldDef {
412
+ /** Scalar name ('string', 'int', 'float', 'boolean') or Pothos type ref. Use [ref] for lists. */
413
+ type: ScalarName | any;
414
+ nullable?: boolean;
415
+ /** Custom resolver. Defaults to `(parent) => parent[fieldName]`. */
416
+ resolve?: (parent: any) => any;
417
+ }
418
+
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
+ */
434
+ export function registerObjectType(
435
+ builder: InstanceType<typeof SchemaBuilder>,
436
+ name: string,
437
+ fieldDefs: Record<string, ObjectFieldDef>,
438
+ ): any {
439
+ return (builder as any).objectRef(name).implement({
440
+ fields: (t: any) => {
441
+ const result: Record<string, any> = {};
442
+ for (const [key, def] of Object.entries(fieldDefs)) {
443
+ const nullable = def.nullable ?? false;
444
+
445
+ // Custom resolve → field resolver (lazy/computed fields)
446
+ if (def.resolve) {
447
+ if (Array.isArray(def.type)) {
448
+ result[key] = t.field({ type: def.type, nullable, resolve: def.resolve });
449
+ } else if (isScalar(def.type)) {
450
+ result[key] = SCALARS[def.type](t, { nullable, resolve: def.resolve });
451
+ } else {
452
+ result[key] = t.field({ type: def.type, nullable, resolve: def.resolve });
453
+ }
454
+ continue;
455
+ }
456
+
457
+ // No custom resolve → expose directly from parent (no resolveField overhead)
458
+ if (isScalar(def.type)) {
459
+ const expose = {
460
+ string: () => t.exposeString(key, { nullable }),
461
+ int: () => t.exposeInt(key, { nullable }),
462
+ float: () => t.exposeFloat(key, { nullable }),
463
+ boolean: () => t.exposeBoolean(key, { nullable }),
464
+ };
465
+ result[key] = expose[def.type]();
466
+ } else if (Array.isArray(def.type)) {
467
+ result[key] = t.field({ type: def.type, nullable, resolve: (parent: any) => parent[key] });
468
+ } else {
469
+ result[key] = t.field({ type: def.type, nullable, resolve: (parent: any) => parent[key] });
470
+ }
471
+ }
472
+ return result;
473
+ },
474
+ });
475
+ }
476
+
477
+ // ─── Public API ────────────────────────────────────
478
+
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
+ */
496
+ export function registerType(builder: InstanceType<typeof SchemaBuilder>, config: TypeConfig): any {
497
+ // A live class or a card — an adapter needs the fields, never the constructor.
498
+ const schema = schemaOf(config.entity);
499
+ const fields = schema.getFields();
500
+ const exclude = new Set(config.exclude ?? []);
501
+ // Who owns the enum names: the schema a view came from, so `PostCard.status` and
502
+ // `CreatePostInput.status` land on the one `PostStatus`. A card that travelled carries no
503
+ // class name — the GraphQL type name is then the best owner available.
504
+ const enumOwner = Card.fromSchema(schema).descriptor.title ?? config.name;
505
+
506
+ return (builder as any).objectRef(config.name).implement({
507
+ fields: (t: any) => {
508
+ const result: Record<string, any> = {};
509
+
510
+ for (const [fieldName, field] of Object.entries(fields)) {
511
+ if (exclude.has(fieldName)) continue;
512
+ // Skip 'many' fields — handled by relations
513
+ if (Role.of(field).isCollection) continue;
514
+ // Skip fields that have a relation override
515
+ if (config.relations?.[fieldName]) continue;
516
+ // Write-only (boundary out: 'closed', e.g. password): never emitted
517
+ if (Boundary.of(field).writeOnly) continue;
518
+
519
+ result[fieldName] = fieldToGraphQL(t, field, fieldName, (values) => {
520
+ const name = enumNameFor(enumOwner, fieldName);
521
+ return name ? enumTypeFor(builder, name, values) : undefined;
522
+ });
523
+ }
524
+
525
+ // Add relations
526
+ if (config.relations) {
527
+ for (const [name, rel] of Object.entries(config.relations)) {
528
+ if (rel.list) {
529
+ result[name] = t.field({
530
+ type: [rel.type],
531
+ resolve: rel.resolve,
532
+ });
533
+ } else {
534
+ result[name] = t.field({
535
+ type: rel.type,
536
+ resolve: rel.resolve,
537
+ });
538
+ }
539
+ }
540
+ }
541
+
542
+ // Add presenter computed fields (resolveField — called only when requested)
543
+ if (config.presenter) {
544
+ const allowed = config.presenterFields
545
+ ? new Set(config.presenterFields)
546
+ : null;
547
+ const metaMap = new Map(
548
+ (config.presenterFieldMeta ?? []).map((m) => [m.name, m]),
549
+ );
550
+ // The names the scan found, looked up on the instance — never `Object.entries`.
551
+ // A presenter is a class instance: its methods live on the prototype, so own
552
+ // enumerable properties are its INJECTED DEPENDENCIES and nothing else. Enumerating
553
+ // them added no computed field at all and would have exposed the ORMs if any name
554
+ // had matched — an Order reached GraphQL with neither user, items nor total, while
555
+ // REST carried all three from the same presenter.
556
+ const names = config.presenterFields ?? Object.getOwnPropertyNames(
557
+ Object.getPrototypeOf(config.presenter),
558
+ );
559
+ for (const name of names) {
560
+ if (name === 'constructor') continue;
561
+ if (allowed && !allowed.has(name)) continue;
562
+ if (result[name]) continue; // entity field takes precedence
563
+ const fn = (config.presenter as Record<string, unknown>)[name];
564
+ if (typeof fn !== 'function') continue;
565
+
566
+ const meta = metaMap.get(name);
567
+ const nullable = meta?.nullable ?? true;
568
+ // READ the value, never recompute it. The façade applies the presenter on every
569
+ // door (`PresenterExecutor`), so the row arrives carrying its computed fields; calling
570
+ // the method again ran the work twice — and once the method started receiving the
571
+ // PAGE rather than one row, the second call was handed a single object and threw
572
+ // `posts.map is not a function`. What GraphQL owes the field is its declaration.
573
+ const resolve = (parent: any) => parent?.[name] ?? null;
574
+
575
+ // The presenter STATED what this field emits — build its type instead of guessing.
576
+ const declared = config.presenterViews?.[name];
577
+ if (declared && config.viewType) {
578
+ const isList = Array.isArray(declared);
579
+ const view = (isList ? declared[0] : declared) as EntityClass;
580
+ const viewRef = config.viewType(view, name);
581
+ result[name] = t.field({ type: isList ? [viewRef] : viewRef, nullable, resolve });
582
+ continue;
583
+ }
584
+
585
+ // Map inferred return type → GraphQL scalar, one per row or a list of them.
586
+ // `list` is the arity the scan measured after removing the page level of the
587
+ // method's return type; without it a computed list announced its item type and
588
+ // a client selecting the field got one value where the row carried several.
589
+ const many = meta?.list === true;
590
+ switch (meta?.returnType) {
591
+ case 'number':
592
+ result[name] = many ? t.floatList({ nullable, resolve }) : t.float({ nullable, resolve });
593
+ break;
594
+ case 'boolean':
595
+ result[name] = many ? t.booleanList({ nullable, resolve }) : t.boolean({ nullable, resolve });
596
+ break;
597
+ case 'string':
598
+ result[name] = many ? t.stringList({ nullable, resolve }) : t.string({ nullable, resolve });
599
+ break;
600
+ default:
601
+ // The scan could not name a scalar: the method returns an object, a list, or
602
+ // nothing it can read. Serialize, exactly as an `object`-shaped entity field
603
+ // does — a String typing without serialization made GraphQL coerce the value
604
+ // to "[object Object]", and any client selecting subfields got a schema error
605
+ // on a field that REST served whole.
606
+ result[name] = t.string({
607
+ nullable,
608
+ resolve: async (parent: any) => {
609
+ const value = await resolve(parent);
610
+ if (value == null) return null;
611
+ return typeof value === 'string' ? value : JSON.stringify(value);
612
+ },
613
+ });
614
+ break;
615
+ }
616
+ }
617
+ }
618
+
619
+ return result;
620
+ },
621
+ });
622
+ }
623
+
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
+ */
644
+ export function registerInput(builder: InstanceType<typeof SchemaBuilder>, config: InputConfig): any {
645
+ const fields = Object.fromEntries(
646
+ Object.entries(config.schema.getFields())
647
+ .filter(([, field]) => !Role.of(field).isCollection && !Boundary.of(field).readOnly),
648
+ );
649
+ if (Object.keys(fields).length === 0) return undefined;
650
+ // The view's SOURCE, not the input's name: `CreatePostInput` derives from `Post`, and its
651
+ // `status` must be the same `PostStatus` the query emits.
652
+ const enumOwner = Card.fromSchema(config.schema).descriptor.title;
653
+ // Input-field omissibility is a projection of the view's MODE (partial() → patch),
654
+ // never of forged per-field flags — the fields themselves stay untouched.
655
+ const patch = config.schema.getOpts().patch ?? false;
656
+
657
+ return (builder as any).inputType(config.name, {
658
+ fields: (t: any) => {
659
+ const result: Record<string, any> = {};
660
+
661
+ for (const [fieldName, field] of Object.entries(fields)) {
662
+ result[fieldName] = fieldToInput(
663
+ t, field, patch,
664
+ (shape, suffix) => nestedInputType(builder, shape, `${config.name}${upperFirst(fieldName)}${suffix}`),
665
+ (values) => {
666
+ const name = enumNameFor(enumOwner, fieldName);
667
+ return name ? enumTypeFor(builder, name, values) : undefined;
668
+ },
669
+ );
670
+ }
671
+
672
+ return result;
673
+ },
674
+ });
675
+ }
676
+
677
+ // ─── GraphQL field naming ────────────────────────
678
+
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
+ */
692
+ const claimed = new WeakMap<object, Map<string, string>>();
693
+ 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); }
696
+
697
+ const first = held.get(fieldName);
698
+ if (first !== undefined && first !== origin) {
699
+ const opName = origin.split('.').pop();
700
+ // `operations:` is keyed by op name PER FROND, so it cannot tell two handlers of the
701
+ // same frond apart. Saying otherwise would send the author to a fix that cannot work.
702
+ const remedy = first.split('/')[0] === origin.split('/')[0]
703
+ ? `Both are in the same frond, where \`operations:\` is keyed by op name and cannot `
704
+ + `tell them apart — rename one of the methods.`
705
+ : `Rename one of the methods, or name the field in the frond.config.ts of whichever `
706
+ + `should yield:\n operations: { ${opName}: { graphql: '…' } }`;
707
+ throw new Error(
708
+ `two operations claim the GraphQL root field \`${fieldName}\`:\n`
709
+ + ` ${first}\n ${origin}\n`
710
+ + `A root field is global, so one of them has to give. ${remedy}`,
711
+ );
712
+ }
713
+ held.set(fieldName, origin);
714
+ }
715
+
716
+ function graphqlFieldName(opName: string, entityName: string): string {
717
+ const nameLower = entityName.charAt(0).toLowerCase() + entityName.slice(1);
718
+ const namePlural = pluralize(nameLower);
719
+
720
+ switch (opName) {
721
+ case 'list': return namePlural;
722
+ case 'findById': return nameLower;
723
+ case 'create':
724
+ case 'update':
725
+ case 'delete':
726
+ return `${opName}${entityName}`;
727
+ default:
728
+ return opName;
729
+ }
730
+ }
731
+
732
+ // ─── Args building from parsed signatures ────────
733
+
734
+ interface ArgsResult {
735
+ 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> };
737
+ }
738
+
739
+ function buildArgsFromSignature(
740
+ sig: ParsedSignature,
741
+ meta: OperationMeta,
742
+ builder: InstanceType<typeof SchemaBuilder>,
743
+ opName: string,
744
+ entityName: string,
745
+ ): ArgsResult {
746
+ // Classify from the façade's binding plan when it exists. A collector is not a
747
+ // GraphQL argument just because its value has an entity type: it is supplied by the
748
+ // invocation context, and publishing it would let callers impersonate that value.
749
+ const paramPlan: {
750
+ name: string;
751
+ kind: 'primitive' | 'body' | 'skip' | 'pagination';
752
+ typeName: string;
753
+ optional: boolean;
754
+ nullable: boolean;
755
+ }[] = [];
756
+ const bindings = new Map((meta.binding ?? []).map((binding) => [binding.name, binding]));
757
+
758
+ for (const param of sig.params) {
759
+ const typeName = param.type.name;
760
+ const binding = bindings.get(param.name);
761
+
762
+ if (binding) {
763
+ switch (binding.source.kind) {
764
+ case 'collector':
765
+ case 'context':
766
+ case 'fact':
767
+ paramPlan.push({ name: param.name, kind: 'skip', typeName, optional: true, nullable: param.type.nullable === true });
768
+ continue;
769
+ case 'query':
770
+ paramPlan.push({ name: param.name, kind: 'pagination', typeName, optional: binding.optional, nullable: param.type.nullable === true });
771
+ continue;
772
+ case 'param':
773
+ paramPlan.push({ name: param.name, kind: 'primitive', typeName, optional: binding.optional, nullable: param.type.nullable === true });
774
+ continue;
775
+ case 'body':
776
+ paramPlan.push({ name: param.name, kind: 'body', typeName, optional: binding.optional, nullable: param.type.nullable === true });
777
+ continue;
778
+ }
779
+ }
780
+
781
+ // Compatibility for callers constructing OperationMeta by hand, without core's plan.
782
+ if (SKIP_TYPES.has(typeName)) {
783
+ paramPlan.push({ name: param.name, kind: 'skip', typeName, optional: true, nullable: param.type.nullable === true });
784
+ continue;
785
+ }
786
+
787
+ if (typeName === 'ListOptions') {
788
+ paramPlan.push({ name: param.name, kind: 'pagination', typeName, optional: true, nullable: param.type.nullable === true });
789
+ continue;
790
+ }
791
+
792
+ if (typeName in PRIMITIVES) {
793
+ paramPlan.push({ name: param.name, kind: 'primitive', typeName, optional: param.optional ?? false, nullable: param.type.nullable === true });
794
+ continue;
795
+ }
796
+
797
+ // Object/entity param → body
798
+ paramPlan.push({ name: param.name, kind: 'body', typeName, optional: param.optional ?? false, nullable: param.type.nullable === true });
799
+ }
800
+
801
+ // Register input type if needed
802
+ let inputRef: any;
803
+ const bodyParam = paramPlan.find((p) => p.kind === 'body');
804
+ if (bodyParam && meta.input) {
805
+ // Only strip non-client fields for create/update — other ops may legitimately use them (e.g. publish(id))
806
+ const isMutation = opName === 'create' || opName === 'update';
807
+ const opInputFields = isMutation ? Visibility.of(meta.input.getFields()).input : meta.input.getFields();
808
+ const inputName = `${upperFirst(opName)}${entityName}Input`;
809
+
810
+ // `undefined` when the view asks for nothing, and `argsDef` already guards on it —
811
+ // the op then takes no input, which is the truth.
812
+ inputRef = registerInput(builder, {
813
+ name: inputName,
814
+ // A real schema over those fields, not a forged stand-in: an update input is the
815
+ // same fields seen through the patch mode.
816
+ schema: Schema.of({ fields: opInputFields, opts: { patch: opName === 'update' } }),
817
+ });
818
+ }
819
+
820
+ const hasPagination = paramPlan.some((p) => p.kind === 'pagination');
821
+
822
+ const argsDef = (t: any): Record<string, any> => {
823
+ const args: Record<string, any> = {};
824
+
825
+ for (const p of paramPlan) {
826
+ if (p.kind === 'primitive') {
827
+ // GraphQL's NonNull means both "present" and "not null". A `T | null`
828
+ // parameter therefore cannot use it: the resolver still preserves an explicit
829
+ // null, and core remains the authority on the TypeScript invocation.
830
+ args[p.name] = PRIMITIVES[p.typeName](t, !p.optional && !p.nullable);
831
+ }
832
+ }
833
+
834
+ if (bodyParam && inputRef) {
835
+ args.input = t.arg({ type: inputRef, required: !bodyParam.optional && !bodyParam.nullable });
836
+ }
837
+
838
+ if (hasPagination) {
839
+ args.limit = t.arg.int();
840
+ args.offset = t.arg.int();
841
+ args.page = t.arg.int();
842
+ args.after = t.arg.string();
843
+ args.orderBy = t.arg.string();
844
+ args.order = t.arg.string();
845
+ }
846
+
847
+ return args;
848
+ };
849
+
850
+ const buildInvocation = (args: any, gqlCtx: any) => {
851
+ const params: Record<string, any> = {};
852
+ let body: unknown = undefined;
853
+
854
+ for (const p of paramPlan) {
855
+ if (p.kind === 'primitive') {
856
+ // graphql-js omits an omitted optional argument and keeps an explicitly supplied
857
+ // null. Test undefined alone: `!= null` erased the second case and made
858
+ // `foo?: T | null` indistinguishable from `foo?: T`.
859
+ if (args[p.name] !== undefined) params[p.name] = args[p.name];
860
+ } else if (p.kind === 'body') {
861
+ body = args.input;
862
+ } else if (p.kind === 'pagination') {
863
+ // Collect pagination args into body (ListOptions)
864
+ const options: Record<string, any> = {};
865
+ for (const key of ['limit', 'offset', 'page', 'after', 'orderBy', 'order']) {
866
+ if (args[key] !== undefined) options[key] = args[key];
867
+ }
868
+ body = options;
869
+ }
870
+ }
871
+
872
+ return { params, query: {}, body, state: gqlCtx?.state ?? {} };
873
+ };
874
+
875
+ return { argsDef, buildInvocation };
876
+ }
877
+
878
+ // ─── Output type resolution ──────────────────────
879
+
880
+ function resolveOutputType(
881
+ sig: ParsedSignature,
882
+ config: OperationsConfig,
883
+ meta?: OperationMeta,
884
+ opName?: string,
885
+ ): { type: any; isList: boolean; nullable: boolean } {
886
+ const rt = sig.returnType;
887
+
888
+ // boolean → Boolean scalar
889
+ if (rt?.name === 'boolean') {
890
+ return { type: 'Boolean', isList: false, nullable: false };
891
+ }
892
+
893
+ // ListResult<T> → paginated list wrapper
894
+ if (rt?.name === 'ListResult') {
895
+ return { type: 'list-wrapper', isList: true, nullable: false };
896
+ }
897
+
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
+ */
909
+ const declared = meta?.output && opName && config.viewType
910
+ ? config.viewType(meta.output, opName)
911
+ : undefined;
912
+ const type = declared ?? config.type;
913
+
914
+ if (rt?.array) {
915
+ return { type: [type], isList: false, nullable: false };
916
+ }
917
+ return { type, isList: false, nullable: rt?.nullable === true || rt?.undefined === true };
918
+ }
919
+
920
+ // ─── registerOperations ──────────────────────────
921
+
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
+ */
935
+ export function registerOperations(builder: InstanceType<typeof SchemaBuilder>, config: OperationsConfig): void {
936
+ // Pre-register list wrapper type if list op exists
937
+ let listWrapperType: any;
938
+ const listMeta = config.operations.get('list');
939
+ if (listMeta && typeof config.facade.list === 'function') {
940
+ listWrapperType = registerObjectType(builder, `${config.name}List`, {
941
+ items: { type: [config.type] },
942
+ total: {
943
+ type: 'int', nullable: true,
944
+ resolve: async (parent: any) => {
945
+ if (parent.total !== undefined) return parent.total;
946
+ if (parent._count) return (await parent._count()).total ?? null;
947
+ return null;
948
+ },
949
+ },
950
+ endCursor: { type: 'string', nullable: true },
951
+ hasMore: { type: 'boolean', nullable: true },
952
+ });
953
+ }
954
+
955
+ for (const [opName, meta] of config.operations) {
956
+ if (typeof config.facade[opName] !== 'function') {
957
+ throw new Error(
958
+ `GraphQL EffectiveOperation table exposes '${opName}' but its facade does not.`,
959
+ );
960
+ }
961
+
962
+ const sig = meta.signature;
963
+ if (!sig) continue;
964
+
965
+ const fieldName = config.operationsOverrides?.[opName]?.graphql
966
+ ?? graphqlFieldName(opName, config.name);
967
+ claimRootField(builder, fieldName, `${config.origin ?? config.name}.${opName}`);
968
+ const { argsDef, buildInvocation } = buildArgsFromSignature(sig, meta, builder, opName, config.name);
969
+
970
+ // Output type — what the op declares, else the entity's.
971
+ const output = resolveOutputType(sig, config, meta, opName);
972
+ const isListWrapper = output.type === 'list-wrapper';
973
+ const outputType = isListWrapper ? listWrapperType : output.type;
974
+ if (!outputType) continue;
975
+
976
+ const fieldDef = (t: any) => ({
977
+ [fieldName]: t.field({
978
+ type: outputType,
979
+ nullable: output.nullable,
980
+ args: argsDef(t),
981
+ ...(meta.description && { description: meta.description }),
982
+ resolve: async (_: any, args: any, gqlCtx: any) => {
983
+ const invocation = buildInvocation(args, gqlCtx);
984
+ const result = await config.facade[opName](invocation);
985
+
986
+ // List wrapper: shape the result for the paginated type
987
+ if (isListWrapper) {
988
+ const items = Array.isArray(result) ? [...result] : result;
989
+ return {
990
+ items,
991
+ endCursor: result?.endCursor,
992
+ hasMore: result?.hasMore,
993
+ _count: () => config.facade[opName]({
994
+ ...invocation,
995
+ body: { ...(invocation.body as any ?? {}), count: true, limit: 1 },
996
+ }),
997
+ };
998
+ }
999
+
1000
+ return result;
1001
+ },
1002
+ }),
1003
+ });
1004
+
1005
+ if (operationIsQuery(opName, meta.kind)) {
1006
+ (builder as any).queryFields(fieldDef);
1007
+ } else {
1008
+ (builder as any).mutationFields(fieldDef);
1009
+ }
1010
+ }
1011
+ }