09
📈 成長編 Chapter 09

データの住処

データベースでデータを永続化する

約55分
Database Concepts · intro SQL · intro Data Modeling · intro
目次(25セクション)
🎬 Story — Introduction

月曜の朝。Mikaから緊急の電話がかかってきた。


Mika: 「予約データが全部消えてるんです!週末に入った予約が全部なくなってて…」

あなた: 「えっ?ちょっと確認します…」

サーバーのログを確認すると、日曜日の深夜にサーバーが自動再起動された記録があった。

あなた: 「あ…サーバー再起動すると、メモリ上のデータ全部消えるんだった。」

あなた: 「Yukiさん、これどうすれば…」

Yuki: 「これは想定内の問題だよ。今のAPIは、データをメモリ(サーバーのRAM)に一時的に保持してるだけ。サーバーが止まれば消える。データを”永続化”するには、データベースが必要なんだ。」


Yuki: 「データベースは、データの”住処”。ファイルシステムに安全にデータを保存し、高速に検索し、複数のユーザーが同時にアクセスしても壊れないように管理してくれる。」

あなた: 「データベースって、Excelみたいなもの?」

Yuki: 「表形式でデータを管理する点は似てるけど、規模もパワーも桁違い。何百万行のデータを一瞬で検索できるし、同時アクセスにも耐えられる。まずはSQLという言語から学ぼう。」

あなたはMikaに「必ず対策します」と約束した。二度とデータが消えないシステムを構築するために。

なぜデータベースが必要か

ShimaLinkの予約データが消えた原因は、データの保存方法にあった。メモリ上のデータは揮発性——電源が切れれば消える。

「メモリはホワイトボード。電源を切ったら消える。データベースはノート。書いたら残る。」——Yuki

データ保存方法の比較

方法永続性検索速度同時アクセス整合性
メモリ(変数)なし最速不可なし
JSONファイルあり遅い危険なし
データベースあり高速安全あり

データベースが解決する問題

1. 永続化(Persistence)

サーバー再起動 → メモリのデータ → 消失!
サーバー再起動 → データベース → そのまま残る

2. 高速検索

JSONファイル: ファイル全体を読んでからフィルタリング
データベース: インデックスを使って瞬時に検索

3. 同時アクセス

JSONファイル: 2人が同時に書き込み → データ破壊
データベース: トランザクションで安全に処理

4. データの整合性

JSONファイル: 不正なデータも書き込める
データベース: 制約(NOT NULL, UNIQUE等)で不正データを防ぐ

データベースの種類

リレーショナルデータベース(RDBMS)

表形式(テーブル)でデータを管理。SQLで操作する。

名前特徴用途
PostgreSQL高機能、信頼性本番環境
MySQL普及率が高いWeb開発全般
SQLiteファイルベース、軽量小規模アプリ、学習

NoSQLデータベース

テーブル以外の形式でデータを管理。

名前形式用途
MongoDBドキュメント(JSON風)柔軟なスキーマ
Redisキーバリューキャッシュ
Firebaseドキュメントリアルタイムアプリ

ShimaLinkではどれを使う?

ShimaLinkの要件:
- クライアント、予約、ユーザーの管理 → 構造化データ
- データ間の関連(クライアント↔予約)→ リレーション
- 信頼性が必要 → トランザクション

→ リレーショナルデータベース(SQLite → 将来はPostgreSQL)

ポイント: まずはSQLiteで学習。仕組みは同じなので、本番環境ではPostgreSQLに移行しやすい。SQLiteはインストール不要で、1つのファイルにデータベース全体が入ります。

SQL基礎 — SELECT / INSERT / UPDATE

SQL(Structured Query Language)は、データベースを操作するための言語だ。「何が欲しいか」を宣言的に記述する。

「SQLは”注文書”みたいなもの。どんなデータが欲しいかを書くだけで、データベースが探してきてくれる。」——Yuki

テーブルの作成(CREATE TABLE)

まず、ShimaLinkのクライアントテーブルを作ろう。

CREATE TABLE clients (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  name TEXT NOT NULL,
  owner TEXT NOT NULL,
  category TEXT NOT NULL,
  monthly_visits INTEGER DEFAULT 0,
  is_active BOOLEAN DEFAULT true,
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

よく使うデータ型

SQL型説明
INTEGER整数42
TEXT文字列'海風テラス'
REAL浮動小数点3.14
BOOLEAN真偽値true / false
DATETIME日時'2024-12-01 10:30:00'

よく使う制約

制約説明
PRIMARY KEY主キー(一意の識別子)
NOT NULLNULLを許可しない
UNIQUE重複を許可しない
DEFAULTデフォルト値を設定
AUTOINCREMENT自動採番

SELECT — データの取得

-- 全クライアントを取得
SELECT * FROM clients;

-- 特定のカラムだけ取得
SELECT name, category, monthly_visits FROM clients;

-- 条件を指定(WHERE)
SELECT * FROM clients WHERE category = 'カフェ';

-- 複数条件(AND / OR)
SELECT * FROM clients
WHERE is_active = true AND monthly_visits > 500;

-- 並び替え(ORDER BY)
SELECT * FROM clients
ORDER BY monthly_visits DESC;

-- 件数制限(LIMIT)
SELECT * FROM clients
ORDER BY monthly_visits DESC
LIMIT 5;

よく使うSELECT構文

-- 件数を数える
SELECT COUNT(*) FROM clients WHERE is_active = true;

-- 合計値
SELECT SUM(monthly_visits) FROM clients;

-- 平均値
SELECT AVG(monthly_visits) FROM clients;

-- カテゴリごとの集計
SELECT category, COUNT(*) as count, AVG(monthly_visits) as avg_visits
FROM clients
GROUP BY category;

INSERT — データの追加

-- 1件追加
INSERT INTO clients (name, owner, category)
VALUES ('海風テラス', 'Mika', 'カフェ');

-- 複数件追加
INSERT INTO clients (name, owner, category) VALUES
  ('首里そば太郎', '太郎', '飲食'),
  ('美ら花フラワー', '花子', '花屋');

UPDATE — データの更新

-- 特定のクライアントを更新
UPDATE clients
SET monthly_visits = 1500, is_active = true
WHERE id = 1;

-- WHEREなしは全件更新(危険!)
UPDATE clients SET is_active = false;
-- → 全クライアントが非アクティブに!

警告: UPDATE と DELETE は必ず WHERE をつけること。WHERE なしだと全件に適用されます。

DELETE — データの削除

-- 特定のクライアントを削除
DELETE FROM clients WHERE id = 3;

-- 条件付き削除
DELETE FROM clients WHERE is_active = false;

実行順序

SQLは書いた順番とは異なる順序で実行されます。

書く順: SELECT → FROM → WHERE → GROUP BY → ORDER BY → LIMIT
実行順: FROM → WHERE → GROUP BY → SELECT → ORDER BY → LIMIT

ポイント: まずは SELECT, INSERT, UPDATE, DELETE の4つを覚えれば、基本的なデータ操作はすべてできます。WHERE句は特に重要です。

データモデリングとリレーション

ShimaLinkには「クライアント」と「予約」という2つのデータがある。これらには関連がある——予約は必ず特定のクライアントに紐づく。この関連をリレーションと呼ぶ。

「データモデリングは設計図を描くこと。建物を建てる前に設計するように、コードを書く前にデータの構造を考える。」——Yuki

テーブル間の関係

1対多(One-to-Many)

1つのクライアントは複数の予約を持てるが、1つの予約は1つのクライアントにだけ属する。

clients テーブル          reservations テーブル
┌────┬──────────┐       ┌────┬───────────┬────────────┐
│ id │ name     │       │ id │ client_id │ guest_name │
├────┼──────────┤       ├────┼───────────┼────────────┤
│ 1  │ 海風テラス│  ←──  │ 1  │ 1         │ 田中様     │
│    │          │  ←──  │ 2  │ 1         │ 山田様     │
│ 2  │ 首里そば │  ←──  │ 3  │ 2         │ 佐藤様     │
└────┴──────────┘       └────┴───────────┴────────────┘

外部キー(Foreign Key)

テーブル間の関連を定義するのが外部キーです。

CREATE TABLE reservations (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  client_id INTEGER NOT NULL,
  guest_name TEXT NOT NULL,
  date TEXT NOT NULL,
  party_size INTEGER DEFAULT 1,
  status TEXT DEFAULT 'pending',
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  FOREIGN KEY (client_id) REFERENCES clients(id)
);

FOREIGN KEY により、存在しないクライアントIDの予約は作成できなくなります。

ShimaLinkのデータモデル

┌─────────────┐     ┌──────────────┐     ┌─────────────┐
│   clients   │     │ reservations │     │   users     │
├─────────────┤     ├──────────────┤     ├─────────────┤
│ id (PK)     │──┐  │ id (PK)      │     │ id (PK)     │
│ name        │  └─>│ client_id(FK)│     │ email       │
│ owner       │     │ guest_name   │     │ password    │
│ category    │     │ date         │     │ role        │
│ visits      │     │ party_size   │     │ created_at  │
│ is_active   │     │ status       │     └─────────────┘
│ created_at  │     │ created_at   │
└─────────────┘     └──────────────┘

JOIN — テーブルを結合する

リレーションを使ってデータを取得するのがJOINです。

-- クライアント名と一緒に予約情報を取得
SELECT
  reservations.id,
  clients.name AS client_name,
  reservations.guest_name,
  reservations.date,
  reservations.party_size
FROM reservations
INNER JOIN clients ON reservations.client_id = clients.id
WHERE reservations.date >= '2024-12-01'
ORDER BY reservations.date;

JOINの種類

種類説明
INNER JOIN両方に存在するデータのみ
LEFT JOIN左テーブルの全データ + 右の一致データ
RIGHT JOIN右テーブルの全データ + 左の一致データ
-- LEFT JOIN: 予約がないクライアントも含めて表示
SELECT
  clients.name,
  COUNT(reservations.id) AS reservation_count
FROM clients
LEFT JOIN reservations ON clients.id = reservations.client_id
GROUP BY clients.id;

正規化の基本

データの重複を避けるために、テーブルを適切に分割することを正規化と言います。

-- 悪い例: 予約テーブルにクライアント名を直接入れる
CREATE TABLE reservations_bad (
  id INTEGER PRIMARY KEY,
  client_name TEXT,      -- 名前が変わったら全部更新が必要!
  client_category TEXT,  -- 重複データ
  guest_name TEXT,
  date TEXT
);

-- 良い例: クライアントIDで参照する
CREATE TABLE reservations_good (
  id INTEGER PRIMARY KEY,
  client_id INTEGER,     -- IDで参照(名前変更の影響なし)
  guest_name TEXT,
  date TEXT,
  FOREIGN KEY (client_id) REFERENCES clients(id)
);

ポイント: データモデリングは「何を保存するか」と「データ同士がどう関係するか」を考えること。適切な設計は、将来の変更やバグ防止に大きく貢献します。

データベースをアプリに接続する

SQLの基礎を学んだ。次は、ShimaLinkのAPIとデータベースを接続して、データを永続化しよう。

「APIがウェイターなら、データベースはキッチンの冷蔵庫。ウェイターが冷蔵庫から材料を取り出して、お客さんに料理を届ける。」——Yuki

接続の全体像

ブラウザ

   │ fetch("/api/clients")

APIサーバー(Node.js)

   │ SQL クエリ

データベース(SQLite)

   │ 結果を返す

APIサーバー

   │ JSONレスポンス

ブラウザ

SQLiteの導入

# better-sqlite3 をインストール(Node.js用)
npm install better-sqlite3
npm install -D @types/better-sqlite3

データベース接続の基本

// db.ts — データベース接続
import Database from "better-sqlite3";

// データベースファイルを作成(なければ自動生成)
const db = new Database("shimalink.db");

// WALモード(パフォーマンス向上)
db.pragma("journal_mode = WAL");

export default db;

テーブルの初期化

// init-db.ts — テーブル作成
import db from "./db";

db.exec(`
  CREATE TABLE IF NOT EXISTS clients (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    name TEXT NOT NULL,
    owner TEXT NOT NULL,
    category TEXT NOT NULL,
    monthly_visits INTEGER DEFAULT 0,
    is_active BOOLEAN DEFAULT 1,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP
  );

  CREATE TABLE IF NOT EXISTS reservations (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    client_id INTEGER NOT NULL,
    guest_name TEXT NOT NULL,
    date TEXT NOT NULL,
    party_size INTEGER DEFAULT 1,
    status TEXT DEFAULT 'pending',
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (client_id) REFERENCES clients(id)
  );
`);

console.log("データベースの初期化が完了しました");

APIとの連携

メモリ上の配列をデータベースに置き換えます。

// Before: メモリ上の配列(サーバー再起動で消える)
let clients: Client[] = [];

// After: データベースから取得(永続化)
function getClients(): Client[] {
  const stmt = db.prepare("SELECT * FROM clients WHERE is_active = 1");
  return stmt.all() as Client[];
}

CRUD操作の実装

// READ: 全クライアント取得
function getAllClients(): Client[] {
  const stmt = db.prepare(
    "SELECT * FROM clients ORDER BY created_at DESC"
  );
  return stmt.all() as Client[];
}

// READ: IDで1件取得
function getClientById(id: number): Client | undefined {
  const stmt = db.prepare("SELECT * FROM clients WHERE id = ?");
  return stmt.get(id) as Client | undefined;
}

// CREATE: 新規作成
function createClient(
  name: string,
  owner: string,
  category: string
): Client {
  const stmt = db.prepare(
    "INSERT INTO clients (name, owner, category) VALUES (?, ?, ?)"
  );
  const result = stmt.run(name, owner, category);

  return getClientById(result.lastInsertRowid as number)!;
}

// UPDATE: 更新
function updateClient(
  id: number,
  updates: Partial<Client>
): Client | undefined {
  const fields = Object.keys(updates)
    .map(key => `${key} = ?`)
    .join(", ");
  const values = Object.values(updates);

  const stmt = db.prepare(
    `UPDATE clients SET ${fields} WHERE id = ?`
  );
  stmt.run(...values, id);

  return getClientById(id);
}

// DELETE: 削除
function deleteClient(id: number): boolean {
  const stmt = db.prepare("DELETE FROM clients WHERE id = ?");
  const result = stmt.run(id);
  return result.changes > 0;
}

プレースホルダー(?)の重要性

// 危険! SQLインジェクション攻撃の対象
const name = "'; DROP TABLE clients; --";
db.prepare(`SELECT * FROM clients WHERE name = '${name}'`);
// → テーブルが削除される!

// 安全! プレースホルダーを使う
db.prepare("SELECT * FROM clients WHERE name = ?").get(name);
// → 文字列として安全に処理される

警告: ユーザーの入力をSQLに直接埋め込んではいけません。必ずプレースホルダー(?)を使いましょう。これはSQLインジェクションと呼ばれる攻撃を防ぎます。

トランザクション

複数の操作をまとめて実行し、一つでも失敗したら全部取り消す仕組みです。

// 複数のINSERTをトランザクションで実行
const insertMany = db.transaction((clients: NewClient[]) => {
  const stmt = db.prepare(
    "INSERT INTO clients (name, owner, category) VALUES (?, ?, ?)"
  );

  for (const client of clients) {
    stmt.run(client.name, client.owner, client.category);
  }
});

// 全部成功するか、全部失敗するか
insertMany([
  { name: "店A", owner: "太郎", category: "飲食" },
  { name: "店B", owner: "花子", category: "カフェ" },
]);

ポイント: データベース接続は「メモリの配列をSQLクエリに置き換える」だけ。API側のインターフェースは変わらないので、フロントエンドのコードは修正不要です。

📖 Story — Conclusion

データベースの導入が完了した。サーバーを再起動しても、データはしっかりとデータベースに残っている。Mikaに確認のメッセージを送った。

SELECT name, date, party_size FROM reservations
WHERE client_id = 1 AND date >= '2024-12-01'
ORDER BY date;
| name       | date       | party_size |
|------------|------------|------------|
| 田中様     | 2024-12-05 | 4          |
| 山田様     | 2024-12-08 | 2          |
| 佐藤様     | 2024-12-12 | 6          |

Mika: 「本当だ、予約データがちゃんと残ってる!サーバー再起動しても大丈夫なんですね?」

あなた: 「はい。データベースに保存されているので、サーバーが止まっても消えません。」

Mika: 「ありがとうございます!安心しました。ところで、お客さんから”予約状況をネットで確認したい”って声があるんですけど…」

あなた: 「それ、ユーザーごとにログインが必要になるよね。」

Yuki: 「その通り。今のShimaLinkには大きな穴がある。」

Yukiがブラウザを開き、ダッシュボードのURLを直接入力した。

Yuki: 「ほら、URLを知っていれば誰でもアクセスできる。クライアントの売上データも、予約情報も、全部丸見えだ。」

あなた: 「…それはまずいですね。」

Yuki: 「認証——つまり”誰がアクセスしているか”を確認する仕組みが必要だ。次はそこに取り組もう。」

ちょうどその時、あなたのモニターにアクセスログの異常な数字が映っていた。見知らぬIPアドレスから、大量のリクエストが送られている——。


次のチャプター: Chapter 10: 誰がアクセスしているか — ダッシュボードに認証機能を追加。そして、謎のアクセスの正体とは…?

🧠 理解度チェック

Q1.メモリ上の変数にデータを保存する場合の最大の問題点は?

💡 Mikaの予約データが日曜夜のサーバー再起動で消えてしまった事件を思い出そう。

Q2.SQLでデータを取得するために使うキーワードは?

💡 ShimaLinkのクライアント一覧を取得するとき、SELECT * FROM clientsと書いたのを覚えてる?

Q3.外部キー(FOREIGN KEY)の役割は?

💡 予約テーブルのclient_idがクライアントテーブルのidを参照している——この関連が外部キーだ。

Q4.WHERE句なしでDELETE文を実行するとどうなる?

💡 Yukiが「UPDATEとDELETEのWHERE句忘れは最も危険なミスの一つ」と警告していたね。

Q5.SQLインジェクション攻撃を防ぐ方法は?

💡 ユーザーの入力に「'; DROP TABLE clients; --」が含まれていたら、テーブルが丸ごと削除されてしまう!

Q6.INNER JOINの結果に含まれるのは?

💡 クライアント情報と予約情報を結合して表示するとき、予約のあるクライアントだけが表示された。

Q7.PRIMARY KEYの特徴として正しいのは?

💡 クライアントテーブルのidカラムがPRIMARY KEY。各クライアントを一意に識別するIDだ。

よくある質問

SQLiteとPostgreSQLの違いは?

**SQLite**: ファイルベースの軽量DB。サーバー不要。学習や小規模プロジェクトに最適。 **PostgreSQL**: サーバー型の本格DB。大規模・高負荷に対応。 | 特徴 | SQLite | PostgreSQL | |------|--------|------------| | セットアップ | 不要 | サーバー構築が必要 | | 同時アクセス | 制限あり | 高い同時実行性 | | データ量 | 小~中規模 | 大規模対応 | | 用途 | 開発・学習 | 本番環境 | **ShimaLinkの戦略**: 開発中はSQLite、本番ではPostgreSQLに移行。SQLの文法はほぼ同じなので移行は簡単です。

「SQLITE_ERROR: no such table」エラーが出る

テーブルがまだ作成されていません。 **対処法**: 1. **テーブル作成スクリプトを実行する** ```bash node init-db.js ``` 2. **CREATE TABLE IF NOT EXISTS を使う** ```sql CREATE TABLE IF NOT EXISTS clients ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL ); ``` 3. **データベースファイルの場所を確認** ```javascript const db = new Database("shimalink.db"); // 実行ディレクトリに shimalink.db が作られる ``` `IF NOT EXISTS` をつけると、テーブルが既にある場合はスキップされます。

SELECTで結果が0件になる(データがあるはずなのに)

**WHERE句の条件を確認**しましょう。 **よくある原因**: 1. **文字列の大小文字** ```sql -- SQLiteは大小文字を区別する SELECT * FROM clients WHERE category = 'カフェ'; -- 'Cafe' や 'CAFE' では一致しない ``` 2. **型の不一致** ```sql -- is_activeがINTEGERの場合 WHERE is_active = 'true' -- NG(文字列) WHERE is_active = 1 -- OK(数値) ``` 3. **NULLの比較** ```sql -- NULLは = で比較できない WHERE phone = NULL -- NG(常にfalse) WHERE phone IS NULL -- OK ``` **デバッグ**: まず `SELECT * FROM clients;` で全件表示し、データの状態を確認しましょう。

JOINが分からない。いつ使うの?

**2つ以上のテーブルのデータを組み合わせて取得したい**ときに使います。 **例**: 予約情報にクライアント名を含めたい ```sql -- JOINなし: client_id だけ表示される SELECT * FROM reservations; -- | id | client_id | guest_name | -- | 1 | 1 | 田中様 | -- JOINあり: クライアント名も表示 SELECT r.*, c.name AS client_name FROM reservations r INNER JOIN clients c ON r.client_id = c.id; -- | id | client_id | guest_name | client_name | -- | 1 | 1 | 田中様 | 海風テラス | ``` **イメージ**: 2枚のExcelシートを、共通のIDで横に結合するイメージです。

AUTO_INCREMENTとは何?

**自動で連番を振ってくれる**機能です。 ```sql CREATE TABLE clients ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL ); INSERT INTO clients (name) VALUES ('海風テラス'); -- id は自動的に 1 になる INSERT INTO clients (name) VALUES ('首里そば太郎'); -- id は自動的に 2 になる ``` **注意点**: - idを手動で指定する必要がない - 削除された番号は再利用されない(1, 2, 3 の2を削除しても、次は4) - SQLiteでは `INTEGER PRIMARY KEY` だけでも自動採番されるが、`AUTOINCREMENT` をつけるとより厳密に管理される

トランザクションって何?いつ使うの?

**複数のSQL操作をまとめて「全部成功」か「全部取り消し」にする**仕組みです。 **例**: 振込処理(Aから引いてBに足す) ```sql BEGIN TRANSACTION; UPDATE accounts SET balance = balance - 1000 WHERE id = 'A'; UPDATE accounts SET balance = balance + 1000 WHERE id = 'B'; COMMIT; -- 片方だけ実行される事態を防ぐ ``` **失敗した場合**: ```sql BEGIN TRANSACTION; UPDATE accounts SET balance = balance - 1000 WHERE id = 'A'; -- ここでエラー発生! ROLLBACK; -- 最初のUPDATEも取り消される ``` **ShimaLinkでの使いどころ**: 複数のクライアントを一括登録するとき、1件でも失敗したら全部取り消す。

データベースの設計(テーブル分割)の基準は?

**正規化の基本ルール**に従います。 **原則**: 同じデータを2か所以上に保存しない。 ``` 悪い例(非正規化): 予約テーブル | guest | client_name | client_phone | date | → client_nameを何度も書く。名前変更時に全部更新が必要。 良い例(正規化): クライアントテーブル: | id | name | phone | 予約テーブル: | id | client_id | guest | date | → client_idで参照。名前変更は1か所だけ。 ``` **テーブル分割の判断基準**: - そのデータは他のデータから独立して存在できるか? - そのデータが繰り返し出現しないか? - 更新時に複数箇所を変更する必要がないか?

GROUP BYの使い方が分からない

`GROUP BY`は**同じ値を持つ行をまとめて集計**するときに使います。 ```sql -- カテゴリごとのクライアント数を集計 SELECT category, COUNT(*) as count FROM clients GROUP BY category; -- 結果: -- | category | count | -- | カフェ | 3 | -- | 飲食 | 5 | -- | 花屋 | 2 | ``` **集計関数と一緒に使う**: - `COUNT(*)`: 件数 - `SUM(column)`: 合計 - `AVG(column)`: 平均 - `MAX(column)`: 最大値 - `MIN(column)`: 最小値 **HAVING**: GROUP BY後に条件をつける ```sql SELECT category, COUNT(*) as count FROM clients GROUP BY category HAVING count >= 3; -- 3件以上のカテゴリだけ表示 ```

better-sqlite3のインストールでエラーが出る

better-sqlite3はネイティブモジュールなので、ビルドツールが必要です。 **Mac**: ```bash # Xcodeコマンドラインツールをインストール xcode-select --install npm install better-sqlite3 ``` **Windows**: ```bash # windows-build-toolsをインストール npm install --global windows-build-tools npm install better-sqlite3 ``` **それでもダメな場合**: ```bash # Node.jsのバージョンを確認(v18以上推奨) node --version # npm キャッシュをクリアして再試行 npm cache clean --force npm install better-sqlite3 ``` **代替**: ネイティブモジュールが不要な `sql.js`(Wasm版SQLite)も選択肢です。