Firebase に Cloud SQL を接続できるようになった、という話を聞いてから「いつか試そう」と先送りにしているうちに、気がつけばFirebase Data Connectは正式リリースから1年以上が経過していました。RealTime Database でも Firestore でもない、GraphQL ベースのリレーショナルデータベース連携——これが既存のFirebaseプロジェクトに何をもたらすのか、正直なところまだ掴みきれていない方も多いのではないでしょうか。
私も最初はそうでした。しかし Antigravity IDE でスキーマ定義ファイルを開いた瞬間、AI が「この構造なら @table ディレクティブでこう設計するとN+1クエリが消えます」と即座に提案してきたとき、このツールが従来の Firebase 開発を根本から変えることを実感しました。
先に結論を書いておきます。Data Connect は Firestore の置き換えではありません。両者は解こうとしている問題が違います。
以下では、実際に動かしながら見えてきたことを——エミュレーターと本番環境の差異、認証ディレクティブの落とし穴、データが増えたときの劣化、そして本番で見えるようにしておくべき指標まで——貼ればそのまま動くコードとともに書き残しています。公式ドキュメントが素通りしている箇所に、できるだけ紙幅を割きました。
Firebase Data Connect とは何か:Firestoreとの根本的な違いを理解する
Firebase Data Connect は、Google Cloud SQL(PostgreSQL)を Firebase のエコシステムから直接操作できるようにする GraphQL ベースのサービスです。2024年に GA(正式リリース)となり、従来の Firebase Realtime Database や Firestore とは設計思想が大きく異なります。
最も重要な違いは「スキーマファースト」という設計アプローチです。Firestore はドキュメント指向でスキーマレスな柔軟性が売りでしたが、Data Connect では .gql ファイルでスキーマを先に定義し、そこからクライアント SDK、型定義、GraphQL リゾルバーが自動生成されます。
Data Connect が Firestore より適しているケース
ユーザー・投稿・コメント・いいねのような複雑なリレーションがある
集計クエリ(SUM・AVG・GROUP BY)を多用する分析系の機能がある
既存の PostgreSQL スキーマを Firebase に移植したい
型安全な GraphQL API を素早く生成したい
Firestore が引き続き適しているケース
リアルタイムリスニング(onSnapshot)を多用する
ドキュメントの柔軟な構造変更が頻繁に必要
スモールスタートで後からスキーマを固めたい
Antigravity でこの判断を AI に聞くと、既存のドメインモデルを分析して「このエンティティ群は Data Connect の方が適切です、理由は〜」と根拠付きで回答してくれます。これが Antigravity を使う最初のメリットです。
Antigravity での開発環境セットアップ:正しい初期化手順
Firebase Data Connect の開発環境は、セットアップ手順を間違えると後から修正が難しい落とし穴があります。特に Antigravity でアシストを受けながら進める場合、以下の順序を守る点が肝心です。
まず、Firebase CLI と Data Connect Emulator を正しいバージョンで用意します。
# Firebase CLI を最新版に更新(12.0.0 以降が必要)
npm install -g firebase-tools@latest
firebase --version # 13.x.x であることを確認
# プロジェクトの初期化
mkdir my-app && cd my-app
firebase init dataconnect
# エミュレーターのセットアップ(Data Connect + Auth を一緒に起動)
firebase init emulators
# dataconnect, auth, hosting を選択
初期化後、Antigravity でプロジェクトを開くと .antigravity/rules ファイルに Data Connect 用のコンテキストを追加するよう提案されます。これを受け入れることで、スキーマファイルを開いたときに AI が GraphQL ディレクティブの補完を正確に行えるようになります。
# .antigravity/rules に追加(Antigravity が自動提案する内容)
You are working on a Firebase Data Connect project.
Schema files use .gql extension with @table, @col, @ref directives.
Connector files define queries and mutations.
Always generate TypeScript SDK imports from the auto-generated client SDK.
次に、プロジェクト構造を確認します。
dataconnect/
schema/
schema.gql ← エンティティ定義
connector/
connector.yaml ← コネクター設定
queries.gql ← クエリ定義
mutations.gql ← ミューテーション定義
dataconnect.yaml ← サービス設定
ここでよくあるミスは、schema.gql と queries.gql を1つのファイルにまとめてしまうことです。Data Connect のビルドは分割されたファイルを前提としており、混在させるとエミュレーターが正しく起動しません。Antigravity の AI に「スキーマと操作を1ファイルにまとめたい」と伝えると、なぜ分割すべきかを根拠付きで説明してくれます。
スキーマ設計の核心:GraphQLディレクティブを使いこなす
Data Connect のスキーマ設計で最初に理解すべきは、@table・@col・@ref の3つのコアディレクティブです。Antigravity に「ブログサービスのスキーマを作って」と頼むと、以下のような型安全なスキーマを即座に生成してくれます。
# dataconnect/schema/schema.gql
type User @table {
id : UUID ! @col ( name : "user_id" ) @default ( expr : "uuidV4()" )
email : String ! @col ( dataType : "varchar(255)" ) @unique
displayName : String ! @col ( name : "display_name" , dataType : "varchar(100)" )
createdAt : Date ! @col ( name : "created_at" ) @default ( expr : "request.time" )
posts : [ Post ! ] ! @hasMany ( foreignKey : "author_id" )
}
type Post @table {
id : UUID ! @col ( name : "post_id" ) @default ( expr : "uuidV4()" )
title : String ! @col ( dataType : "varchar(500)" )
content : String ! @col ( dataType : "text" )
published : Boolean ! @default ( value : false )
authorId : UUID ! @col ( name : "author_id" )
author : User ! @belongsTo ( foreignKey : "author_id" )
tags : [ Tag ! ] ! @manyToMany ( joinTable : "PostTag" )
createdAt : Date ! @col ( name : "created_at" ) @default ( expr : "request.time" )
updatedAt : Date @col ( name : "updated_at" )
}
type Tag @table {
id : UUID ! @col ( name : "tag_id" ) @default ( expr : "uuidV4()" )
name : String ! @col ( dataType : "varchar(50)" ) @unique
posts : [ Post ! ] ! @manyToMany ( joinTable : "PostTag" )
}
このスキーマで重要なポイントを解説します。
@default(expr: "uuidV4()") について : Data Connect はサーバーサイドで UUID を自動生成できます。クライアントから ID を送る必要がなくなり、UUID の衝突リスクを排除できます。
@manyToMany(joinTable: "PostTag") について : 中間テーブルを明示的に定義せずに多対多のリレーションを表現できます。Data Connect が PostTag テーブルを自動生成します。ただし、中間テーブルに追加属性(例: 並び順)を持たせたい場合は、明示的なエンティティ定義が必要です。これは Antigravity に「中間テーブルに order カラムを追加したい」と伝えると正しい実装方法を教えてくれます。
@col(dataType: "varchar(255)") について : PostgreSQL の型を直接指定できます。デフォルトでは String は text 型になりますが、インデックス効率を考えると varchar の方が適切なケースが多いです。
クエリとミューテーションの実装:型安全APIの自動生成
スキーマを定義したら、次はクエリとミューテーションを定義します。ここが Data Connect の最大の魅力で、定義した GraphQL がそのまま型安全な TypeScript SDK に変換されます。
# dataconnect/connector/queries.gql
# 公開済み投稿の一覧(ページネーション付き)
query ListPublishedPosts ( $limit : Int , $offset : Int ) @auth ( level : PUBLIC ) {
posts (
where : { published : { eq : true } }
orderBy : [{ createdAt : DESC }]
limit : $limit
offset : $offset
) {
id
title
createdAt
author {
id
displayName
}
tags {
name
}
}
}
# ユーザーの全投稿(認証必須)
query GetUserPosts ( $userId : UUID ! ) @auth ( level : USER ) {
posts (
where : {
authorId : { eq : $userId }
# 自分の投稿のみアクセス可能
_and : [{ authorId : { eq_expr : "auth.uid" } }]
}
orderBy : [{ createdAt : DESC }]
) {
id
title
published
createdAt
updatedAt
}
}
# 投稿詳細(公開済みのみ、または自分の投稿)
query GetPostDetail ( $postId : UUID ! ) @auth ( level : PUBLIC ) {
post (
id : $postId
where : {
_or : [
{ published : { eq : true } }
{ authorId : { eq_expr : "auth.uid" } }
]
}
) {
id
title
content
published
createdAt
updatedAt
author {
id
displayName
}
tags {
id
name
}
}
}
# dataconnect/connector/mutations.gql
# 投稿の新規作成(認証必須)
mutation CreatePost ( $title : String ! , $content : String ! , $tagIds : [ UUID ! ]) @auth ( level : USER ) {
post_insert ( data : {
title : $title
content : $content
authorId_expr : "auth.uid"
tags : { connect : $tagIds }
}) {
id
title
createdAt
}
}
# 投稿の更新(オーナーのみ)
mutation UpdatePost ( $postId : UUID ! , $title : String , $content : String , $published : Boolean ) @auth ( level : USER ) {
post_update (
id : $postId
data : {
title : $title
content : $content
published : $published
updatedAt_expr : "request.time"
}
where : { authorId : { eq_expr : "auth.uid" } }
) {
id
title
published
updatedAt
}
}
# 投稿の削除(オーナーのみ)
mutation DeletePost ( $postId : UUID ! ) @auth ( level : USER ) {
post_delete (
id : $postId
where : { authorId : { eq_expr : "auth.uid" } }
)
}
これらの定義から、Antigravity 上でエミュレーターを起動すると TypeScript SDK が自動生成されます。
// src/lib/dataconnect.ts
import {
connectDataConnectEmulator,
getDataConnect,
} from "firebase/data-connect" ;
import { connectorConfig } from "@firebasegen/my-app-connector" ;
// 開発環境ではエミュレーターに接続
const dc = getDataConnect (connectorConfig);
if (process.env. NODE_ENV === "development" ) {
connectDataConnectEmulator (dc, "localhost" , 9399 );
}
export { dc };
// src/app/posts/page.tsx(Next.js App Router の例)
import {
executeQuery,
listPublishedPostsRef,
} from "@firebasegen/my-app-connector" ;
import { dc } from "@/lib/dataconnect" ;
async function getPosts ( limit = 10 , offset = 0 ) {
try {
const result = await executeQuery ( listPublishedPostsRef (dc, { limit, offset }));
return result.data.posts;
} catch (error) {
// Data Connect のエラーは FirebaseError 型
if (error instanceof Error && error.message. includes ( "permission" )) {
console. error ( "アクセス権限エラー:" , error.message);
return [];
}
throw error;
}
}
export default async function PostsPage () {
const posts = await getPosts ();
return (
< ul >
{ posts . map (( post ) => (
< li key = {post.id} >
< a href = { `/posts/${ post . id }` } > {post.title} </ a >
< span >{post.author.displayName} </ span >
</ li >
))}
</ ul >
);
}
この型安全性が Data Connect の強みです。posts.map(post => post.title) と書いたとき、post.title が string であることを TypeScript が保証してくれます。Firestore の DocumentData では実現できなかった体験です。
Firebase Auth との認証統合:@auth ディレクティブの設計パターン
Data Connect の認証は、クエリ・ミューテーションに @auth ディレクティブを付けることで制御します。ここでの設計ミスがセキュリティホールに直結するため、慎重に理解する必要があります。
@auth レベルの種類と使い分け
@auth(level: PUBLIC): 未認証ユーザーも実行可能。公開コンテンツの読み取りに使います
@auth(level: USER): Firebase Auth で認証済みのユーザーのみ実行可能。auth.uid が使えます
@auth(level: ADMIN): 管理者(Firebase Admin SDK)からのみ実行可能。バックエンドのバッチ処理などに使います
@auth(level: USER_ANON): 匿名認証ユーザーを含む認証済みユーザー
Antigravity でスキーマを設計していると、AI が「このミューテーションは level: USER だけでなく where: { authorId: { eq_expr: "auth.uid" } } も必ず追加してください」と警告してくれます。@auth(level: USER) だけでは「ログイン済みユーザーなら誰でも他人のデータを変更できる」という危険な状態になるためです。
# ❌ 危険:ログイン済みなら誰でも他人の投稿を削除できる
mutation DeletePost ( $postId : UUID ! ) @auth ( level : USER ) {
post_delete ( id : $postId )
}
# ✅ 安全:自分の投稿しか削除できない
mutation DeletePost ( $postId : UUID ! ) @auth ( level : USER ) {
post_delete (
id : $postId
where : { authorId : { eq_expr : "auth.uid" } }
)
}
クライアント側での Firebase Auth との連携は以下の通りです。
// src/lib/auth-dataconnect.ts
import { getAuth, onAuthStateChanged } from "firebase/auth" ;
import { getDataConnect, setInitialUserToken } from "firebase/data-connect" ;
import { connectorConfig } from "@firebasegen/my-app-connector" ;
const auth = getAuth ();
const dc = getDataConnect (connectorConfig);
// Firebase Auth の状態変化を Data Connect に反映する
onAuthStateChanged (auth, async ( user ) => {
if (user) {
const token = await user. getIdToken ();
await setInitialUserToken (dc, token);
} else {
// ログアウト時はトークンをクリア
await setInitialUserToken (dc, undefined );
}
});
ここで注意すべき点は、setInitialUserToken は非同期処理であり、呼び出し直後に Data Connect クエリを実行すると認証状態が反映されていない場合があります。Antigravity に「ログイン直後にユーザーデータを取得したい」と相談すると、onAuthStateChanged のコールバック内でクエリを実行するパターンを提案してくれます。
AntigravityのAIによるスキーマ最適化:実際のプロンプトパターン
Antigravity を使う最大のメリットは、スキーマ設計の相談相手として AI を使えることです。以下は実際に効果があったプロンプトパターンです。
パターン1: N+1クエリの検出と修正
既存の schema.gql と queries.gql を分析して、N+1クエリが発生しているクエリを全て特定し、
@join や nested selection を使って修正してください。
各修正について、なぜN+1が発生していたかと、どう解決したかを説明してください。
このプロンプトで Antigravity が「ListPublishedPosts クエリでは author を取得するたびに個別クエリが走っています。Data Connect の nested selection は自動的に JOIN に最適化されるため、以下のように書き直すと1クエリで完結します」という具体的な修正を提案してくれます。
パターン2: インデックス設計の提案
現在の schema.gql と queries.gql を分析して、Cloud SQL (PostgreSQL) で
必要なインデックスを特定してください。
クエリのWHERE句とORDER BY句を基に、CREATE INDEX文のSQLも生成してください。
Data Connect はインデックスを自動管理しますが、複合インデックスや部分インデックスは手動で追加した方が効率的なケースがあります。AI がクエリパターンを分析して最適なインデックス戦略を提案してくれます。
パターン3: ページネーション戦略の選択
現在 offset ベースのページネーションを使っています。
データ量が増えた場合のカーソルベースへの移行方法と、
Data Connect でのカーソルページネーションの実装例を教えてください。
よくある落とし穴:実際に詰まった5つのポイント
Firebase Data Connect を使い始めて実際に遭遇した問題を、解決策とともに共有します。
1. エミュレーターと本番環境でのスキーマ差異
エミュレーターは dataconnect.yaml の schemaValidation: COMPATIBLE 設定を無視して常に厳格検証を行います。本番デプロイ時に「エミュレーターでは動いたのに」というエラーが出た場合、ほとんどのケースで @col(dataType) の指定がないカラムが PostgreSQL のデフォルト型(text)として作成されているのに対し、クライアントが varchar を期待している不整合が原因です。
解決策 : スキーマ変更のたびに firebase dataconnect:sql:migrate を実行し、Cloud SQL への適用内容を確認します。
2. ミューテーション後のキャッシュ不整合
Next.js App Router と組み合わせた場合、ミューテーション後に router.refresh() を呼び出しても古いデータが表示されることがあります。Data Connect のクライアント SDK はデフォルトでキャッシュを持たないため、これは Next.js の RSC(React Server Components)キャッシュの問題です。
解決策 : ミューテーション後に revalidatePath('/posts') を Server Action 内で呼び出します。
// src/app/actions/posts.ts
"use server" ;
import { revalidatePath } from "next/cache" ;
import { executeMutation, createPostRef } from "@firebasegen/my-app-connector" ;
import { dc } from "@/lib/dataconnect" ;
export async function createPost ( title : string , content : string ) {
try {
const result = await executeMutation (
createPostRef (dc, { title, content })
);
// Next.js のキャッシュを無効化
revalidatePath ( "/posts" );
return { success: true , post: result.data.post_insert };
} catch (error) {
console. error ( "投稿作成エラー:" , error);
return { success: false , error: "投稿の作成に失敗しました" };
}
}
3. UUID の型不一致
TypeScript SDK では UUID が string 型として扱われますが、フォームから受け取った postId をそのまま渡すと実行時エラーになることがあります。これは UUID のフォーマット検証を Data Connect がサーバーサイドで行うためです。
解決策 : ユーザー入力から受け取った ID には必ずバリデーションを追加します。
import { z } from "zod" ;
const uuidSchema = z. string (). uuid ( "無効なIDフォーマットです" );
export async function getPost ( postId : string ) {
// バリデーション
const validatedId = uuidSchema. parse (postId);
try {
const result = await executeQuery (
getPostDetailRef (dc, { postId: validatedId })
);
return result.data.post;
} catch (error) {
if (error instanceof z . ZodError ) {
throw new Error ( "無効なリクエストです" );
}
throw error;
}
}
4. @default(expr: "request.time") のタイムゾーン
request.time は UTC で記録されます。日本のユーザー向けに JST で表示する場合、クライアントサイドで変換が必要です。これを忘れると「9時間ずれた日時が表示される」という定番バグが発生します。
解決策 : 日時変換には Intl.DateTimeFormat を使います。
function formatJST ( dateString : string ) : string {
return new Intl. DateTimeFormat ( "ja-JP" , {
timeZone: "Asia/Tokyo" ,
year: "numeric" ,
month: "2-digit" ,
day: "2-digit" ,
hour: "2-digit" ,
minute: "2-digit" ,
}). format ( new Date (dateString));
}
5. 大量データでの offset ページネーションの劣化
10万件を超えるデータで offset: 50000 のようなクエリを実行すると、PostgreSQL が先頭から50,000件を読み飛ばすため極めて遅くなります。Data Connect でのカーソルベースページネーションは現時点(2026年4月)で実験的サポートのため、回避策として createdAt < [最後の記事の日時] での絞り込みが現実的です。
本番環境デプロイと運用のベストプラクティス
本番環境への初回デプロイで最も重要なのは、Cloud SQL インスタンスの選択です。Development エディションと Enterprise エディションでは価格と機能が大きく異なります。
個人開発・スタートアップ段階では Development エディション(約 $7/月〜)から始め、DAU が 1,000 を超えたあたりで Enterprise への移行を検討するのが現実的です。
# 本番環境へのデプロイ
firebase deploy --only dataconnect
# スキーママイグレーション(破壊的変更がある場合)
firebase dataconnect:sql:migrate --force
# デプロイ前のドライラン(推奨)
firebase dataconnect:sql:migrate --dry-run
接続数管理も重要です。Cloud SQL はデフォルトで接続数に上限があります。Next.js の Serverless 環境(Cloudflare Workers, Vercel Edge)では各リクエストが新しい接続を作るため、接続プールの設定が必須です。
Data Connect SDK はコネクションプールを内部管理していますが、Antigravity の AI に「Cloudflare Workers と Data Connect の組み合わせで接続プールを最適化したい」と相談すると、@google-cloud/cloud-sql-connector を使った明示的な設定方法を提案してくれます。詳細は Antigravity × Cloudflare Workers AIエッジアプリ構築ガイド もご参照ください。
コスト管理については Antigravity AIクレジット最適化ガイド に詳しくまとめています。Data Connect の Cloud SQL コストは読み取りより書き込みの方が高く、バッチ挿入時には post_insertMany を使うことで API 呼び出し回数を削減できます。
MCP との統合:AIエージェントからデータベースを操作する
Antigravity の真の力は、MCP(Model Context Protocol)サーバーと組み合わせたときに発揮されます。Antigravity × MCP エコシステム 2026 完全活用ガイド で紹介されている Firebase MCP サーバーを使うと、Antigravity の AI が Data Connect のクエリを自然言語で実行できるようになります。
// カスタム MCP サーバー:Data Connect をAIエージェントに公開する例
// mcp-server/src/tools/dataconnect.ts
import { Server } from "@modelcontextprotocol/sdk/server/index.js" ;
import { executeQuery, listPublishedPostsRef } from "@firebasegen/my-app-connector" ;
import { dc } from "../lib/dataconnect.js" ;
export function registerDataConnectTools ( server : Server ) {
server. setRequestHandler ( "tools/call" , async ( request ) => {
if (request.params.name === "get_recent_posts" ) {
const { limit = 5 } = request.params.arguments as { limit ?: number };
try {
const result = await executeQuery (
listPublishedPostsRef (dc, { limit })
);
return {
content: [{
type: "text" ,
text: JSON . stringify (result.data.posts, null , 2 )
}]
};
} catch (error) {
return {
content: [{
type: "text" ,
text: `エラー: ${ error instanceof Error ? error . message : "不明なエラー"}`
}],
isError: true
};
}
}
});
}
この MCP サーバーを Antigravity に接続すると、「最新5件の投稿を取得して要約を作って」という自然言語の指示で、AI が get_recent_posts ツールを呼び出してデータを取得・処理します。詳細なマルチエージェント設計については Antigravity マルチエージェント設計の実践パターン をご参照ください。
クエリを速いまま保つ:スケール時のチューニング
初期構築が終わってしばらく経つと、動作はするけれど少しずつ重くなってきた、という状態に必ず出会います。Data Connect はネストしたリレーションを自動で解決してくれるため、書き味は軽いのですが、その裏で PostgreSQL 側の JOIN が積み上がっていることに気づきにくいのです。
投稿・著者・タグ・著者の直近コメント履歴、と欲張って1本のクエリにまとめると、見た目は1操作でも実体は複数段の JOIN になります。私が実際に運用の中で置いている目安は「トラフィックの多いエンドポイントではネストを2階層まで」というものです。
用途 推奨ネスト深度 深くなりそうなときの対処
一覧・フィード(高頻度) 1〜2階層 必要な項目だけ選択し、残りは詳細画面で取得
詳細画面(中頻度) 2〜3階層 コメント等はページネーションで分離
管理画面・集計(低頻度) 制限なし 読み取りレプリカへ分離
# dataconnect/connector/queries.gql
# 高頻度エンドポイント:リレーションは浅く保つ
query ListPostsForFeed ( $limit : Int ) @auth ( level : PUBLIC ) {
posts (
where : { published : { eq : true } }
orderBy : [{ createdAt : DESC }]
limit : $limit
) {
id
title
createdAt
# 1階層のリレーションは JOIN 1回で済む
author {
id
displayName
}
# タグは中間テーブル経由でも配列を返すだけなので許容範囲
tags {
name
}
# 避けたい例: author { posts { ... } } / author { followers { ... } }
}
}
カーソルページネーションの現実的な代替
先ほど落とし穴として挙げた offset の劣化は、データが数万件を超えたあたりから体感できるようになります。カーソルベースの正式サポートを待つ間、createdAt と id の複合条件で「前ページの最後の1件より古いもの」を取る方法が実務では機能します。
# キーセットページネーション:最後に取得した投稿の位置から続きを読む
query ListPostsAfter (
$cursorCreatedAt : Date !
$cursorId : UUID !
$limit : Int
) @auth ( level : PUBLIC ) {
posts (
where : {
published : { eq : true }
_or : [
{ createdAt : { lt : $cursorCreatedAt } }
{
createdAt : { eq : $cursorCreatedAt }
id : { lt : $cursorId }
}
]
}
orderBy : [{ createdAt : DESC }, { id : DESC }]
limit : $limit
) {
id
title
createdAt
}
}
createdAt が同一秒に複数件並んだときに取りこぼしたり重複したりしないよう、第2ソートキーとして id を必ず添えます。ここを省いた実装は、投稿がまばらなうちは正常に見えて、まとめて投入した直後に一部の記事が消えるという厄介な形で表面化します。
書き込みが多い処理は必ずまとめる
インポートやイベント処理で post_insert を1件ずつ呼ぶと、レイテンシと Cloud SQL のコストの両方で効いてきます。post_insertMany が用意されているので、バッチ処理では迷わずこちらを使います。
// src/app/actions/batch-import.ts
"use server" ;
import { executeMutation, postInsertManyRef } from "@firebasegen/my-app-connector" ;
import { dc } from "@/lib/dataconnect" ;
const MAX_BATCH = 500 ;
export async function importPosts (
posts : Array <{ title : string ; content : string }>
) {
if (posts. length === 0 ) return { imported: 0 };
if (posts. length > MAX_BATCH ) {
// 分割は呼び出し側の責務にして、境界を越えたら早期に失敗させる
throw new Error ( `バッチサイズが上限(${ MAX_BATCH }件)を超えています` );
}
try {
const result = await executeMutation (
postInsertManyRef (dc, {
data: posts. map (( p ) => ({
title: p.title,
content: p.content,
// authorId_expr はサーバー側で auth.uid に解決される
authorId_expr: "auth.uid" ,
})),
})
);
return { imported: result.data.post_insertMany. length };
} catch (error) {
console. error ( "バッチインポート失敗:" , error);
throw new Error ( "インポートに失敗しました" );
}
}
上限を超えたときに黙って切り詰めず例外にしているのは、部分的に成功した状態が一番デバッグしづらいからです。呼び出し側で chunk してから渡す設計にしておくと、失敗したチャンクだけ再実行できます。
読み取りレプリカへ集計クエリを逃がす
Cloud SQL Enterprise では読み取りレプリカが使えます。集計や全件走査のような重いクエリをレプリカに寄せると、書き込みがプライマリ側で詰まらなくなります。Data Connect にレプリカ振り分けの機能は用意されていないので、コネクターを2つ持ち、用途で使い分ける形になります。
// src/lib/dataconnect-analytics.ts
import { getDataConnect } from "firebase/data-connect" ;
import { replicaConnectorConfig } from "@firebasegen/my-app-connector-analytics" ;
// 集計・レポート専用。書き込みには使わない
export const dcAnalytics = getDataConnect (replicaConnectorConfig);
コネクターを分けておくと「このクエリはレプリカで良いのか」をコードレビューの時点で判断できるようになります。実行時に切り替える実装よりも、型と import 元で用途が見える方が事故が少ないと感じています。
テストをどう書くか:生成された SDK と向き合う
Data Connect のテストは Firestore のときと少し勝手が違います。スキーマから生成された型付き SDK を相手にするため、ネットワーク層ではなく生成関数をモックする方が素直に書けます。
// src/__tests__/posts.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest" ;
import { getPosts } from "@/app/posts/actions" ;
// 生成された SDK をモックする
vi. mock ( "@firebasegen/my-app-connector" , () => ({
executeQuery: vi. fn (),
listPublishedPostsRef: vi. fn (),
}));
import { executeQuery } from "@firebasegen/my-app-connector" ;
describe ( "getPosts" , () => {
beforeEach (() => {
vi. clearAllMocks ();
});
it ( "クエリ成功時に投稿を返す" , async () => {
vi. mocked (executeQuery). mockResolvedValue ({
data: {
posts: [
{
id: "550e8400-e29b-41d4-a716-446655440000" ,
title: "テスト投稿" ,
createdAt: "2026-04-15T05:30:00Z" ,
author: { id: "user-123" , displayName: "テストユーザー" },
tags: [{ name: "firebase" }],
},
],
},
} as never );
const result = await getPosts ( 5 , 0 );
expect (result). toHaveLength ( 1 );
expect (result[ 0 ].title). toBe ( "テスト投稿" );
});
it ( "権限エラー時は空配列を返す" , async () => {
vi. mocked (executeQuery). mockRejectedValue ( new Error ( "permission denied" ));
expect ( await getPosts ( 5 , 0 )). toEqual ([]);
});
it ( "権限エラー以外はそのまま投げ直す" , async () => {
vi. mocked (executeQuery). mockRejectedValue ( new Error ( "network timeout" ));
await expect ( getPosts ( 5 , 0 )).rejects. toThrow ( "network timeout" );
});
});
3つ目のケースを必ず入れておく点が肝心です。前半で紹介した getPosts はメッセージに permission が含まれるときだけ空配列を返す実装でしたが、この分岐は雑に書くとネットワーク障害まで「データなし」として握り潰します。握り潰しは画面上では空の一覧として現れるので、原因にたどり着くまでに時間を取られます。
結合テストではエミュレーターを起動し、実際のクエリを通します。
// vitest.setup.ts
import { initializeApp } from "firebase/app" ;
import {
connectDataConnectEmulator,
getDataConnect,
} from "firebase/data-connect" ;
import { connectorConfig } from "@firebasegen/my-app-connector" ;
const app = initializeApp ({ projectId: "demo-test-project" });
const dc = getDataConnect (app, connectorConfig);
connectDataConnectEmulator (dc, "localhost" , 9399 );
export { dc };
エミュレーターは REST API 経由で状態をリセットできるため、テストケースごとに初期状態へ戻す運用が現実的に回ります。Antigravity に「CreatePost ミューテーションについて、認証成功・認証失敗・バリデーション境界のテストを書いてください」と頼むと、自分のコネクター定義に合わせたテストファイルが返ってきます。正常系だけを書いて満足してしまう癖への、ちょうどよい歯止めになっています。
本番で起きていることを見えるようにする
デプロイした後に必要になるのは、クエリの所要時間・エラー率・接続プールの挙動を把握する手立てです。
Cloud SQL Insights(Enterprise エディション)は、実行時間や待ち時間、ロック競合、実行計画をクエリ単位で見せてくれます。コード変更なしにデータベース層で計測されるので、まずはこれを有効にしておくと判断材料が揃います。遅いクエリが特定できたら、そのクエリ定義を Antigravity に渡して「このクエリに必要なインデックスと、構造上の改善案を挙げてください」と相談する流れが噛み合います。
アプリケーション側には、薄いラッパーを挟んでおきます。Data Connect SDK のエラーは、そのままでは「どの操作が」「どれだけ待って」失敗したのかが分からないためです。
// src/lib/dataconnect-instrumented.ts
import {
executeQuery as sdkExecuteQuery,
executeMutation as sdkExecuteMutation,
} from "firebase/data-connect" ;
const SLOW_QUERY_MS = 1000 ;
const SLOW_MUTATION_MS = 2000 ;
type QueryRef = Parameters < typeof sdkExecuteQuery>[ 0 ];
type MutationRef = Parameters < typeof sdkExecuteMutation>[ 0 ];
function refName ( ref : unknown ) : string {
return (ref as { name ?: string })?.name ?? "unknown-operation" ;
}
export async function executeQuery ( ref : QueryRef ) {
const start = Date. now ();
const name = refName (ref);
try {
const result = await sdkExecuteQuery (ref);
const duration = Date. now () - start;
if (duration > SLOW_QUERY_MS ) {
console. warn ( `[DataConnect] slow query "${ name }": ${ duration }ms` );
}
return result;
} catch (error) {
console. error (
`[DataConnect] query "${ name }" failed after ${ Date . now () - start }ms:` ,
error
);
throw error;
}
}
export async function executeMutation ( ref : MutationRef ) {
const start = Date. now ();
const name = refName (ref);
try {
const result = await sdkExecuteMutation (ref);
const duration = Date. now () - start;
if (duration > SLOW_MUTATION_MS ) {
console. warn ( `[DataConnect] slow mutation "${ name }": ${ duration }ms` );
}
return result;
} catch (error) {
console. error ( `[DataConnect] mutation "${ name }" failed:` , error);
throw error;
}
}
あとはアプリ全体の import 先をこのモジュールに差し替えるだけです。追加コストは Date.now() の呼び出し1回分で、得られる情報量とは釣り合いません。閾値をログに出しておくと、「遅くなった」という体感の訴えを、具体的な操作名まで絞り込めるようになります。
個人開発で判断が変わったところ
個人開発でこの構成を試す前、私はリレーショナルなデータが必要になったら素直に別のバックエンドを立てるべきだと考えていました。Firebase は認証とホスティングだけ借りて、データは自分で持つ、という切り分けです。
その考えが変わったのは、集計画面を作ったときでした。私自身、ここで一度考えを改めています。Firestore で「タグ別の投稿数」を出そうとすると、書き込みのたびにカウンタを更新する設計を自分で用意する必要があります。整合性の面倒を見るコードが増え、そのコードにバグが入り、表示される数字が少しずつずれていく——この流れを何度か経験しています。Data Connect では同じことが GROUP BY を含む1本のクエリで済み、ずれる余地そのものがなくなりました。
一方で、リアルタイム更新が必要な画面は今も Firestore のままにしています。onSnapshot の手軽さに代わるものは Data Connect にはなく、無理に寄せると自前のポーリングを書くことになります。同じプロジェクト内で両方を併用する構成は、最初こそ二重管理に見えますが、実際には「変化を配るデータ」と「問い合わせるデータ」が自然に分かれるだけでした。
私はこの併用の形を好んで使っています。どちらか一方に統一しようとして生まれる歪みより、役割の違いを認めて置き分ける方が、後から読んだときに理由が説明できるからです。
全体を振り返って:Firebase Data Connectで変わる開発の現場
Firebase Data Connect は「Firestore の代替」ではなく「リレーショナルデータが必要なアプリのための Firebase ネイティブな選択肢」です。GraphQL スキーマから型安全な SDK が自動生成され、Firebase Auth との統合がディレクティブ一行で完結するという体験は、バックエンド API を自分でゼロから実装する手間を大幅に削減してくれます。
Antigravity と組み合わせた場合、スキーマの N+1 問題検出、インデックス最適化提案、セキュリティルールのレビューを AI がリアルタイムでサポートしてくれるため、一人開発でも高品質なデータ層を短期間で構築できます。
次のステップとして、手元のプロジェクトで firebase init dataconnect を実行し、エミュレーター環境で実際にスキーマを定義してみてください。最初の型安全なクエリが動いた瞬間、Firestore とは異なる確かな手応えを感じていただけると思います。
計装ラッパーとキーセットページネーションだけは、本番に出す前に入れておくことをお勧めします。どちらも後から入れると既存コードの書き換えが広範囲に及び、そのタイミングでは大抵ほかの火を消しているからです。実装の参考になれば幸いです。