GraphQL
@zeltjs/graphql は実験的です。runtime manifestの形状と生成されるhelper APIは、安定版リリースまでに変更される可能性があります。
GraphQLサポートは、共有のruntime manifestを中心に構築されています:
schemaSdl- resolverのbinding
- enum・scalar・unionのマッピングなどのruntime metadata
executorはruntime manifestを消費します。Code-firstとSchema-firstは同じmanifestを生成するフロントエンドであり、そのmanifestはアプリ自身がロードするのではなく、prebuiltモジュールとして実行中のアプリへ届けられます。
実線のノードはあなたが編集する正(source of truth)、点線のノードはコマンドが作る生成物です。正を変更したら、そこから出るエッジのコマンドを再実行してください — 古い生成物のまま進めると型エラーやビルドエラーとして現れます。
API boundary
サポートされる実験的なapp-authoring API:
graphql()ResolverQueryMutationResolveFieldargs()gqlScalar()GqlOutput
graphql({ path, resolvers, schema? }) はendpointを宣言します。そのendpointがcode-firstかschema-firstかは、schema が渡されているかどうかによってendpointごとに決まります: code-firstの場合は省略し、schema-firstの場合は zelt graphql codegen が生成したhelperの schema exportを渡します。いずれの場合も、graphql() は生成されたruntimeを直接参照することはありません — それはadapterの prebuilt オプションを通じて別途供給されます(下記のBuild flowを参照)。
各endpointは識別用のkeyを持ちます: graphql({ path, resolvers, name }) — オプションの name で、省略時のデフォルトは 'graphql'、http() の name オプションと同じ規約です。このkeyは、そのendpointのprebuilt entryと生成されるファイル名をnamespace化します。複数の graphql() をmountするには、endpointごとに異なる name が必要です。2つのendpointが同じkeyを共有するとbuild時エラーになります。name は /^[A-Za-z0-9_-]+$/ にマッチしなければならず、予約済みのWindowsデバイス名(CON、PRN、AUX、NUL、COM1-9、LPT1-9)であってはいけません。graphql() は不正な name に対して即座にthrowします。endpointの組み合わせは自由です — 複数のschema-first行、複数のcode-first行、あるいはその両方を混在させても、同じapp内に共存できます。各行は自身のschema(またはresolverから導出されたSDL)とresolverだけをbindします。
生成コード用のAPIは、schema-firstのhelper専用にexportされます:
readGraphqlArgs()validateGraphqlArgs()
graphqlPlugin()、generateGraphqlSdl()、generateSdlForResolvers()、schema-first codegen、metadataの検査、型変換といったbuild時APIは、@zeltjs/graphql/codegen からのみexportされます。
adapterとframework内部で使われるruntime統合API — createGraphqlExecutor()、executeGraphqlRequest()、GraphqlRuntimeManifest、GeneratedGraphqlRuntime、GraphqlPrebuiltEntry、computeGraphqlPrebuiltHash() を含む — は @zeltjs/graphql に残ります。アプリケーションコードが通常これらを必要とすることはありません。
Code-first
import { createApp, http } from '@zeltjs/core';
import { args, graphql, Query, Resolver } from '@zeltjs/graphql';
import * as v from 'valibot';
const GetProductInput = v.object({
id: v.string(),
});
type Product = {
readonly id: string;
readonly name: string;
};
@Resolver()
class ProductResolver {
@Query()
product(input = args(GetProductInput)): Product {
return { id: input.id, name: 'Keyboard' };
}
}
export const app = createApp([
http({
children: [
graphql({
path: '/graphql',
resolvers: [ProductResolver],
}),
],
}),
]);
args(schema) は、Standard SchemaからGraphQLのfield引数を定義し、runtime時にvalidationを行います。
Schema-first
type Query {
product(id: ID!): Product
}
type Product {
id: ID!
name: String!
}
zelt graphql codegen --schema src/graphql/schema.graphql --out src/generated/graphql.ts
これにより、型付きhelperの Gql namespaceと、このschemaを識別する schema export({ sdl })を持つ src/generated/graphql.ts が書き出されます。また、<cwd>/.zelt/graphql-codegen.json へエントリをupsertし、schemaのcontent hashとこのhelperのパスをペアリングします — graphqlPlugin() は後で、あるendpointのresolverChecksをどこに書くかを見つけるために、このペアリングを利用します(下記のBuild flowを参照)。
import { Query, Resolver } from '@zeltjs/graphql';
import { Gql } from '../../generated/graphql';
@Resolver()
class ProductResolver {
@Query()
product(input = Gql.Query.product.args()): Gql.Query.product.Result {
return { id: input.id, name: 'Keyboard' };
}
}
このhelperの schema exportを graphql() に渡すことで、endpointをそれにbindします:
import { createApp, http } from '@zeltjs/core';
import { graphql } from '@zeltjs/graphql';
import { schema } from './generated/graphql';
import { ProductResolver } from './graphql/product.resolver';
export const app = createApp([
http({
children: [
graphql({
path: '/graphql',
resolvers: [ProductResolver],
schema,
}),
],
}),
]);
各schema-firstの graphql() endpointは、このようにしてschemaとペアリングされます — plugin設定を通じてではなく、アプリケーションコード内でです。ペアリングが graphql() の呼び出し箇所にあるため、appは複数のschema-first行(あるいはschema-firstとcode-firstを混在させた行)を、それぞれ独自の name を持たせて並べてmountできます:
graphql({ name: 'storefront', path: '/graphql', resolvers: [...], schema: storefrontSchema }),
graphql({ name: 'admin', path: '/admin/graphql', resolvers: [...], schema: adminSchema }),
storefrontSchema と adminSchema は、それぞれ独自の --out を持つ2つの別々の zelt graphql codegen 実行から生成されます。この2つの行がresolverやschemaを共有することは決してありません — /graphql に送られたqueryはstorefront schemaのfieldのみを見ることができ、/admin/graphql に送られたqueryはadmin schemaのfieldのみを見ることができます。
生成されたhelperには、追加のruntime validationを重ねることができます:
@Query()
product(input = Gql.Query.product.args(GetProductInput)): Gql.Query.product.Result {
return { id: input.id, name: 'Keyboard' };
}
schema-firstモードでは、SDLがsource of truthであり続けます。生成されたargs helperに渡されたStandard Schemaは、追加のvalidationとして扱われます。
args<T>() は意図的にuser-facing APIには含まれていません。Schema-firstの型は、手書きのgeneric引数ではなく、生成されたhelperから得るべきです。
Build flow
build生成のruntime出力 — .zelt/ 配下のすべて — をimportするのは、platform entryファイルだけです。app定義がそれをimportすることはないため、初めてappをbuildするときにchicken-and-eggの問題が発生することはありません。Schema-first appは生成コード(zelt graphql codegen が src/generated/graphql.ts に書き出す型付きhelper)をimportしますが、codegenは schema.graphql から直接実行され、appを評価する必要がないため、こちらでもchicken-and-eggの問題は発生しません。
import { graphqlPlugin } from '@zeltjs/graphql/codegen';
zelt.config.ts にpluginを登録します:
import { defineConfig } from '@zeltjs/cli';
import { graphqlPlugin } from '@zeltjs/graphql/codegen';
export default defineConfig({
app: () => import('./src/app').then((m) => m.app),
plugins: [graphqlPlugin()],
build: { entry: './src/node.ts' },
dev: { entry: './src/node.ts' },
});
その後、zelt build と zelt dev は2つの生成ステップを自動的に実行します:
- 登録された各
graphqlPlugin()は、graphql({ path, resolvers })のendpointごとに1組、.zelt/graphql/<key>.runtime.ts(export const graphqlPrebuilt = { runtime, resolversHash })と対になる.graphqlSDLファイルを書き出します。<key>はendpointのvalidateされたkey(そのname、省略時はgraphql。上記参照)であり、2つのgraphql()endpointが衝突するのはkeyを共有した場合のみです — 区別するには、それぞれに異なるnameを渡してください。 - CLIはすべてのpluginの成果物を集約し、
.zelt/prebuilt.ts(export const zeltPrebuilt = {...} satisfies ZeltPrebuilt)を書き出します。これは、生成された各モジュールをそのkeyのもとでre-exportし、graphqlfeatureの下にnamespace化します。このファイルは、pluginが何も貢献しない場合でも常に生成されます。
platform entryファイルは zeltPrebuilt を静的にimportし、adapterに渡します:
import { onNode } from '@zeltjs/adapter-node';
import { app } from './app';
import { zeltPrebuilt } from '../.zelt/prebuilt';
const nodeApp = await onNode(app, { prebuilt: zeltPrebuilt });
すべてのadapter(onNode、onBun、onCloudflareWorkers、onElectron、onLambda)は同じ prebuilt オプションを受け付けます。entryファイルは静的な import のみを使うため、これはCloudflare Workersの wrangler bundleのような、静的importを要求するbundler下でも変更なしに動作します — どのplatformにもファイルシステムへのfallbackはありません。
1つの graphqlPlugin() が、app内のすべてのendpoint — code-first、schema-first、あるいはその混在 — を処理します。各endpointが自身の行を持つためです(上記のAPI boundaryを参照)。それらを選択するための mode オプションはありません。
Code-first
- resolverを書く。
graphql({ path, resolvers })を設定する。zelt.config.tsのpluginsにgraphqlPlugin()を追加する。zelt buildまたはzelt devを実行する。- platform entryファイルが
../.zelt/prebuiltからzeltPrebuiltをimportし、adapterに渡す。
Schema-first
schema.graphqlを書く。zelt graphql codegen --schema ... --out ...を実行する。- 生成された
Gqlhelperを使ってresolverを書く。 - helperの
schemaexportを渡してgraphql({ path, resolvers, schema })を設定する。 zelt.config.tsのpluginsにgraphqlPlugin()を追加する(code-firstと同じ — schema-first固有のオプションはない)。zelt buildまたはzelt devを実行する。- platform entryファイルが
../.zelt/prebuiltからzeltPrebuiltをimportし、adapterに渡す。
別のschema-first行を追加するには、異なる --out とendpointごとに異なる name を使ってステップ1〜4を繰り返します。
zelt dev 中の自動schema-first codegenは、この時点のリリース範囲には含まれません。schema.graphql が変更されるたびに、zelt build/zelt dev の前に明示的に zelt graphql codegen を実行してください — codegen manifestのエントリ(<cwd>/.zelt/graphql-codegen.json)が欠落または古い場合、buildは再実行を促すエラーで失敗します。
schema-firstの各endpointには、各resolverメソッドの戻り値の型が、対応する生成された Gql.Query/Gql.Mutation の結果型に代入可能であることをassertする型チェックファイルが自動的に付与されます。これは、endpointのschemaがhashするcodegen helperの隣に書き出されます(<helper>.resolver-checks.ts、あるいは2つのendpointが1つのhelperを共有する場合は <helper>.<name>.resolver-checks.ts) — これに設定はありません。graphqlPlugin() はcodegen manifestを通じてペアリングを検出します。
Current limitations
GraphQLはprebuiltモジュールを必要とします。それが欠落している場合、endpointのprebuilt entryが欠落している場合、あるいはentryファイルが zeltPrebuilt をimportしていない場合、そのendpointは起動時にthrowします — どのplatformにもsilentなfallbackはありません。
各endpointは、そのkey(endpointの name、省略時は graphql)によってprebuiltモジュール内で検索されます。そのkeyの下にエントリが存在しない場合 — prebuiltにそのendpointが欠けているか、まだbuildされていないため — 起動時にthrowし、zelt build の再実行を促します。エントリが見つかると、そこに埋め込まれた resolversHash(endpointのpathとソートされたresolverクラス名に対するSHA-256フィンガープリント)が、実行中の graphql({ path, resolvers }) 宣言から再計算された値と照合されます。不一致は、prebuilt entryが現在のresolverに対して古いことを意味し、起動時に zelt build の再実行を促すthrowが発生します。v1はendpointのpathとresolverクラス名のみをフィンガープリントします — resolverメソッドのシグネチャ、引数の型、戻り値の型の変更は検出されず、zelt build を手動で再実行する必要があります。
Code-first
- 出力型のサポートは意図的に狭く保たれています。
- 複雑なGraphQLインターフェースはサポートが限定的です。
- Code-firstは、custom scalar codecとnamed unionを実験的にサポートしています。
- フィールド名はデフォルトでメソッド名になります。可能な場合、decoratorを通じて明示的な名前もサポートされます。
- フィールドの引数はStandard Schemaによるruntime validationを使い、SDL生成にはschema adapterが必要です。
Schema-first
- Schema-first codegenは、現時点では組み込みscalar、object型、
Query、Mutationをサポートしています。 - custom scalar、enum、union、interface、input objectは、意図的に制限されているか、今後に見送られています。
- custom scalar codecとnamed unionに対するschema-firstのサポートはまだ限定的で、今後別途拡張される予定です。
- ルートの
QueryとMutationのfieldにはresolver bindingが必須です。 - Object型のfieldは、GraphQLのデフォルトのfield resolutionに頼ることができます。
- 生成された
Gql.Query.<field>.args()helperが、schema-firstのメインのargs APIです。 - user-facingな
args<T>()は意図的にサポートされていません。