📑

DrizzleがTiDB Serverlessに対応していた件

2024/06/28に公開2

僕がちょうど検証していた2週間後くらいにリリースされていたみたいです…。

https://x.com/kazuph/status/1792432207547773361

https://github.com/drizzle-team/drizzle-orm/releases/tag/0.31.2

ということで試してみます。

というか公式のチュートリアルあるので詳しくはそっちを見てね。
https://docs.pingcap.com/tidbcloud/serverless-driver-drizzle-example

接続情報を取得する


TiDBの管理画面のconnectを押下します。

Serverless Driverを選択してmysqlから始まる文字列をゲットします。またパスワードも必要なので生成してください。

mysql://<USER_ID>.root:<PASSWORD>@gateway01.ap-northeast-1.prod.aws.tidbcloud.com/<DB_NAME>

※DBをまだつくってない場合は

CREATE DATABASE test_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

をSQL Editorから実行してください。 test_db は適当に変えてください。

既存プロジェクトを書き換える

ちょうどDrizzleをD1で使っているStackを作っていたので、それをTiDBに切り替えます。
https://github.com/kazuph/cf-next-hono-authjs-drizzle-d1-stack

pnpm up drizzle-orm @tidbcloud/serverless

環境変数に TIDB_HTTP_URL を追加

TIDB_HTTP_URL=mysql://<USER_ID>.root:<PASSWORD>@gateway01.ap-northeast-1.prod.aws.tidbcloud.com/<DB_NAME>

スキーマを書き換えます。

schema.ts ※長いので折りたたみ
import { sql } from "drizzle-orm";
import {
	mysqlTable,
	varchar,
	int,
	timestamp,
	boolean,
	primaryKey,
} from "drizzle-orm/mysql-core";
import { createInsertSchema, createSelectSchema } from "drizzle-zod";
import { z } from "zod";

export const users = mysqlTable("user", {
	id: varchar("id", { length: 255 }).notNull().primaryKey(),
	name: varchar("name", { length: 255 }),
	email: varchar("email", { length: 255 }).notNull(),
	emailVerified: timestamp("emailVerified"),
	image: varchar("image", { length: 255 }),
});

export const accounts = mysqlTable(
	"account",
	{
		userId: varchar("userId", { length: 255 })
			.notNull()
			.references(() => users.id, { onDelete: "cascade" }),
		type: varchar("type", { length: 255 })
			.$type<"oauth" | "oidc" | "email">()
			.notNull(),
		provider: varchar("provider", { length: 255 }).notNull(),
		providerAccountId: varchar("providerAccountId", { length: 255 }).notNull(),
		refresh_token: varchar("refresh_token", { length: 255 }),
		access_token: varchar("access_token", { length: 255 }),
		expires_at: int("expires_at"),
		token_type: varchar("token_type", { length: 255 }),
		scope: varchar("scope", { length: 255 }),
		id_token: varchar("id_token", { length: 255 }),
		session_state: varchar("session_state", { length: 255 }),
	},
	(table) => ({
		pk: primaryKey(table.userId, table.provider),
	}),
);

export const sessions = mysqlTable("session", {
	sessionToken: varchar("sessionToken", { length: 255 }).notNull().primaryKey(),
	userId: varchar("userId", { length: 255 })
		.notNull()
		.references(() => users.id, { onDelete: "cascade" }),
	expires: timestamp("expires").notNull(),
});

export const verificationTokens = mysqlTable(
	"verificationToken",
	{
		identifier: varchar("identifier", { length: 255 }).notNull(),
		token: varchar("token", { length: 255 }).notNull(),
		expires: timestamp("expires").notNull(),
	},
	(table) => ({
		pk: primaryKey(table.identifier, table.token),
	}),
);

export const todos = mysqlTable("todos", {
	id: int("id").primaryKey().autoincrement(),
	description: varchar("description", { length: 255 }).notNull(),
	userId: varchar("user_id", { length: 255 })
		.notNull()
		.references(() => users.id),
	completed: boolean("completed").default(false),
	createdAt: timestamp("created_at").default(sql`CURRENT_TIMESTAMP`),
});

export const insertTodoSchema = createInsertSchema(todos).extend({
	description: z
		.string()
		.min(1, "Description is required")
		.min(3, "Description must be at least 3 characters")
		.max(100, "Description must be 100 characters or less"),
});
export const selectTodoSchema = createSelectSchema(todos);

export const createTodoSchema = z.object({
	description: z
		.string()
		.min(1, "Description is required")
		.min(3, "Description must be at least 3 characters")
		.max(100, "Description must be 100 characters or less"),
});

export const updateToggleTodoSchema = z.object({
	id: z.coerce.number(),
	completed: z.boolean(),
});

export const updateTodoParamSchema = z.object({
	id: z.coerce.number(),
});

export const updateTodoJsonSchema = z.object({
	description: z.string().min(1),
});

export const deleteTodoSchema = z.object({
	id: z.coerce.number(),
});

drizzle.config.tsを設定します。リモートのTiDBに直接接続する設定です。

drizzle.config.ts
import { defineConfig } from "drizzle-kit";

if (!process.env.TIDB_HTTP_URL) {
	throw new Error("TIDB_HTTP_URL is not defined");
}

export default defineConfig({
	schema: "./app/schema.ts",
	out: "./drizzle/migrations",
	dialect: "mysql",
	dbCredentials: {
		url: process.env.TIDB_HTTP_URL,
	},
	verbose: true,
	strict: true,
});

そして以下を実行します。

# drizzle-kitがinstallされていない場合は以下
# pnpm add -D drizzle-kit mysql2
npx drizzle-kit generate
or
pnpm drizzle-kit generate

続いてそのままリモートのDBに対してmigration用のSQLを流します。

SQL Editorで確認すると作成されていることがわかります。

ではこの状態であとはアプリケーションから使うだけになりました。

今回のアプリケーションはNext.js on Cloudflare Page(@cloudflare/next-on-pages)上で動くHono経由で動いているので、

lib/db.ts
import { getRequestContext } from "@cloudflare/next-on-pages";
import { connect } from "@tidbcloud/serverless";
import { drizzle } from "drizzle-orm/tidb-serverless";

export const runtime = "edge";

export const getDb = () => {
	const client = connect({
		url: getRequestContext().env.TIDB_HTTP_URL,
	});
	return drizzle(client);
};

という様に書くことでContextを取得してenvを呼び出せます(honoならmiddlewareとして書くのです…という声が聞こえましたが一旦スルーします、そのうち)。

さてこれで開発サーバを動かしてみます

pnpm dev

え、エラーだと…。

[auth][cause]: Error: Execute SQL fail: Error 1406 (22001): Data too long for column 'id_token' at row 1

お、追加でマイグレーションするのにちょうどいいエラーですね。
修正します。

diff --git a/app/schema.ts b/app/schema.ts
index 44284f5..0a2e352 100644
--- a/app/schema.ts
+++ b/app/schema.ts
@@ -4,6 +4,7 @@ import {
 	varchar,
 	int,
 	timestamp,
+	text,
 	boolean,
 	primaryKey,
 } from "drizzle-orm/mysql-core";
@@ -34,7 +35,7 @@ export const accounts = mysqlTable(
 		expires_at: int("expires_at"),
 		token_type: varchar("token_type", { length: 255 }),
 		scope: varchar("scope", { length: 255 }),
-		id_token: varchar("id_token", { length: 255 }),
+		id_token: text("id_token"),
 		session_state: varchar("session_state", { length: 255 }),
 	},
 	(table) => ({

SQLを生成します。

npx drizzle-kit generate

こういうSQLが生成されました。

ALTER TABLE `account` MODIFY COLUMN `id_token` text;

流します。

npx drizzle-kit migrate

無事に成功したみたいなので、再度アプリケーションで確認します。

レコードも確認できました!

最後に

DrizzleさんTiDB Serverlessに対応したなら言ってよ!!!!!(見てないだけ)

おわり

https://github.com/kazuph/cf-next-hono-authjs-drizzle-TiDB-stack

Discussion

kazuphkazuph

ローカルでTiDBを立ち上げて作業する方法を書きます。

TiDBのインストール

curl --proto '=https' --tlsv1.2 -sSf https://tiup-mirrors.pingcap.com/install.sh | sh

これで一発で入ります。

TiDBの起動

$ tiup playground

Note: Version constraint  is resolved to v8.1.0. If you'd like to use other versions:

    Use exact version:      tiup playground v7.1.0
    Use version range:      tiup playground ^5
    Use nightly:            tiup playground nightly

Start pd instance:v8.1.0
Start tikv instance:v8.1.0
Start tidb instance:v8.1.0
Waiting for tidb instances ready
127.0.0.1:4000 ... Done
The component `prometheus` version v8.1.0 is not installed; downloading from repository.
download https://tiup-mirrors.pingcap.com/prometheus-v8.1.0-linux-amd64.tar.gz 121.03 MiB / 121.03 MiB 100.00% 23.25 MiB/s
download https://tiup-mirrors.pingcap.com/grafana-v8.1.0-linux-amd64.tar.gz 50.16 MiB / 50.16 MiB 100.00% 34.18 MiB/s
Start tiflash instance:v8.1.0
The component `tiflash` version v8.1.0 is not installed; downloading from repository.
download https://tiup-mirrors.pingcap.com/tiflash-v8.1.0-linux-amd64.tar.gz 433.83 MiB / 433.83 MiB 100.00% 30.61 MiB/s
Waiting for tiflash instances ready
127.0.0.1:3930 ... Done

🎉 TiDB Playground Cluster is started, enjoy!

Connect TiDB:    mysql --comments --host 127.0.0.1 --port 4000 -u root
TiDB Dashboard:  http://127.0.0.1:2379/dashboard
Grafana:         http://127.0.0.1:33043

この状態で別ターミナルからmysqlコマンドでアクセスできるようになります。

今回はDrizzleを使っているので、configを修正します。

drizzle.config.ts
import { defineConfig } from "drizzle-kit";

const isDevelopment = process.env.NODE_ENV === "development";

if (!isDevelopment && !process.env.TIDB_HTTP_URL) {
	throw new Error(
		"TIDB_HTTP_URL is not defined in non-development environment",
	);
}

export default defineConfig({
	schema: "./app/schema.ts",
	out: "./drizzle/migrations",
	dialect: "mysql",
	dbCredentials: isDevelopment
		? {
				url: "mysql://root:@localhost:4000/test",
			}
		: {
				url: `${process.env.TIDB_HTTP_URL}?ssl={"rejectUnauthorized":true}`,
			},
	verbose: true,
	strict: true,
});

※testというDBが初期状態でCREATEされているみたいです。

そして以下を実行するとスキーマが流せます。

npx drizzle-kit generate
npx drizzle-kit migrate

studioで確認してみます。

npx drizzle-kit studio

tableが生成されていれば成功です。

最後に私のpackage.jsonを書いておきます。

	"scripts": {
		"dev": "next dev",
		"build": "next build",
		"start": "next start",
		"lint": "next lint",
		"pages:dev": "npx wrangler pages dev .next",
		"pages:build": "npx @cloudflare/next-on-pages",
		"preview": "wrangler pages dev",
		"studio:local": "NODE_ENV=development drizzle-kit studio",
		"studio:remote": "NODE_ENV=production drizzle-kit studio",
		"migration:generate": "drizzle-kit generate",
		"migration:apply:local": "NODE_ENV=development drizzle-kit migrate",
		"migration:apply:remote": "NODE_ENV=production drizzle-kit migrate",
		"deploy": "npm run pages:build && wrangler pages deploy",
		"cf-typegen": "wrangler types --env-interface CloudflareEnv env.d.ts"
	},

以上です。

kazuphkazuph

追加です。アプリケーションからもローカルのtidbにアクセスする方法です。

結論的にはアプリケーションからはmysql2を使ってのローカルのmysqlへのアクセスができません。なので drizzle-orm/mysql-proxy を使うように修正します。

lib/db.ts
import { getRequestContext } from "@cloudflare/next-on-pages";
import { drizzle as drizzleTiDB } from "drizzle-orm/tidb-serverless";
import { drizzle as drizzleProxy } from "drizzle-orm/mysql-proxy";
import { connect } from "@tidbcloud/serverless";

export const runtime = "edge";

export const getDb = () => {
	const env = getRequestContext().env;

	if (env.NODE_ENV === "production") {
		// 本番環境: TiDB Serverlessに直接接続
		const client = connect({
			url: `${env.TIDB_HTTP_URL}?ssl={"rejectUnauthorized":true}`,
			debug: true,
		});
		return drizzleTiDB(client);
		// biome-ignore lint/style/noUselessElse: <explanation>
	} else {
		// 開発環境: ローカルのMySQL proxyを使用
		return drizzleProxy(async (sql, params, method) => {
			try {
				const response = await fetch("http://localhost:8000/query", {
					method: "POST",
					headers: {
						"Content-Type": "application/json",
					},
					body: JSON.stringify({ sql, params, method }),
				});

				if (!response.ok) {
					throw new Error(`HTTP error! status: ${response.status}`);
				}

				const data = await response.json();
				return { rows: data };
				// biome-ignore lint/suspicious/noExplicitAny: <explanation>
			} catch (e: any) {
				console.error("Error from mysql proxy server: ", e.message);
				return { rows: [] };
			}
		});
	}
};

ここで http://localhost:8000 を用意する必要があります。これは本家の ドキュメント…は間違っているので githubの exampleの方のコードを拝借します。

https://github.com/drizzle-team/drizzle-orm/blob/main/examples/mysql-proxy/README.md

mkdir mysql-proxy
pnpm add -d express @types/express mysql2

をして

mysql-proxy/index.ts
import * as mysql from "mysql2/promise";
import express from "express";
const app = express();
app.use(express.json());
const port = 8000;
const main = async () => {
	const connection = await mysql.createConnection(
		"mysql://root:@127.0.0.1:4000/test",
	);

	app.post("/query", async (req, res) => {
		const { sql: sqlBody, params, method } = req.body;

		if (method === "all") {
			try {
				const result = await connection.query({
					sql: sqlBody,
					values: params,
					rowsAsArray: true,
					typeCast: function (field: any, next: any) {
						if (
							field.type === "TIMESTAMP" ||
							field.type === "DATETIME" ||
							field.type === "DATE"
						) {
							return field.string();
						}
						return next();
					},
				});
				console.log({ sqlBody, params, method, result });
				res.send(result[0]);
			} catch (e: any) {
				res.status(500).json({ error: e });
			}
		} else if (method === "execute") {
			try {
				const result = await connection.query({
					sql: sqlBody,
					values: params,
					typeCast: function (field: any, next: any) {
						if (
							field.type === "TIMESTAMP" ||
							field.type === "DATETIME" ||
							field.type === "DATE"
						) {
							return field.string();
						}
						return next();
					},
				});

				console.log({ sqlBody, params, method, result });
				res.send(result);
			} catch (e: any) {
				res.status(500).json({ error: e });
			}
		} else {
			res.status(500).json({ error: "Unknown method value" });
		}
	});

	app.post("/migrate", async (req, res) => {
		const { queries } = req.body;

		await connection.query("BEGIN");
		try {
			for (const query of queries) {
				await connection.query(query);
			}
			await connection.query("COMMIT");
		} catch {
			await connection.query("ROLLBACK");
		}

		res.send({});
	});

	app.listen(port, () => {
		console.log(`Example app listening on port ${port}`);
	});
};

main();

そして

npx tsx mysql-proxy/index.ts

として実行します。

あとはちゃんとgenerate & migrateができている状態で pnpm dev を行ってもらえればローカルのみmysql-proxy経由でDBにアクセスできていることが確認できると思います。