[Question] Integration with websocket (partykit)
I'm adding partykit (websocket) to my project, and when a mutation happens, I'm sending a message to my ws server to broadcast my update to my clients (for other users to receive the update). I'm using nextjs so can't really use the smart subscriptions. I was wondering if there's some utility function to convert my data (coming from stripe api) onto the my pothos object implementation so i matches my graphql format. This may involve writing a plugin maybe but curious if there's some function that will do that already.
Here's a snippet of what I'm trying to do:
// Schema definition
export const StripeAccount = builder
.objectRef<Stripe.Account & { spaceId: string }>('StripeAccount')
.implement({
fields: (t) => ({
id: t.field({
type: 'ID',
resolve: (account) => encodeGlobalID('StripeAccount', account.spaceId),
}),
accountId: t.exposeID('id'),
chargesEnabled: t.exposeBoolean('charges_enabled'),
detailsSubmitted: t.exposeBoolean('details_submitted'),
payoutsEnabled: t.exposeBoolean('payouts_enabled'),
requirements: t.field({
type: ['String'],
resolve: async (account) => account.requirements?.currently_due as string[] ?? [],
}),
name: t.field({
type: 'String',
nullable: true,
resolve: (account) => {
return account.business_profile?.name ?? account.settings?.dashboard?.display_name ?? 'Unknown';
},
}),
isOnboardingComplete: t.field({
type: 'Boolean',
resolve: (account) => {
return account.details_submitted && account.charges_enabled && account.payouts_enabled;
},
}),
}),
});
// Mutation
builder.mutationField('updateStripeAccount', (t) =>
t.field({
authScopes: {
admin: true,
space: 'owner',
},
type: StripeAccount,
nullable: true,
args: {...},
async resolve(_root, _args, { decodedSpaceId, spaceId }) {
// Update the stripe account
const account = await stripe.accounts.retrieve(id);
// Websocket call
fetch(`http://127.0.0.1:1999/parties/spaces/${spaceId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
type: 'stripeAccount:update',
data: account, // this is where I'd wanna convert from stripe schema to StripeAccount schema defined above
}),
});
return {
...account,
spaceId: space.id,
};
},
}),
);
this is what I have in mind:
StripeAccount.init(account).toGraphql()
Hey @hayes, wanted to give you a little more context, I got something going but it's a bit buggy and some guidance would be super helpful.
Overview
This plugin extends Pothos GraphQL by adding a transformation layer that allows for controlled serialization of GraphQL objects. The plugin adds a toGraphQL method to Pothos ObjectRef instances, enabling recursive transformation of objects with features like field-level authorization, exclusion/inclusion, and depth control, all from a given prisma object.
Why This Is Needed
I'm using PartyKit websockets for real-time updates in my application. When changes occur in the backend, I need to propagate these changes to connected clients through the websocket. However, I faced a challenge: My GraphQL schema defines complex types with resolvers, auth rules, and relationships When sending data over websockets, I need the same transformations that GraphQL would apply (resolving fields, applying auth rules, etc.) There's no built-in way to "serialize" a Pothos object outside of a GraphQL query context I'm trying to solve this problem with this plugin by allowing me to take a database object (e.g., from Prisma) and transform it into its GraphQL representation with all resolvers applied and auth rules respected, making it ready to be sent over websockets while maintaining consistency with the GraphQL API. Any field that is not present in the db object is skipped, which gives me control of what I'm sending back (currently I'm only sending back the changed data only).
Key Features
-
Field-level Authorization: Respects auth scopes defined on fields, ensuring that unauthorized fields are excluded from the output.
-
Field Exclusion/Inclusion: Supports configuration to explicitly exclude or include specific fields during transformation.
-
Depth Control: Prevents infinite recursion by limiting the depth of nested object transformations.
-
Type-aware Transformations: Handles different GraphQL types appropriately:
- Scalar types (String, Int, Boolean, etc.)
- Object types (with nested transformation)
- List types (transforming each item)
- Non-nullable wrappers around these types
-
Relay Connection Support: Detects and properly handles Relay-style connections.
-
Resolver Integration: Uses field resolvers when available, falling back to parent object properties when needed.
Implementation Details
The plugin works by:
- Registering itself with the Pothos SchemaBuilder
- Adding a
toGraphQLmethod to theObjectRefprototype - When
toGraphQLis called on an object:- It retrieves the GraphQL type definition
- Processes each field according to configuration
- Applies auth scope checks
- Transforms nested objects recursively
- Handles special cases like DateTime fields
Current Challenges
While the plugin works for most cases, I'm encountering issues with:
-
Nested Non-nullable Lists: Fields with types like
GraphQLNonNull(GraphQLList(GraphQLNonNull(ObjectType)))need special handling. -
Generated Fields: Fields that don't exist on the database object but are generated by resolvers (like
idfields that combine multiple properties) need to be properly resolved. -
Relay Connection Integration: Properly detecting and handling Relay connections without attempting to transform them directly.
-
Type Detection: Reliably detecting the type of fields and their contents, especially for complex nested types.
I'm looking for guidance on best practices for handling these cases and any potential improvements to the current implementation.
Here's the code:
import SchemaBuilder, { BasePlugin, SchemaTypes, ObjectRef, BuildCache } from '@pothos/core';
import {
GraphQLList,
GraphQLNamedType,
GraphQLNonNull,
GraphQLObjectType,
GraphQLResolveInfo,
GraphQLType,
isObjectType,
} from 'graphql';
import * as logger from '@ambrosia/api/utils/logger';
import './global-types';
import { GraphqlContext } from '../../context';
const pluginName = 'transform';
export interface TransformPluginOptions {
maxDepth?: number;
}
export interface TransformOptions {
context: GraphqlContext;
maxDepth?: number;
excludeFields?: string[];
}
export interface TransformConfig {
enabled: boolean;
exclude?: string[];
include?: string[]; // Array of field names to include from resolvers
maxDepth?: number;
}
// Store the schema builder instance
let schemaBuilder: BuildCache<any>;
function unwrap(type: GraphQLType): GraphQLNamedType {
if (type instanceof GraphQLNonNull) {
return unwrap(type.ofType as GraphQLType);
}
if (type instanceof GraphQLList) {
return unwrap(type.ofType);
}
return type;
}
function isList(type: GraphQLType): boolean {
return (
type instanceof GraphQLList ||
(type instanceof GraphQLNonNull && type.ofType instanceof GraphQLList)
);
}
function isObject(type: GraphQLType): boolean {
return (
type instanceof GraphQLObjectType ||
(type instanceof GraphQLNonNull && type.ofType instanceof GraphQLObjectType)
);
}
export class PothosTransformPlugin<Types extends SchemaTypes> extends BasePlugin<Types> {
constructor(buildCache: BuildCache<Types>) {
super(buildCache, pluginName);
logger.log('🔌 Init:', {
builder: buildCache,
hasBuilder: !!buildCache,
toSchema: typeof buildCache?.builder === 'function',
});
// Store the builder instance
schemaBuilder = buildCache;
logger.log('📝 Stored:', {
hasBuilder: !!schemaBuilder,
});
// Add toGraphQL to ObjectRef prototype
Object.defineProperty(ObjectRef.prototype, 'toGraphQL', {
configurable: true,
writable: true,
value: async function toGraphQL(parent: any, options: TransformOptions) {
try {
if (!parent) return null;
// Validate that context is provided
if (!options.context) {
throw new Error('Context is required for toGraphQL transformation');
}
const authResult = await schemaBuilder?.builder?.options?.scopeAuth?.authScopes(
options.context,
);
logger.log('🔍 Transform:', {
type: this.name,
hasBuilder: !!schemaBuilder,
});
if (!schemaBuilder) {
logger.error('❌ No builder');
return null;
}
// Cache for auth scope results
const authCache: Record<string, boolean> = {};
const checkScopeWithCache = async (
scopeType: string,
scopeValue: any,
): Promise<boolean> => {
const cacheKey = `${scopeType}:${JSON.stringify(scopeValue)}`;
if (cacheKey in authCache) {
return authCache[cacheKey];
}
// Special handling for negative scopes (e.g., public: false)
// If scopeValue is false, we want to invert the result
const isNegativeScope = scopeValue === false;
const scopeChecker = authResult?.[scopeType];
let result = false;
if (typeof scopeChecker === 'boolean') {
// For boolean scopes, if scopeValue is false, we want to invert the result
result = isNegativeScope ? !scopeChecker : scopeChecker;
} else if (typeof scopeChecker === 'function') {
try {
// For function scopes, we pass the actual value (which might be a string, object, etc.)
// If scopeValue is false, we invert the result
const checkResult = await scopeChecker(isNegativeScope ? true : scopeValue);
result = isNegativeScope ? !checkResult : checkResult;
} catch (error) {
logger.warn('⚠️ Scope check failed:', { scopeType, error });
result = false;
}
}
authCache[cacheKey] = result;
return result;
};
const type = schemaBuilder.types?.get(this.name);
if (!type || !isObjectType(type)) {
// Instead of returning null, we'll check if this is a scalar type
// For scalar types like String, we should just return the parent value
if (['String', 'Int', 'Float', 'Boolean', 'ID', 'DateTime'].includes(this.name)) {
return parent;
}
logger.log(`❌ Invalid type: ${this.name}`);
return null;
}
const fields = type.getFields();
const result: any = {
__typename: this.name,
};
if (options.maxDepth != null && options.maxDepth <= 0) {
logger.log(`⚠️ Max depth: ${this.name}`);
for (const [fieldName, field] of Object.entries(fields)) {
if (fieldName in parent && !field.resolve) {
result[fieldName] = parent[fieldName];
}
}
return result;
}
const parentType = unwrap(type);
const config = schemaBuilder?.builder?.configStore?.typeConfigs?.get(parentType.name)
?.pothosOptions as PothosSchemaTypes.ObjectTypeOptions<any, any>;
const excludeFields =
typeof config?.transform === 'boolean' ? [] : config?.transform?.exclude ?? [];
const includeFields =
typeof config?.transform === 'boolean' ? [] : config?.transform?.include ?? [];
// Create a list of all fields to process, including include fields
const fieldsToProcess = new Set<string>();
// Add all fields from the GraphQL schema
for (const fieldName of Object.keys(fields)) {
fieldsToProcess.add(fieldName);
}
// Add include fields if they're not already in the list
for (const fieldName of includeFields) {
fieldsToProcess.add(fieldName);
}
// Process all fields
for (const fieldName of fieldsToProcess) {
try {
logger.log(`📝 Processing field ${this.name}.${fieldName}`);
// Skip excluded fields
if (excludeFields.includes(fieldName)) {
logger.log(` - Skipping excluded field ${this.name}.${fieldName}`);
continue;
}
// Get the field from the schema
const field = fields[fieldName];
if (!field) {
logger.log(` - Field ${fieldName} not found in schema, skipping`);
continue;
}
// Check auth scopes first if they exist
const authScopes =
(field.extensions as any)?.pothosOptions?.authScopes ||
(field.extensions as any)?.authScopes;
if (authScopes && options.context) {
try {
// Check each scope type
let isAuthorized = false;
for (const [scopeType, scopeValue] of Object.entries(authScopes)) {
const hasAccess = await checkScopeWithCache(scopeType, scopeValue);
if (hasAccess) {
isAuthorized = true;
break;
}
}
if (!isAuthorized) {
// Skip this field if no scope checks passed
continue;
}
} catch (error) {
// Skip this field if auth check fails
logger.warn('⚠️ Auth check failed:', error);
continue;
}
}
// ALWAYS try to use the resolver first if it exists
try {
logger.log(` - Using resolver for ${this.name}.${fieldName}`);
const info: GraphQLResolveInfo = {
fieldName,
parentType: type,
returnType: field.type,
// @ts-ignore
path: { key: fieldName },
// schema: schemaBuilder.builder.toSchema(), // Skip schema for now since it's not available
};
// Check if this is a Relay connection field
const isRelayConnection =
// Check if the field has the typical Relay connection arguments
field.args?.some((arg) =>
['before', 'after', 'first', 'last'].includes(arg.name),
) &&
// Check if the field type follows the Relay connection pattern
(field.type instanceof GraphQLNonNull
? unwrap(field.type).name?.endsWith('Connection')
: unwrap(field.type)?.name?.endsWith('Connection'));
if (isRelayConnection) {
// Skip direct transformation for Relay connections
// Let the resolver handle it
continue;
}
// Check that the property is defined on the parent object
if (field.type instanceof GraphQLObjectType && fieldName in parent) {
// In this case we don't resolve the value, we try to transform the object ref
const objectRef = schemaBuilder.builder.objectRef(
field.type.name,
) as unknown as PothosSchemaTypes.ObjectRef<any, any>;
if (objectRef && 'toGraphQL' in objectRef) {
result[fieldName] = await objectRef.toGraphQL(parent[fieldName], {
...options,
maxDepth: options.maxDepth ? options.maxDepth - 1 : undefined,
});
continue;
}
}
const resolvedValue =
(await field?.resolve?.(parent, {}, options.context, info)) ?? parent[fieldName];
if (isList(field.type)) {
const type = unwrap(field.type);
// Check if the item type is a scalar type
const isScalarType = [
'String',
'Int',
'Float',
'Boolean',
'ID',
'DateTime',
'Date',
'Time',
'JSON',
'JSONObject',
'JSONB',
'BigInt',
'Decimal',
'URL',
'URI',
'Email',
'UUID',
'GUID',
'Upload',
].includes(type.name);
if (isScalarType) {
// For arrays of scalar types, use the values directly
result[fieldName] = resolvedValue;
continue;
}
const objectRef = schemaBuilder.builder.objectRef(
type.name,
) as unknown as PothosSchemaTypes.ObjectRef<any, any>;
if (objectRef && 'toGraphQL' in objectRef) {
if (!resolvedValue?.length) {
result[fieldName] = [];
continue;
}
const transformed = await Promise.all(
resolvedValue?.map(
(item: any[]) =>
objectRef.toGraphQL(item, {
...options,
maxDepth: options.maxDepth ? options.maxDepth - 1 : undefined,
}) || [],
),
);
result[fieldName] = transformed;
continue;
} else {
// If we can't transform the items, use the array as is
result[fieldName] = resolvedValue;
continue;
}
}
if (isObject(field.type)) {
const type = unwrap(field.type);
// Get object ref for the type
const objectRef = schemaBuilder.builder.objectRef(type.name);
if (objectRef && 'toGraphQL' in objectRef) {
// To this:
const transformed = await (
objectRef as unknown as PothosSchemaTypes.ObjectRef<any, any>
).toGraphQL(resolvedValue, {
...options,
maxDepth: options.maxDepth ? options.maxDepth - 1 : undefined,
});
result[fieldName] = transformed;
continue;
}
}
if (type instanceof GraphQLObjectType && type.name === 'DateTime') {
result[fieldName] = new Date(resolvedValue);
continue;
}
// Skip null values
if (resolvedValue === undefined) continue;
// Clean excluded fields from the resolved value based on its type
let cleanedValue = resolvedValue;
// If we couldn't transform the cleaned value, use it directly
result[fieldName] = cleanedValue;
continue;
} catch (error) {
// logger.warn(`⚠️ Resolver failed for ${this.name}.${fieldName}:`, error);
// Fall back to parent value if resolver fails
}
// Skip resolver fields if no context
if (!options.context) {
continue;
}
// We should never reach here since we already tried the resolver above
// but keeping this as a fallback
if (field.resolve) {
// ... existing fallback resolver code ...
}
} catch (error) {
// Skip field if any error occurs
// logger.warn(`⚠️ Error processing ${this.name}.${fieldName}:`, error);
continue;
}
}
return result;
} catch (error) {
logger.error('❌ Transform error', error);
throw error;
}
},
});
}
beforeBuildSchema() {
logger.log('🔍 Setup:', {
hasToGraphQL: typeof ObjectRef.prototype.toGraphQL === 'function',
hasBuilder: !!schemaBuilder,
});
}
}
// Register the plugin
logger.log('🔧 Registering plugin');
SchemaBuilder.registerPlugin(pluginName, PothosTransformPlugin);
export default pluginName;
Examples:
const product = await prisma.product.findUnique({
where: {
id: xxx,
}
})
export const ProductRef = builder.prismaNode('Product', {
id: { field: 'id' },
findUnique: (id) => ({ id }),
fields: (t) => ({
createdAt: t.expose('createdAt', { type: 'DateTime' }),
updatedAt: t.expose('updatedAt', { type: 'DateTime', nullable: true }),
....
});
ProductRef.toGraphQL(record, {
context,
maxDepth: 2,
});
PS: I used cursor to help me write some of the code as the pothos internals are a bit too complex for me to grasp everything.
Hey, this looks pretty cool!
Sorry I somehow missed this issue when it was first opened.
I think my first though about this would be that using GraphQL directly to do this. I'm not sure how many or how complex your selections are, so maybe its too complicated to hand-write the queries but there are a few ways to do this.
This simplest thing I can think of would be to add queries to your schema that take your data as the root object and return it.-graph plugin to build schemas with/without these transform fields
This would be something like:
import { execute, parse } from 'graphql';
builder.queryField('transformStripeAccount', t => t.field({
subGraphs: ['transforms'],
type: StripeAccount,
resolve: (root) => root as any,
}))
const transformSchema = builder.toSchema({ subGraph: ['transforms'] })
async function transformStripeAccount(context, account) {
const result = await execute({
schema: transformSchema,
contextValue: context,
rootValue:
document: parse(`
query {
transformStripeAccount {
...your selections for StripAccount
}
}`),
});
}
This approach actually uses GraphQL to execute your queries so all transforms and auth checks are applied exactly as they are when querying though the graphql endpoint, and doesn't require you to do any complex data resolving yourself.
The downside is that you actually need a query to execute. If its just a couple of cases, it probably isn't hard to just write out the selection you want. But, if that doesn't sound like a good option, its probably a LOT easier to generate the query programmatically that it is to resolve all the data manually, so you could also just write a function that takes a GraphQL type and generates a recursive selection/query for that type.
To get back to your plugin idea, you could probably also implement a similar API that uses graphql to resolve the actual query.
All you would need to do is something like this:
async function toGraphQL(
type: GraphQLType,
data: unknown,
contextValue: unknown
) {
const Query = new GraphQLObjectType({
name: 'Query',
fields: {
transform: {
type: type as GraphQLOutputType,
resolve: () => data,
},
},
});
const schema = new GraphQLSchema({
query: Query,
});
return execute({
schema,
contextValue,
document: parse(`
query {
transform {
id
#your selection
}
}`),
});
}
Again, this requires either manually defining your selection, or generating the selection, but the logic to execute it should be very simple
yeah that makes sense. Thank you so much for the direction. This is what I came up with with the help of o3. Not sure if that's a plugin that might be helpful to others.
Usage
export const ProductRef = builder.prismaNode('Product', {
transform: {
exclude: ['password'],
include: ['some_custom_resolver'],
},
})
ProductRef.toGraphql(product, {
maxDepth: 2,
ctx: context,
})
Code
import SchemaBuilder, { BasePlugin, SchemaTypes, ObjectRef, BuildCache } from '@pothos/core';
import {
GraphQLList,
GraphQLNamedType,
GraphQLNonNull,
GraphQLObjectType,
GraphQLOutputType,
GraphQLSchema,
GraphQLType,
execute,
isObjectType,
parse,
} from 'graphql';
import * as logger from '@ambrosia/api/utils/logger';
import './global-types';
import { GraphqlContext } from '../../context';
const pluginName = 'transform';
export interface TransformPluginOptions {
maxDepth?: number;
}
export interface TransformOptions {
context: GraphqlContext;
maxDepth?: number;
excludeFields?: string[];
includeFields?: string[] | 'all' | '*';
}
export interface TransformConfig {
enabled: boolean;
exclude?: string[];
include?: string[] | 'all' | '*';
maxDepth?: number;
}
// Cache for the schema builder and built schema
let schemaBuilder: BuildCache<any>;
let builtSchema: GraphQLSchema | null = null;
let schemaBuilt = false;
// --- Memoized unwrap ---
// Cache previously unwrapped types to avoid redundant recursion.
const unwrapCache = new WeakMap<GraphQLType, GraphQLNamedType>();
function unwrap(type: GraphQLType): GraphQLNamedType {
if (unwrapCache.has(type)) return unwrapCache.get(type)!;
const result =
type instanceof GraphQLNonNull || type instanceof GraphQLList ? unwrap(type.ofType) : type;
unwrapCache.set(type, result);
return result;
}
// --- Top-level Field Filter Helpers ---
function isExcluded(fieldName: string, combinedExcludeFields: string[]): boolean {
return combinedExcludeFields.includes(fieldName) || fieldName === '__typename';
}
function fieldExists(fields: Record<string, any>, fieldName: string): boolean {
return !!fields[fieldName];
}
function getUniqueIncludeFields(globalIncludes: any, typeIncludes: any): string[] {
return Array.from(
new Set([
...(Array.isArray(globalIncludes) ? globalIncludes : []),
...(Array.isArray(typeIncludes) ? typeIncludes : []),
]),
);
}
function shouldIncludeAllFields(globalIncludes: any, typeIncludes: any): boolean {
return (
globalIncludes === 'all' ||
globalIncludes === '*' ||
typeIncludes === 'all' ||
typeIncludes === '*'
);
}
// --- Streamlined Field Inclusion ---
function determineFieldsToInclude(
parentObject: any,
fields: Record<string, any>,
combinedExcludeFields: string[],
uniqueExplicitIncludeFields: string[],
includeAllFields: boolean,
currentDepth: number,
): string[] {
const fieldNames: Set<string> = new Set();
if (parentObject) {
const addFields = (obj: any) => {
if (obj && typeof obj === 'object') {
Object.keys(obj).forEach((key) => {
if (!isExcluded(key, combinedExcludeFields) && fieldExists(fields, key)) {
fieldNames.add(key);
}
});
}
};
if (Array.isArray(parentObject)) {
parentObject.forEach((item) => {
addFields(item);
if (item && typeof item === 'object') {
Object.values(item).forEach((val) => {
if (val && typeof val === 'object' && !Array.isArray(val)) {
addFields(val);
}
});
}
});
} else {
addFields(parentObject);
Object.values(parentObject).forEach((val) => {
if (val && typeof val === 'object' && !Array.isArray(val)) {
addFields(val);
}
});
}
uniqueExplicitIncludeFields.forEach((field) => {
if (
!fieldNames.has(field) &&
!isExcluded(field, combinedExcludeFields) &&
fieldExists(fields, field)
) {
fieldNames.add(field);
}
});
if (includeAllFields) {
Object.keys(fields).forEach((field) => {
if (!fieldNames.has(field) && !isExcluded(field, combinedExcludeFields)) {
fieldNames.add(field);
}
});
}
if (fieldExists(fields, 'id') && !isExcluded('id', combinedExcludeFields)) {
fieldNames.add('id');
}
} else if (currentDepth === 0) {
const baseFields = includeAllFields
? Object.keys(fields).filter((name) => !isExcluded(name, combinedExcludeFields))
: uniqueExplicitIncludeFields.filter(
(name) => fieldExists(fields, name) && !isExcluded(name, combinedExcludeFields),
);
baseFields.forEach((f) => fieldNames.add(f));
} else {
Object.keys(fields).forEach((f) => {
if (!isExcluded(f, combinedExcludeFields)) fieldNames.add(f);
});
}
return Array.from(fieldNames);
}
function shouldSkipField(field: any): boolean {
return (
field.args &&
field.args.length > 0 &&
field.args.some(
(arg: any) => arg.type instanceof GraphQLNonNull && arg.defaultValue === undefined,
)
);
}
function getTypeConfig(typeName: string): {
maxDepth: number;
typeExcludeFields: string[];
typeIncludeFields: string[] | 'all' | '*';
} {
const typeConfig = schemaBuilder?.builder?.configStore?.typeConfigs?.get(typeName);
const transformConfig = (typeConfig?.pothosOptions as any)?.transform as
| TransformConfig
| undefined;
return {
maxDepth: transformConfig?.maxDepth ?? 3,
typeExcludeFields:
transformConfig && typeof transformConfig !== 'boolean' ? transformConfig.exclude || [] : [],
typeIncludeFields:
transformConfig && typeof transformConfig !== 'boolean' ? transformConfig.include || [] : [],
};
}
function buildConnectionQuery(
fields: Record<string, any>,
maxDepth: number,
currentDepth: number,
combinedExcludeFields: string[],
includeAllFields: boolean,
): string {
let query = '__typename\n';
if (fields.edges) {
const edgesType = unwrap(fields.edges.type);
if (isObjectType(edgesType)) {
const edgeFields = edgesType.getFields();
if (edgeFields.node) {
const nodeType = unwrap(edgeFields.node.type);
if (isObjectType(nodeType)) {
// eslint-disable-next-line @typescript-eslint/no-use-before-define
const nodeQuery = buildQuery(nodeType, {
currentDepth: currentDepth + 1,
rootMaxDepth: maxDepth,
rootExcludeFields: combinedExcludeFields,
rootIncludeFields: includeAllFields ? 'all' : [],
parentObject: null,
});
if (nodeQuery) {
query += `edges {\n __typename\n node {\n${nodeQuery} }\n}\n`;
}
}
}
}
}
if (fields.pageInfo) {
query +=
'pageInfo {\n __typename\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n}\n';
}
if (fields.totalCount) {
query += 'totalCount\n';
}
return query;
}
function buildArrayFieldQuery(
fieldName: string,
arrayItemType: GraphQLObjectType,
nestedParentObject: any[],
currentDepth: number,
maxDepth: number,
combinedExcludeFields: string[],
includeAllFields: boolean,
uniqueExplicitIncludeFields: string[],
): string {
const arrayItemFields = arrayItemType.getFields();
const fieldNames = determineFieldsToInclude(
nestedParentObject,
arrayItemFields,
combinedExcludeFields,
uniqueExplicitIncludeFields,
includeAllFields,
currentDepth + 1,
);
// eslint-disable-next-line @typescript-eslint/no-use-before-define
const arrayItemQuery = buildQuery(arrayItemType, {
currentDepth: currentDepth + 1,
rootMaxDepth: maxDepth,
rootExcludeFields: combinedExcludeFields,
rootIncludeFields: includeAllFields ? 'all' : fieldNames,
parentObject: nestedParentObject[0] || null,
});
return arrayItemQuery ? `${fieldName} {\n${arrayItemQuery}}\n` : '';
}
function buildQuery(
type: GraphQLObjectType,
context: {
parentObject?: any;
currentDepth?: number;
rootMaxDepth?: number;
rootExcludeFields?: string[];
rootIncludeFields?: string[] | 'all' | '*';
} = {},
): string {
const {
parentObject = null,
currentDepth = 0,
rootMaxDepth,
rootExcludeFields = [],
rootIncludeFields = [],
} = context;
const typeName = type.name;
const fields = type.getFields();
const { maxDepth: typeMaxDepth, typeExcludeFields, typeIncludeFields } = getTypeConfig(typeName);
const maxDepth = rootMaxDepth || typeMaxDepth;
if (currentDepth > maxDepth) return '';
const combinedExcludeFields = [...rootExcludeFields, ...typeExcludeFields];
const includeAllFields = shouldIncludeAllFields(rootIncludeFields, typeIncludeFields);
const uniqueExplicitIncludeFields = getUniqueIncludeFields(rootIncludeFields, typeIncludeFields);
const fieldNames = determineFieldsToInclude(
parentObject,
fields,
combinedExcludeFields,
uniqueExplicitIncludeFields,
includeAllFields,
currentDepth,
);
logger.log('🔍 Executing query:', {
typeName,
fieldNames,
includeFields: uniqueExplicitIncludeFields,
excludeFields: combinedExcludeFields,
});
let query = '__typename\n';
if (typeName.endsWith('Connection')) {
return buildConnectionQuery(
fields,
maxDepth,
currentDepth,
combinedExcludeFields,
includeAllFields,
);
}
for (const fieldName of fieldNames) {
if (fieldName === '__typename') continue;
const field = fields[fieldName];
if (!field || shouldSkipField(field)) continue;
const fieldType = unwrap(field.type);
if (!isObjectType(fieldType)) {
query += `${fieldName}\n`;
continue;
}
const isFieldConnectionType = fieldType.name.endsWith('Connection');
const nestedParentObject =
parentObject && parentObject[fieldName] ? parentObject[fieldName] : null;
if (Array.isArray(nestedParentObject)) {
query += buildArrayFieldQuery(
fieldName,
fieldType as GraphQLObjectType,
nestedParentObject,
currentDepth,
maxDepth,
combinedExcludeFields,
includeAllFields,
uniqueExplicitIncludeFields,
);
continue;
}
const subQuery = buildQuery(fieldType as GraphQLObjectType, {
currentDepth: currentDepth + 1,
rootMaxDepth: maxDepth,
rootExcludeFields: combinedExcludeFields,
rootIncludeFields: includeAllFields ? 'all' : [],
parentObject: nestedParentObject,
});
if (subQuery) {
query += `${fieldName} {\n${subQuery}}\n`;
} else if (isFieldConnectionType) {
query += `${fieldName} {\n __typename\n edges {\n __typename\n node {\n __typename\n id\n }\n }\n}\n`;
} else {
query += `${fieldName} { __typename id }\n`;
}
}
return query;
}
export class PothosTransformPlugin<Types extends SchemaTypes> extends BasePlugin<Types> {
constructor(buildCache: BuildCache<Types>) {
super(buildCache, pluginName);
logger.log('🔌 Init:', {
builder: buildCache,
hasBuilder: !!buildCache,
toSchema: typeof buildCache?.builder === 'function',
});
schemaBuilder = buildCache;
logger.log('📝 Stored:', { hasBuilder: !!schemaBuilder });
Object.defineProperty(ObjectRef.prototype, 'toGraphQL', {
configurable: true,
writable: true,
value: async function toGraphQL(parent: any, options: TransformOptions) {
try {
if (!parent) return null;
if (!options.context) throw new Error('Context is required for toGraphQL transformation');
const typeName = this.name;
if (!schemaBuilt) {
try {
builtSchema = schemaBuilder.builder.toSchema();
schemaBuilt = true;
logger.log('📝 Schema built:', {
hasSchema: !!builtSchema,
typeCount: builtSchema ? Object.keys(builtSchema.getTypeMap()).length : 0,
});
} catch (error) {
logger.error('❌ Failed to build schema:', error);
return null;
}
}
if (!builtSchema) {
logger.error('❌ No schema available');
return null;
}
const type = builtSchema.getType(typeName);
if (!type || !isObjectType(type)) {
logger.error(`❌ Invalid type: ${typeName}`);
return null;
}
const Query = new GraphQLObjectType({
name: 'Query',
fields: {
transform: {
type: type as GraphQLOutputType,
resolve: () => parent,
},
},
});
const tempSchema = new GraphQLSchema({ query: Query });
const { maxDepth, typeExcludeFields, typeIncludeFields } = getTypeConfig(typeName);
const queryFields = buildQuery(type as GraphQLObjectType, {
rootMaxDepth: options.maxDepth || maxDepth,
rootExcludeFields: [...(options.excludeFields || []), ...typeExcludeFields],
rootIncludeFields: options.includeFields || typeIncludeFields,
currentDepth: 0,
parentObject: parent,
});
if (!queryFields) {
logger.error(`❌ Failed to build query for type: ${typeName}`);
return null;
}
const query = `
query {
transform {
${queryFields}
}
}
`;
const result = await execute({
schema: tempSchema,
document: parse(query),
contextValue: options.context,
});
if (result.errors) {
logger.error('❌ GraphQL execution errors:', result.errors);
}
return result.data?.transform || null;
} catch (error) {
logger.error('❌ Transform error:', error);
return null;
}
},
});
}
beforeBuildSchema() {
logger.log('🔍 Setup:', {
hasToGraphQL: typeof ObjectRef.prototype.toGraphQL === 'function',
hasBuilder: !!schemaBuilder,
});
}
}
logger.log('🔧 Registering plugin');
SchemaBuilder.registerPlugin(pluginName, PothosTransformPlugin);
export default pluginName;
Nice, glad you got something working!