👨‍💻

Cloudflare D1へのseedをdrizzle-orm/d1経由で流したい

2024/12/08に公開

最近個人開発でCloudflareのサービスをフル活用しているのですが、
その中でD1へのseedデータ投入をどうしてもdrizzle経由で行いたい!と思う場面があったため、
対応した経緯を備忘録として残しておきます。

wrangler cli経由ではダメなの?

事前にsqlを準備して、

wrangler d1 execute my-database --local --file=seed.sql

のように叩けばseedデータを投入することは出来ます。

が、せっかくdrizzleでschema定義しているのであれば、
型安全でコードファーストなseederを実装したいと思うものです。
私の場合は、上記に加えて、実際に入るデータに近い値を ValueObject を通じて担保したいという背景がありました。

共通の設定

必要なパッケージは以下です。

npm install drizzle-orm drizzle-orm/sqlite-core

またコマンド実行時に必要なパッケージもインストールしておいてください。
(それぞれ良しなにts-nodeやcross-envに代替しても大丈夫です。

npm install tsx dotenv-cli

SQLiteファイルへのパスが必要なので実行時に環境変数として渡します。
monorepo等で別のパッケージにd1の本データがある場合は参照パスを適宜修正してください。

{
  "scripts": {
    "seed": "dotenv -v DATABASE_URL=$(find .wrangler/state/v3/d1/miniflare-D1DatabaseObject -type f -name '*.sqlite' -print -quit) -- tsx ./seeds/index.ts"
  }
}

ローカル環境用に保存されているD1のsqliteへのパス取得は以下を参考にしています。

https://zenn.dev/hanabi_rest/articles/drizzle-kit-d1

ちなみに、現実的な実装で --persist-to オプションを考慮してsqliteのあるd1までのパスを取得したい場合は、
wranglerのこの辺りの実装を拝借することでいけるかと思います。

https://github.com/cloudflare/workers-sdk/blob/5449fe54b15cf7c6dd12c385b0c8d2883c641b80/packages/wrangler/src/d1/execute.ts#L271-L280

また、以下の実行コマンドは各々の環境に合わせて修正してください。

tsx ./seeds/index.ts

例として、以下のschemaを前提とします。

// seeds/user.ts
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';

export const user = sqliteTable('user', {
  id: text('id').primaryKey(),
  name: text('name').notNull()
});

sqliteのdriverで実装する場合

libsqlの場合

公式の通り必要なパッケージを追加してください。

npm install @libsql/client

以下のような実装になるかと思います。
DATABASE_URLは npm run seed 時に環境変数として埋め込まれているはずです。

// seeds/index.ts
import { drizzle } from 'drizzle-orm/libsql';
import { user } from './user';
import { v4 as uuidv4 } from 'uuid';

if (!process.env.DATABASE_URL) throw new Error('DATABASE_URL is not defined');

const database = drizzle(process.env.DATABASE_URL, { schema: { user } });
database.insert(user).values([{
  id: uuidv4(),
  name: 'user',
}]);

better-sqlite3の場合

こちらも公式の通り必要なパッケージを追加してください。

npm install better-sqlite3

npm install -D @types/better-sqlite3

libsqlとほぼ同じでdrizzleの参照packageが変わるのみになります。

// seeds/index.ts
import { drizzle } from 'drizzle-orm/better-sqlite3';
import { user } from './user';
import { v4 as uuidv4 } from 'uuid';

if (!process.env.DATABASE_URL) throw new Error('DATABASE_URL is not defined');

const database = drizzle(process.env.DATABASE_URL, { schema: { user } });
database.insert(user).values([{
  id: uuidv4(),
  name: 'user',
}]);

どうせなら drizzle-orm/d1 で実装したい

ここからが本題です。
d1がsqliteベースであるとは言えども、
実際のバックエンドではwrangler.tomlでbindingしたD1Databaseを drizzle-orm/d1 経由で操作することになると思います。
なので、可能であればsqliteのdriverを直接使わず drizzle-orm/d1 経由でseedデータを流したいと考えました。

とは言ったものの、 wrangler CLIを一切経由せずにworker外でD1Databaseを扱う方法 について調べても全然出てこなかったので苦戦してしまいました。。


Cloudflare workerのシミュレータであるminiflareを使うことになります。
以下は必要なpackageです。

npm install drizzle-orm/d1 @miniflare/d1 @miniflare/shared

先程までより記述が長くなってしまいますが、結論こうなります。
createSQLiteDBが返すDBは内部的にbetter-sqlite3を経由したものになっていますが、
結果的に drizzle-orm/d1 を使ってseedを流せることが出来ました。

// seeds/index.ts
import { drizzle } from 'drizzle-orm/d1';
import { D1Database, D1DatabaseAPI } from '@miniflare/d1';
import { createSQLiteDB } from '@miniflare/shared';
import { user } from './user';
import { v4 as uuidv4 } from 'uuid';

if (!process.env.DATABASE_URL) throw new Error('DATABASE_URL is not defined');

const sqliteDb = await createSQLiteDB(process.env.DATABASE_URL);
const database = new D1Database(new D1DatabaseAPI(sqliteDb));
const database = drizzle(database, { schema: { user } });

await database.insert(user).values([{
  id: uuidv4(),
  name: 'user',
}]);

実運用に近いseederの実装

実運用を考えると、テーブル毎に別々のファイルでseederを実装して流したいので、
エントリーポイントとなるファイルには実行基盤を実装しました。
seedersの配列には実行したいseederを入れていく想定で、
その型はSeederです。

// seeds/index.ts
import { drizzle } from 'drizzle-orm/d1';
import { D1Database, D1DatabaseAPI } from '@miniflare/d1';
import { createSQLiteDB } from '@miniflare/shared';
import { user } from './user'; // schema
import { users } from './users'; // seeder
import type { DrizzleD1Database } from 'drizzle-orm/d1';
import type { Relations } from 'drizzle-orm';
import type { AnySQLiteTable } from 'drizzle-orm/sqlite-core';

if (!process.env.DATABASE_URL) throw new Error('DATABASE_URL is not defined');

export const schema = {
  user
} satisfies Record<string, AnySQLiteTable<NonNullable<unknown>> | Relations>;
const sqliteDb = await createSQLiteDB(process.env.DATABASE_URL);
const database = new D1Database(new D1DatabaseAPI(sqliteDb));
const database = drizzle(database, { schema });

export type Seeder = (database: DrizzleD1Database<typeof schema>) => Promise<void>;

const seeders: Seeder[] = [
  users,
];

for (const seeder of seeders) {
  await seeder(database);
}

Seederの型を見るだけでは分かりづらいので、
以下サンプルを参考にしてもらえればと思います。

// seeds/users.ts
import { schema } from './index';
import { v4 as uuidv4 } from 'uuid';
import type { Seeder } from './index';

export const users: Seeder = async (database) => {
  await database.delete(schema.user);
  await database.insert(schema.user).values({
    id: uuidv4(),
    name: 'user',
  });
};

まとめ

ここまでにまとめた方法で出来なかった場合、
seederを実行するためだけのダミーworkerを作ろうとしていました笑
それくらい、 drizzle-orm/d1 を使ってseederを実装したいという執念がありましたw
wranglerコマンドで立ち上げたworkerで無かったとしても、
unstable_devgetPlatformProxyでなんとかしようと考えたくらいです。

sqliteクライアントを直接使うよりも記述が長くなってしまいましたが、
バックエンドで使ってる drizzle-orm/d1 をseederでも使えて良かったです。

ちなみに、ローカルのmigrationではsqliteをバッチリ使っています笑
(逆にこの方法しか無いのではというのと、d1用にinterface作ったところでsqliteじゃんとなる気もするのでこれで良いと思ってます。

import * as dotenv from 'dotenv';
import { defineConfig } from 'drizzle-kit';

import type { Config } from 'drizzle-kit';

dotenv.config();

const config = process.env.DATABASE_URL
  ? ({
      dialect: 'sqlite',
      schema: './src/schema/tables/*',
      out: './migrations',
      dbCredentials: {
        url: process.env.DATABASE_URL as unknown as string,
      },
    } satisfies Config)
  : ({
      dialect: 'sqlite',
      driver: 'd1-http',
      schema: './src/schema/tables/*',
      out: './migrations',
      dbCredentials: {
        accountId: process.env.CLOUDFLARE_ACCOUNT_ID,
        databaseId: process.env.CLOUDFLARE_DATABASE_ID,
        token: process.env.CLOUDFLARE_TOKEN,
      },
    } satisfies Config);

export default defineConfig(config);

interfaceへのこだわりが出てしまった記事でもありましたが、
こうやって無料で個人開発が出来る環境が整えられていることやOSSを提供している方々への感謝とリスペクトは忘れないように、
自分が出来ることはアウトプットだと思いこのような形で備忘録を残しました。

Discussion