White Box技術部

WEB開発のあれこれ(と何か)

Prisma v7に変更した際の落ち葉拾い(DATABASE_URLの扱い注意)

先日のブログでPrismaをv7にアップデートした話を書きましたが、.gitignore以外にも気をつける点があったので今回はそれを書いていこうと思います。

seri.hatenablog.com

.env内でDATABASE_URLが組み立てられない

dotenvが不要だったころは、以下のように.envにデータソースの情報を定義して利用していました。

DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_USERNAME=username
DATABASE_PASSWORD=password
DATABASE_NAME=dbname
DATABASE_URL="postgresql://${DATABASE_USERNAME}:${DATABASE_PASSWORD}@${DATABASE_HOST}:${DATABASE_PORT}/${DATABASE_NAME}"

これをそのままv7でも利用すると、prisma deploy時などに以下のエラーが発生します。

Loaded Prisma config from prisma.config.ts.

Prisma schema loaded from prisma/schema.prisma.
Datasource "db": PostgreSQL database

Error: P1013: The provided database string is invalid. invalid port number in database URL.
Please refer to the documentation in https://pris.ly/d/config-url for constructing a correct connection string.
In some cases, certain characters must be escaped. Please check the string for any illegal characters.

prisma.config.tsでコンソールログを吐いてDATABASE_URLを確認したところ、変数展開される前の値が出力されていました。

postgresql://${DATABASE_USERNAME}:${DATABASE_PASSWORD}@${DATABASE_HOST}:${DATABASE_PORT}/${DATABASE_NAME}

軽く調べたらdotenvは変数展開をサポートしていないため、これをしたければdotenv-expandを入れる必要があるようです。

今回はそこまで頑張る気力がなかったので、変数を取らずに直接DATABASE_URLを作って動作させました。

DATABASE_URL="postgresql://username:password@localhost:5432/dbname"

CIでDATABASE_URLが見つからずにgenerateできない

またDATABASE_URLの話なのですが、GitHub Actionsで実施していたCIで以下のエラーが発生しました。

Error: PrismaConfigEnvError: Cannot resolve environment variable: DATABASE_URL.

prisma.config.tsでenv('DATABASE_URL')のようにenv関数を使っているため、利用している環境変数が読み込まれていなければprisma generateが失敗するようです。

現時点(prisma": "7.4.2")では環境変数さえ定義されていれば、値はダミー値でも問題ないようなので、 以下のようにgenerateが行われるタスクでenvを指定する修正を行いました。

- name: Build
  env:
    DATABASE_URL: "postgresql://dummy:dummy@localhost:5432/dummy"
  run: yarn admin:build

終わりです

なんか色々あった気がしたんですが、全部DATABASE_URLの話でした。

Next Adminの進捗報告:Better AuthとShadcn、Tailwind移行対応が終わった

Next Adminとは?

フリーランスになってから管理画面を1から作ることがもう3回もあったので、いい加減テンプレート的なプロダクトを作っておこうと、ちまちま作っていた管理機能を中心としたNext.jsアプリケーションです。

これをnext-adminというリポジトリ名で公開しています。

github.com

公開していると言っても絶賛開発中なので、これがすぐ管理画面になるわけでもないのですが、ようやくフロントエンド周りの最新技術スタックに移行できたので、一旦ここで記事にしておこうと思ってこれを書いている次第です。

認証基盤の移行(NextAuthからBetter Authに)

元々Next.jsアプリケーションの認証にはNextAuthを使っていたので、このプロダクトも当初はNextAuthで実装していました。 ですがNextAuth自体がAuth.jsに様変わりしてしまったのに、なぜかいまだにベータリリースで、移行することもままならなかったため、 知り合いが乗り換えていたBetter Authに移行してあります。

使ってみた感じ特に違和感もなく、Email認証くらいであればすんなり移行できました。 Better Authは機能も豊富のようなので、これでパスキー認証なども検証していきたいなと思っています。

UIデザインの移行(オリジナルデザインからshadcn/uiベースに)

元々は仕事ではオープンソースの管理画面デザインコンポーネントをベースに管理画面を作っていたのですが、 そのデザインコンポーネントがちょっと古い実装スタイルということもあり、Next Adminを作る際に自分で設計したオリジナルデザインをSCSSで実装していました。

別にそれでも良かったのですが、デザイン設計自体が得意なわけでもないため、結果実装着手が遅延しがちでした。

そんな状況でShadcnに慣れたこともあり、であればと公開されているShadcnのテンプレートデザインに書き換えました。

合わせてSCSSからTailwindCSS V4にCSSを変更しています。

結果、自分で作ったのよりも遥かに現代ぽくなったという満足感と敗北感を味わうことになりはしました。

今後の予定

最低限何か1つ、管理機能が実装されていれば、後はコピペで機能追加していけるだろうという見込みでいるので、 ユーザー管理機能を作成する予定です。

その後はよく実装した管理画面の汎用的な機能をコツコツ入れていこうと考えています。

【TypeScript】Prisma v7でNext.jsのモノレポ構成をYarn v4で実現する方法

先日ヨドバシのクレカ明細を分析するツールを作ったときにPrismaを5系から7系に上げたのですが、 公式のマイグレーションガイドだけでは解決しない点がいくつかあったので、メモを残しておきます。

構成

今回話題に上げるアプリケーション構成は、TypeScriptを利用して、フルスタックNext.jsをyarnでモノレポ管理しているものになります。 またDBはPostgreSQLです。

/
├── apps/
│   └── web/       Next.jsアプリケーション
└── packages/
    └── db/        Prisma管理ディレクトリ

Prisma v5の時点では、packages/db配下でPrismaのクライアントファイルを生成すればnode_modulesに配置されるので、web側では何もしなくてもPrismaClientをimportすることができていました。

以前はnext.config.jsにtranspilePackages: ["@sample-app/db"],のように書いていましたが、 ホスティングされているからかyarn v4で管理している状態では書かなくても利用することができていました。 https://nextjs.org/docs/app/api-reference/config/next-config-js/transpilePackages

今回、この構成はなるべく維持しつつ、v7に上げていきます。

関連しそうなパッケージとバージョンは以下の通りです。

"yarn": "4.12.0"
"next": "16.1.6"
"@prisma/adapter-pg": "7.4.0"
"@prisma/client": "7.4.0"
"dotenv": "17.3.1"
"pg": "8.18.0"
"prisma": "7.4.0"
"pglite-prisma-adapter": "0.7.2"
"tsx": "4.21.0"
"typescript": "5.9.3"

packages/db側の変更点

Prisma関連のファイルを管理するパッケージをpackages/dbに配置しているのですが、変更が必要になったファイルは以下の通りです。

変更が必要なファイル

  • package.json
  • schema.prisma
  • seed.ts
  • .gitignore <- 忘れないように注意

新規追加のファイル

  • prisma.config.ts

package.json関連の話

TypeScriptで記載されたSeedファイルを実行するため、v7以前ではpackage.jsonに以下のような記載をれていましたが、これが不要になりました。というか、あるとエラーになります。

"prisma": {
  "seed": "ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts"
},

またこの構成の場合は、以下のパッケージが新たに必要な構成になっているのでdependenciesに追加します。

  • @prisma/adapter-pg
  • dotenv
  • pg
  • tsx

schema.prismaの変更点とprisma.config.tsの追加

v7の大きい変更点は生成されるクライアントファイルの管理が必要になったところだと思います。

冒頭で記載したように以前はnode_modulesに配置されていたものが、リポジトリ管理ファイルとして保持するものに変更されています。 そのためschema.prismaに以下のような変更が必要になります。

  • Before
datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

generator client {
  provider        = "prisma-client-js"
  previewFeatures = ["fullTextSearch"]
}
  • After
datasource db {
  provider = "postgresql"
}

generator client {
  provider               = "prisma-client"
  output                 = "./generated/prisma"
  moduleFormat           = "esm"
  generatedFileExtension = "ts"
  importFileExtension    = "ts"
  previewFeatures        = ["fullTextSearchPostgres"]
}

ちなみにgeneratedFileExtensionやimportFileExtensionあたりの設定がないと、TypeScript環境では実行時にファイルが見つからない系エラーが発生します。

そしてprisma.config.tsファイルを新規追加します。

import 'dotenv/config';
import path from 'node:path';
import { defineConfig, env } from 'prisma/config';

export default defineConfig({
  schema: path.join(__dirname, 'prisma/schema.prisma'),
  migrations: {
    path: path.join(__dirname, 'prisma/migrations'),
    seed: 'tsx prisma/seed.ts',
  },
  datasource: {
    url: env('DATABASE_URL'),
  },
});

モノレポ構成でのポイントは、「パスの指定に__dirnameを使う必要があるが、seedコマンドは相対パスで書く」ところです。

seed.tsの変更点

seed.tsも以前は事前準備が、以下のように2行で済むお手軽構成でしたが、

import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();

v7からはadapterの定義が必要になっています。
例えばカテゴリデータをを入れるようなコードは以下のようになります。

import { PrismaClient } from "./generated/prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
import { CATEGORIES } from "./seeds/categories";
import "dotenv/config";

const adapter = new PrismaPg({
  connectionString: process.env.DATABASE_URL,
});

const prisma = new PrismaClient({
  adapter,
});

async function main() {
  console.log("Seeding database...");

  for (const category of CATEGORIES) {
    await prisma.category.upsert({
      where: { name: category.name },
      update: {},
      create: category,
    });
  }
  console.log(`Created ${CATEGORIES.length} categories`);

  console.log("Seeding completed.");
}

main()
  .catch((e) => {
    console.error(e);
    process.exit(1);
  })
  .finally(async () => {
    await prisma.$disconnect();
  });

tsconfig.jsonは変更不要

ちなみにtsconfig.jsonは変更が不要でした。今使っている全量はこちらです。

{
  "compilerOptions": {
    "target": "es6",
    "lib": ["dom", "dom.iterable", "es6", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "strictNullChecks": true,
    "forceConsistentCasingInFileNames": true,
    "types": ["node"],
    "typeRoots": ["./node_modules/@types", "../../node_modules/@types"],
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "paths": { "@/*": ["./*"] },
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true,
    "incremental": true,
    "sourceMap": true,
    "declaration": true,
    "emitDeclarationOnly": true,
    "outDir": "dist"
  },
  "include": ["prisma/**/*.ts"],
  "exclude": ["node_modules"]
}

apps/web側の変更点

Next.jsアプリケーション側の変更点は

  • PrismaClientのimport変更
  • dependenciesに"@sample-app/db: "*",を追加

になります。

Prisma管理パッケージの名前が"@sample-app/db"で、ファイルの生成先がprisma/generated/prismaとした場合、package.jsonのdependenciesに"@sample-app/db: "*",を追加し、PrismaClientのインスタンスを生成しているファイルでimport先を以下のように変更してください。

import { PrismaClient } from "@sample-app/db/prisma/generated/prisma/client";

運用上の注意

Prismaパッケージのバージョンを上げると、古い生成コードが使えなくな(る場合があ)ります。

✓ Starting...
✓ Ready in 436ms
⨯ TypeError: Cannot read properties of undefined (reading 'graph')
    at createPrismaClient (src/lib/prisma.ts:27:10)
    at <unknown> (src/lib/prisma.ts:30:42)
  25 |   });
  26 |
> 27 |   return new PrismaClient({ adapter });
     |          ^
  28 | };
  29 |
  30 | const prisma = globalForPrisma.prisma || createPrismaClient(); {
  clientVersion: '7.3.0'
}

なので私みたいにしれっとリポジトリ管理してしまわず、.gitignoreにクライアントが生成されるディレクトリを追加しておきましょう。。。

いやでもこれは、マイグレーションガイドの方にも書いておいてくれても!良かったと思う!よ!なんて。

【React】react-intlでZodの多言語対応をうまくやる方法

Zodのスキーマ定義は別ファイルで管理したい!

Zodのスキーマ定義はクライアント側のチェックでもサーバー側のチェックでも利用したいので、コンポーネント内で定義するのではなく別ファイルで管理したいマンなのですが、react-intlが採用されたプロジェクトでそうしようとしたとき、一筋縄では行かなかったので、解決策を残しておきます。

ちなみに関連パッケージのバージョンは以下のとおりです。

"@hookform/resolvers": "5.2.1",
"next": "16.0.7",
"react": "19.2.1",
"react-intl": "7.1.11",
"zod": "3.25.58",

問題点

スキーマ定義は多言語対応しない場合は以下のように定義することができます。

export const editUserSchema = z.object({
  name: z
    .string()
    .min(1, "必須項目です")
    .max(100, "名前は100文字以下で入力してください"),
});

これを以下のようにメッセージ定義して、

const message = defineMessage({
  required: '必須項目です',
  maxLength: '名前は{length}文字以下で入力してください',
});

さあintl.formatMessage(message.required)と書こうとすると、

intlはどうするんだ?

となってしまいます。

クライアント側であればuseIntl()フックを使って取得できますし、サーバー側はcreateIntl関数で生成することでintlを取得できますが、スキーマ定義を別ファイルにするのであればどちらかに依存しては意味がありません。

よく行われる解決策

zodの返却するエラーメッセージはただのstringなので、メッセージにはMessageDescriptorのidを設定し、エラーメッセージの表示側でそれぞれメッセージ文言に変換する方法が現状よく利用されているようです。

具体的にはschemas.tsには以下のように定義し、

const message = defineMessage({
  required: '必須項目です',
});
export const editUserSchema = z.object({
  name: z
    .string()
    .min(1, message.required.id),
});

例えばクライアント側では、以下のように利用するという感じです。

const intl = useIntl();
const {
    register,
    formState: { errors },
} = useForm<z.infer<typeof editUserSchema>>({
  resolver: zodResolver(editUserSchema),
  defaultValues: { name: '' },
});

const errorMessage = intl.formatMessage({ id: errors.name?.message });

ちなみにzodの話だけであれば、zod-i18nを使うと簡単かもしれません

本格的な解決

上記の方法では、パラメータをメッセージに渡すことができません。
現状Zodの返却値はstringだけなので、後は力技で解決することにしました。

具体的には、

  1. スキーマ定義内のメッセージには必要情報が入ったJSON文字列を定義する
  2. 利用側ではJSON文字列をパースしてformatMessageに設定する

です。

まずは、ユーティリティとして以下の2つの関数を定義します。
getJsonErrorMessageがJSON文字列作成用、getSchemaCheckMessageが実際に出したいメッセージ出力用です。

import { z } from 'zod';
import { IntlShape } from 'react-intl';

export const getJsonErrorMessage = (
  messageId: string | undefined,
  params?: {}
): string => {
  return JSON.stringify({ id: messageId, ...params });
};

export const getSchemaCheckMessage = (
  intl: IntlShape,
  value: string | undefined
): string => {
  if (!value) {
    return '';
  }
  try {
    const message = JSON.parse(value);
    if (!message?.id) {
      return message;
    } else if (intl.messages[message.id] === '') {
      return value;
    } else {
      const { id, ...rest } = message;
      return intl.formatMessage({ id: message.id }, rest);
    }
  } catch (e) {
    if (e instanceof SyntaxError) {
      return value;
    }
    throw e;
  }
};

スキーマ定義では以下のように記述します。

const message = defineMessage({
  required: '必須項目です',
  maxLength: '名前は{length}文字以下で入力してください',
});

export const editUserSchema = z.object({
  name: z
    .string()
    .min(1, getJsonErrorMessage(message.required.id))
    .max(100, getJsonErrorMessage(message.maxLength.id, {
       length: 100,
      })
    ),
});

そして使う側は以下のようになります。

const intl = useIntl();
const {
    register,
    formState: { errors },
} = useForm<z.infer<typeof editUserSchema>>({
  resolver: zodResolver(editUserSchema),
  defaultValues: { name: '' },
});

const errorMessage = getSchemaCheckMessage(intl, errors.name?.message);

サーバーサイドでは以下のような感じでしょうか。こっちはあくまで無理やり使った雰囲気コードです。

"use server";
import z from "zod";
import { createIntl, createIntlCache } from 'react-intl';

const cache = createIntlCache();

export const editUser = async (props: z.infer<typeof editUserSchema>) => {
  const locale = await getServerLocale();
  const intl = createIntl({ locale, messages: i18nMessages, defaultLocale: 'ja-JP' }, cache);
  const parsed = editUserSchema.safeParse(props);
  if (!parsed.success) {
    const nameErrors = z.flattenError(parsed.error).fieldErrors.name;
    if (nameErrors?.length) {
      for (const err of nameErrors) {
        console.error("Name error:", getSchemaCheckMessage(intl, err));
      }
    }
    ...
  }
...
};

これで問題なくreact-intlも使え、スキーマ定義を別ファイルにすることもできるようになりました。

多言語対応は大変すぎますね。日本語に対応してくれているサイトには感謝しかないです。

関連リンク