@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/README.md +1 -1
- package/dist/app.d.ts +43 -0
- package/dist/app.d.ts.map +1 -0
- package/dist/app.js +50 -0
- package/dist/app.js.map +1 -0
- package/dist/auto-register.d.ts +22 -7
- package/dist/auto-register.d.ts.map +1 -1
- package/dist/auto-register.js +23 -45
- package/dist/auto-register.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/pothos.d.ts +43 -11
- package/dist/pothos.d.ts.map +1 -1
- package/dist/pothos.js +127 -52
- package/dist/pothos.js.map +1 -1
- package/package.json +5 -4
- package/src/app.ts +73 -0
- package/src/auto-register.ts +474 -0
- package/src/index.ts +14 -0
- package/src/pothos-entry.ts +9 -0
- package/src/pothos.ts +1011 -0
- package/src/serve.ts +133 -0
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
|
+
}
|