FirestoreのドキュメントをJSONファイルでエクスポート・インポートする


Firestoreのcloud・emulator環境から、指定したドキュメントを子要素含めて１つのJSONとしてエクスポート・インポートするスクリプトを作成したので共有します。

ライブラリは使わずに、firebase-adminで実装しています。

# 動機
すでに以下のような類似ツールがありますが、整備されてなかったりコミュニティが小さかったりして不安定だったので、自分で整備できるプレーンなスクリプトを作ることにしました。

- firestore-export-import（最終更新が約１年前）
  - https://www.npmjs.com/package/firestore-export-import
- fsrpl (約50 GitHub star）
  - https://github.com/matsu0228/fsrpl/blob/master/README_ja.md


# Usage

## インストール
```bash
npm i
```

## エクスポート
`/version/1`ドキュメントのうち、`Tag`サブコレクションはスキップして、`tmp/data.json`へ保存する例。
```bash
# package.jsonに定義したdev-exportを実行する例（Emulator環境からエクスポート）
npm run dev-export -- --document /version/1 --folder ./tmp --skip Tag
# tsxで直接実行する例（Cloud環境からエクスポート）
npx tsx ei_firestore.ts --mode export --document /version/1 --folder ./tmp --skip Tag
```

## インポート
`tmp/data.json`を読込んで、Firestoreの`/version/dev1`ドキュメントを作成する例。
```
# package.jsonに定義したdev-importを実行する例（Emulator環境からエクスポート）
npm run dev-import -- --document /version/dev1 --folder ./tmp
# tsxで直接実行する例（Cloud環境からエクスポート）
npx tsx ei_firestore.ts --mode import --document /version/dev1 --folder ./tmp
```



# Code

## フォルダ構成
以下のように配置する
```
.
├── package.json
└── ei_firestore.ts
```

## package.json
```json
{
  "type": "module",
  "engines": {
    "node": "20"
  },
  "scripts": {
    "import": "tsx ei_firestore.ts --mode import",
    "export": "tsx ei_firestore.ts --mode export",
    "dev-import": "FIRESTORE_EMULATOR_HOST=127.0.0.1:8080 tsx ei_firestore.ts --mode import",
    "dev-export": "FIRESTORE_EMULATOR_HOST=127.0.0.1:8080 tsx ei_firestore.ts --mode export"
  },
  "dependencies": {
    "firebase": "^10.13.1",
    "firebase-functions": "^6.0.0"
  },
  "devDependencies": {
    "tsx": "^4.19.1",
    "typescript": "^5.6.2",
    "yargs": "^17.7.2"
  }
}
```


## ei_firestore.ts
```typescript
import * as fs from "fs";
import * as path from "path";
import { hideBin } from "yargs/helpers";
import yargs from "yargs/yargs";
import admin from "firebase-admin";

// Parameters ----------------------------------
// Functions triggerによる更新待ち時間(ms)
const WAIT_FOR_FUNCTIONS_TRIGGER_MS = 10;
// Firebase秘密鍵JSONファイルを読み込み認証情報としてセット
const SERVICE_ACCOUNT_PATH = "./secret/code-lets-firebase-adminsdk.json";

// Main ----------------------------------
// メイン関数の実行
async function main() {
  const serviceAccount = JSON.parse(
    fs.readFileSync(SERVICE_ACCOUNT_PATH, "utf8")
  );

  admin.initializeApp({
    credential: admin.credential.cert(serviceAccount),
  });

  // コマンドライン引数の処理
  const {
    mode,
    document: documentPath,
    folder: folderPath,
    skip: skipCollections, // 追加: スキップするコレクション名
  } = yargs(hideBin(process.argv))
    .option("mode", {
      alias: "m",
      description: "モードを指定 (export または import)",
      choices: ["export", "import"],
      demandOption: true,
      type: "string",
    })
    .option("folder", {
      alias: "f",
      description: "データの入出力先フォルダ",
      demandOption: true,
      type: "string",
    })
    .option("document", {
      alias: "d",
      description: "Firestoreのドキュメントのパス",
      type: "string",
      demandOption: true,
    })
    .option("skip", {
      // 追加: スキップするコレクション名
      alias: "s",
      description: "スキップするコレクション名を複数指定",
      type: "array",
      default: [],
    })
    .help()
    .alias("help", "h")
    .parseSync();

  // モードに応じて処理を分岐
  if (mode === "export") {
    await exportData(documentPath, folderPath, skipCollections);
  } else if (mode === "import") {
    await importData(documentPath, folderPath, skipCollections);
  } else {
    console.error(
      "モードが正しくありません。--mode export または --mode import を指定してください。"
    );
    process.exit(1);
  }
}

// データをFirestoreからエクスポートする関数
async function exportData(docPath: string, folderPath: string, skip: string[]) {
  console.log(
    `Firestoreのドキュメントパス "${docPath}" からフォルダ "${folderPath}" へデータをエクスポートします...`
  );
  fs.mkdirSync(folderPath, { recursive: true });

  // ドキュメントとしてエクスポート
  const data = await recursiveExport(docPath, skip);

  if (Object.keys(data).length === 0) return;

  const filePath = path.join(folderPath, "data.json");
  fs.writeFileSync(filePath, JSON.stringify(data, null, 2), "utf8");
  console.log(`データを ${filePath} にエクスポートしました。`);
}

async function recursiveExport(docPath: string, skip: string[]): Promise<any> {
  const segments = docPath.split("/").filter(Boolean);
  const isCollection = segments.length % 2 !== 0;
  const segment = segments[segments.length - 1];

  if (isCollection) {
    if (skip.includes(segment)) return {}; // 追加: スキップ
    const collectionSnap = await admin.firestore().collection(docPath).get();
    if (collectionSnap.empty) {
      console.log(`コレクションが空です: ${docPath}`);
      return {};
    }
    const result: any = {};
    await Promise.all(
      collectionSnap.docs.map(async (doc) => {
        result[doc.id] = await recursiveExport(doc.ref.path, skip);
      })
    );
    return result;
  } else {
    const docSnap = await admin.firestore().doc(docPath).get();
    if (!docSnap.exists) {
      console.error(`ドキュメントが存在しません: ${docPath}`);
      return {};
    }
    // フィールドとコレクションを分離して保存
    const data = { fields: docSnap.data() || {}, collections: {} };
    const collections = await admin.firestore().doc(docPath).listCollections();

    await Promise.all(
      collections.map(async (collection) => {
        if (skip.includes(collection.id)) return; // 追加: スキップ
        const snapshot = await collection.get();
        data.collections[collection.id] = await Promise.all(
          snapshot.docs.map(async (doc) => ({
            [doc.id]: await recursiveExport(
              `${docPath}/${collection.id}/${doc.id}`,
              skip
            ),
          }))
        ).then((results) => Object.assign({}, ...results));
      })
    );

    return data;
  }
}

// データをFirestoreにインポートする関数
async function importData(docPath: string, folderPath: string, skip: string[]) {
  console.log(
    `フォルダ "${folderPath}" から Firestoreのドキュメントパス "${docPath}" へデータをインポートします...`
  );
  const filePath = path.join(folderPath, "data.json");
  if (!fs.existsSync(filePath)) {
    console.error(`データファイルが ${filePath} に見つかりません。`);
    process.exit(1);
  }
  const data = JSON.parse(fs.readFileSync(filePath, "utf8"));

  // ドキュメントとしてインポート
  await recursiveImport(docPath, data, skip);
  console.log(`ドキュメントパス: ${docPath} をインポートしました。`);
}

async function recursiveImport(docPath: string, data: any, skip: string[]) {
  const { fields, collections } = data;

  // フィールドを設定
  await admin.firestore().doc(docPath).set(fields, { merge: true });

  // Functions triggerによる更新待ち
  console.log(docPath); // 進捗表示：ドキュメントのパスを出力
  await new Promise((resolve) =>
    setTimeout(resolve, WAIT_FOR_FUNCTIONS_TRIGGER_MS)
  );

  // コレクションを再帰的にインポート
  for (const [collectionName, collectionData] of Object.entries(collections)) {
    if (skip.includes(collectionName)) continue; // 追加: スキップ
    for (const [docId, docData] of Object.entries(collectionData as any)) {
      await recursiveImport(
        `${docPath}/${collectionName}/${docId}`,
        docData,
        skip
      );
    }
  }
}

main().catch((error) => {
  console.error("エラーが発生しました:", error);
  process.exit(1);
});

```


# 所感
CursorのComposer機能でOpenAI o1-miniを利用することで、ほとんどAIが作成・カスタマイズしてくれました。
AIがよしなにスクリプトを編集してくれるので、依存関係の気になるライブラリをインストールするよりも自分でスクリプトを管理してしまった方が、結果的に管理の手間が少なく楽かもしれません。