StackblitzでWebアプリ開発(Stackblitz + Next.js(Server Actions) + Drizzle ORM + SQLite)

全体の9割ぐらいは試行錯誤メモみたいな内容なので、お時間のない方は[飛ばして](#完成品)ください。

## Next.js + Server Actions = ほぼフルスタックフレームワーク

Next.js 13.4でアルファリリースされ、14.0でstableになった[Server Actions](https://nextjs.org/docs/app/api-reference/functions/server-actions)。
リリース当初から気になっていて触ってみてたのですが、Server Componentから`await`でデータが取れるのが快適すぎて、もう全部これでいいんじゃないかとすら思っています。

## オンライン開発環境

オンライン開発環境を提供するサービスは、[GitHub Codespace](https://github.co.jp/features/codespaces)、[CodeSandbox](https://codesandbox.io/)をはじめとして幾つかありますが、UXがよくて個人的に気に入っているのが[Stackblitz](https://stackblitz.com/)です。

## Stackblitzのいいところ
- 同社が開発している[WebContainer](https://webcontainers.io/)により、ブラウザ上でNode.jsが動作します。
  - エディタがMonacoなのでモバイルは厳しそう(未確認)ですが、ブラウザが動作すればどこでもWebアプリの開発ができます。
  - GitHub CodespaceやCodeSandboxのcloud sandboxはmicroVMのスペックが操作感に直結しますが、Stackblitzは手元のマシンで動くので、ある程度スペックの高いPCだとこっちのほうがサクサクです。
- GitHubリポジトリを開くこともできるし、Stackblitz上で作ったプロジェクトをGitHubに持っていくこともできる
- 2023年11月現在、有料プランは主にチーム開発に関連する機能であり、一人で使う分にはFreeでほぼ困りません。
  - Freeでもプライベートプロジェクトが作れます。
  - 色々御託を並べましたが、結局これが一番の理由です。
  - プランの詳細は公式の[Pricing](https://stackblitz.com/pricing)でご確認ください。

## Stackblitzで全部済ませたい
- Stackblitz内では謎の技術(未調査)により、[SQLite](https://blog.stackblitz.com/posts/introducing-sqlite3-webcontainers-support/)を使うことができます。
- Next.js - Server Actions - ORM - SQLiteとすることで、ローカルでの開発とそん色ない開発体験が実現できるのでは？

## ところが
- Stackblitzには、その特性上やむを得ないことですが、[Wasmでないネイティブアドオンを使用できないという大きな制約](https://developer.stackblitz.com/platform/webcontainers/troubleshooting-webcontainers#cannot-load-native-addon)があります。

- これが厄介で、雑にNext.jsを14.0にアップグレードしたところ、動きませんでした。
  - 今回はスタータープロジェクトにあるNext.js 13.5.1のままで進めました。
    - これは多分そのうち解消されると思いますが…。
- そして、[Prisma](https://www.prisma.io/)も動きません。
  - 公式には「[Sequelize](https://sequelize.org/)なら動く」みたいなことが書いてますが、今回軽く試した限りでは、Sequelizeはちょっと冗長すぎると感じました。
      - 一回Prisma体験しちゃうとしんどいなぁという印象です。
  - しかし、かといって今更ORM無しでやるのはもっとしんどい。

## Drizzle ORM

https://github.com/drizzle-team/drizzle-orm

- そこで、白羽の矢が立ったのが[Drizzle ORM](https://orm.drizzle.team/)です。
  - `If you know SQL — you know Drizzle.`とのことで、SQLっぽい書き方ができます。

```typescript
export default async function getTasks() {
    const result = await db.select().from(tasks).where(isNull(tasks.deletedAt));
    return result;
}
```
- メソッドチェーンで書くので今っぽくはないかもしれませんが、個人的にはPrismaよりも直感的で気に入りました。
  - 私はSQLを直で書くことが多かったのと、C#でLINQを通ってきたからというのもありそうです。
- 試していませんが、`not SQL-like`な書き方もできるとのこと。

## better-sqlite3で接続（失敗）
https://github.com/WiseLibs/better-sqlite3

- [公式ドキュメント](https://orm.drizzle.team/docs/quick-sqlite/better-sqlite3)に [BetterSqlite3](https://github.com/WiseLibs/better-sqlite3)でSQLiteに接続する方法が書いてあるので試してみました。
- ここで例の制約が牙を剥きます。
  - BetterSqlite3は速さを一つの売りにしていますが、おそらくはそのために大半がC++で書かれています。
    - 2023年11月現在 Wasmビルドは提供されておらず、動作しませんでした。

## HTTP proxyで接続（成功）
- 公式にはもう一つ、[HTTP Proxy](https://orm.drizzle.team/docs/quick-sqlite/http-proxy)での接続方法が書いてあります。
- HTTP Proxyと言いつつ、単なるコールバック関数なので、これでいけそうです。
  - ちなみに、コールバック関数の型は以下の通り

```typescript
export type AsyncRemoteCallback = (sql: string, params: any[], method: 'run' | 'all' | 'values' | 'get') => Promise<{
    rows: any[];
}>;
```
- ただ、実装時にハマったのですが、実際には`{rows: any[]}`ではなく、`method`が`'all' | 'values'`のときは`{rows: string[][]}`、`'get'`のときは`{rows: string[]}`で返さないといけません。

- コールバック関数内ではちゃんと取れてるように見えるのに、
```typescript
{[{
    id: 1,
    createdAt: "2023-11-09 23:59:59",
    updatedAt: "2023-11-09 23:59:59",
    deletedAt: null,
    name: "買い物",
    description: "牛乳を買う"
}, ...]}
```

- 返ってくるとこうなってて、**なんでやねん**👋って思ってました。
```typescript
{[{
  id: undefined,
  createdAt: undefined,
  updatedAt: undefined,
  deletedAt: undefined,
  name: undefined,
  description: undefined
}, ...]}
```

- ナンデ？と思いながらリポジトリを彷徨っていたら[examples](https://github.com/drizzle-team/drizzle-orm/blob/main/examples/sqlite-proxy/README.md)に以下の記述を見つけました。


> Warning: You will be responsible for proper error handling in this part. Drizzle always waits for {rows: string[][]} so if any error was on http call(or any other call) - be sure, that you return at least empty array back
> 
> For get method you should return {rows: string[]}

- **それ！ドキュメントに！書いといてくれないかなぁ！？！？** と心の底から思ったのですが、[こんなプルリク](https://github.com/drizzle-team/drizzle-orm/pull/1446)も出てるので、今だけかもしれません。

## 完成品

https://stackblitz.com/github.com/kedama-t/fullstack-development-on-stackblitz

- 完成品と言いつつ、Next.jsのバージョンを含めて不安定な部分があるので、適宜直していく所存です。
    - なお、このリポジトリはバイナリファイルを扱う都合上、Stackblitzにログインした状態で開かないと、Drizzleのマイグレーションで止まります。

- フォルダ構成は以下にしています。この辺は個々人々で好みがあると思いますが。
```
./data　…　dev.sqliteの配置先。dev.sqliteはgit管理から外してありますが、お好みで。
./drizzle … Drizzle ORMが吐くマイグレーションSQLの配置先。ここもgitからは外してあります。
./public … Next.jsの静的アセット置き場
./src
  /actions … Server Actionsを置くところ
  /app … Next.jsのApp Router
  /components … コンポーネントを置くところ
  /db … SQLiteへの接続スクリプトや、Drizzleで使うマイグレーション、スキーマ定義、シーダーを置くところ
```

- ワークスペースの起動時に`npm install`のほか、Drizzleの`generate`、マイグレーション用スクリプト、シード用スクリプトが流れるようになっています。

- サンプルアプリはよくあるTODOリストですが、フロントで完結するのではなく、SQLiteを読み書きしています。
  - `submit`で発生するリロードで画面を書き換えてます。
  - めんどくさかったので`Edit`ボタンの一連の挙動が若干エキセントリックになっていますが、ご容赦ください。

- ORMにDrizzleを使っているので、後々データベースを差し替える必要が出てきても、まずまず軽微な修正でいけると思います。
  - 具体的には、`./src/db/db.ts`と`./src/db/schema.ts`のデータ型を気にしておけば、ほぼ問題ないはずです。

## 終わりに
- 「よい開発はよい道具から」と常々思っているのですが、初手の道具としては満足できる環境ができたと、個人的には思っています。
- Drizzle ORMのリポジトリ内の記述によると、[node-sqlite3への対応予定がありそう](https://github.com/drizzle-team/drizzle-orm/tree/main/drizzle-orm/src/sqlite-core)なので、将来的にはもっと簡単に使えるようになりそうです。
- 自分なりの **マイベストStackblitz環境** を作っている方がいらっしゃいましたら、参考にしたいのでぜひ教えてほしいです。

## Appendix:実装や依存関係の解説
- **どこの馬の骨かわからん奴が作ったテンプレートを使うのは抵抗がある** という向きもあると思いますし、自分が数か月後に **なんでコイツこんなことやってんの** と思う可能性も十分あるので、以下に2023年11月時点の記憶として、ポイントになりそうな部分の説明を残しておきます。
    - 規模の限界はありますが、個人が何か作るときに「自分が弄るところに自分の知らんコードがない」ことって案外大事だと思ってます。

### package.json
- Stackblitzのスタートアップコマンドを追加しています。
```json
// package.json
...
  "stackblitz": {
    "startCommand": "npm run db && npm run dev"
  },
...
```
- `npm run db`については後述。

### devDependencies

- 便利ツール枠として、[ts-node](https://github.com/TypeStrong/ts-node)、[npm-run-all](https://github.com/mysticatea/npm-run-all)、[eslint-plugin-tailwindcss](https://github.com/francoismassart/eslint-plugin-tailwindcss)が入っています。


#### ts-node

- Drizzleのマイグレーションスクリプトとシード用スクリプトをTypeScriptで書くために入れてます。

```json
// package.json
...
    "db:migrate": "ts-node --esm -O '{\"module\": \"commonjs\"}' ./src/db/migrate.ts",
    "db:seed": "ts-node --esm -O '{\"module\": \"commonjs\"}' ./src/db/seed.ts",
...
```

#### npm-run-all

- これは別に無くてもいいのですが、データベース関連の`scripts`をまとめて流すとき便利なので入れてます。
  - 当初は[express](https://expressjs.com/ja/)経由でSQLiteとデータをやり取りしようと考えていて、サーバー同時起動のために入れてました。

```json
// package.json
...
    "db": "run-s db:*",
    "db:generate": "drizzle-kit generate:sqlite",
    "db:migrate": "ts-node --esm -O '{\"module\": \"commonjs\"}' ./src/db/migrate.ts",
    "db:seed": "ts-node --esm -O '{\"module\": \"commonjs\"}' ./src/db/seed.ts",
...
```

`npm run db`で、`db:generate`、`db:migrate`、`db:seed`が順番に流れます。

#### eslint-plugin-tailwindcss
- [TailwindCSS](https://tailwindcss.com/)を使うにあたっての、紳士淑女のたしなみ。

### srcフォルダ
- `app`、`components`、`actions`は特別なことはしていないので割愛。
  - `actions`のファイル構成は検討の余地ありですが、どうせ書き換える部分なのでおいておきます。

#### src/db/db.ts

- これはIssueにも挙がってたのでそのうち改善されると思いますが、DrizzleはSQLiteのマイグレーション時、SQLiteでは対応していない`SERIAL`をデータ型に指定しています。
- このため、そのままだと`id`が全部nullになります。
  - **えっ、入るんだ** と思いましたが、試したら確かに複数レコードがid=nullで入ってました。

```typescript
// https://github.com/drizzle-team/drizzle-orm/issues/1227
const after = `
CREATE TABLE IF NOT EXISTS "__drizzle_migrations" (
    id integer PRIMARY KEY AUTOINCREMENT NOT NULL,
    hash text NOT NULL,
    created_at numeric
)`;
const before = '\n' +
    '\t\tCREATE TABLE IF NOT EXISTS "__drizzle_migrations" (\n' +
    '\t\t\tid SERIAL PRIMARY KEY,\n' +
    '\t\t\thash text NOT NULL,\n' +
    '\t\t\tcreated_at numeric\n' +
    '\t\t)\n' +
    '\t';

const remoteDataBase = drizzle(async (sql, params, method) => {
  const sqlProcessed = sql === before ? after : sql;
  ...
```

- ちゃんと`id`が入らないのは気持ち悪いので、少々行儀が悪いですが、強引にトラップしてSQLを差し替えています。

```typescript
const database = new sqlite3.Database("./data/dev.sqlite");
const remoteDataBase = drizzle(async (sql, params, method) => {
    const sqlProcessed = sql === before ? after : sql;
    console.log({ sql, params, method })
    try {
        const operation = method === 'run' ? run(database, sqlProcessed, params)
            : method === 'all' || method === 'values' ? all(database, sqlProcessed, params)
                : method === 'get' ? get(database, sqlProcessed, params)
                    : null;
        if (!operation) { throw new Error(`Method '${method}' is not implemented.`) }

        const result = await operation;
        return { rows: result as any[] };
    } catch (e: any) {
        console.error('An error has occured: ', e)
        return { rows: [] };
    }
});

export default remoteDataBase;
```

- `sql`にはプレースホルダ付きのSQL文、`params`にはパラメタが配列で来ます。`method`はSQL文によって`run`、`all`、`values`、`get`が来ます。

- 全部は書きませんが、`run`、`all`、`get`の3メソッドを、大体以下のような形で実装しています。
  - `sqlite3`の各メソッドはPromisableではないので、Promiseでラップして返します。

```typescript
const all = (db: sqlite3.Database, sql: string, ...params: any[]) => {
    return new Promise((resolve, reject) => {
        db.all(sql, ...params, function (this: sqlite3.Statement, err: Error, rows: any[]) {
            if (err) {
                return reject(err);
            }
            // convert object to value[][]
            const result = convertObjectToArray(rows).map((row) => convertObjectToArray(row))
            resolve(result);
        });
    });
}

const convertObjectToArray = (object: object) => {
    return Object.entries(object).map(([key, value]) => (value))
}
```

- コールバック関数で受けてる`this: sqlite3.Statement`は使ってません。
  - 最初アロー関数式で書いてたんですが、`this`が受けられないからか、関数式じゃないと動かないようです。ずいぶんハマりました。

- `convertObjectToArray`は、`{rows: string[][]}`か`{rows: string[]}`で返さなきゃいけないのに対応するため、objectを投げるとvalueの配列を返すようになっています。

#### src/db/migrate.ts

```typescript
import { sql } from "drizzle-orm";
import db from "./db";
import { migrate } from 'drizzle-orm/sqlite-proxy/migrator';

migrate(db, async (migrationQueries: string[]) => {
    for (const query of migrationQueries) {
        await db.run(sql.raw(query));
    }
}, { migrationsFolder: './drizzle' }).catch((error: Error) => {
    console.log("An error has occurred on migration.")
    console.log({ error })
}).then(() => {
    console.log("Migration is completed.")
});
```

- Drizzleのmigrate関数を呼ぶだけですが、`sqlite-proxy`の場合、`migrationQueries`を手動で流してあげる必要があります。
  - `for ... of`構文を使ってるのは、順番に流してるアピールです。 
    - 今回の環境だと、ワークスペースの起動のたびにsqlが一個できるだけなので、あまり意味はないですが…。

### src/db/schema.ts
```typescript
import {
  sqliteTable,
  text,
  integer
} from 'drizzle-orm/sqlite-core';
import { sql } from 'drizzle-orm';

const id = {
  id: integer('id', { mode: 'number' }).primaryKey({ autoIncrement: true }),
};
const timestamps = {
  createdAt: text('createdAt').default(sql`CURRENT_TIMESTAMP`),
  updatedAt: text('updatedAt').default(sql`CURRENT_TIMESTAMP`),
  deletedAt: text('deletedAt'),
};
const schemaBase = {
  ...id,
  ...timestamps,
};

export const tasks = sqliteTable('tasks', {
  ...schemaBase,
  name: text('name'),
  description: text('description'),
});
```

- スキーマ定義ですが、がっつりTypeScriptなのがめっちゃお気に入りです。
  - どのテーブルでも使う項目をあらかじめ定義しておいて、スプレッド構文で突っ込めるのがよいです。
  - カラムの順番を気にする場合は注意が必要です。

### src/db/seed.ts
```typescript
import db from "./db";
import { tasks } from "./schema";

(async () => {
    await db.insert(tasks).values([
        { id: 1, name: "買い物", description: "牛乳を買う" },
        { id: 2, name: "ジム", description: "筋トレ３０分、有酸素３０分" },
        { id: 3, name: "読書", description: "リーダブルコード続き" },
    ]);
})();
```

- シーディングはDrizzleに備え付けの機能ではなく、このスクリプトはinsertメソッドを使ってデータを突っ込んでいるだけです。
  - `ts-node`で実行するのですが、Top-level Awaitと`{"module":"commonjs"}`が干渉するので、関数の即時実行で書いてます。