1. 始める前に
この Codelab では、Firebase SQL Connect を Cloud SQL データベースと統合して、リアルタイムの絵文字株式市場ウェブアプリである Friendly Exchange を構築します。
完成したアプリでは、次のような高度な SQL Connect 機能が紹介されています。
- ネイティブ SQL:
_executeと_selectを使用して、複雑なデータ操作言語(DML)ステートメントと共通テーブル式(CTE)を安全に実行します。 - SQL ビュー:
@viewディレクティブを使用して、動的な Postgres クエリに支えられた厳密な型安全な GraphQL オブジェクトを作成します。 - リアルタイム サブスクリプション:
@refreshトリガーを使用して、フロントエンド UI の同期を維持します。 - アトミック トランザクション: 複数のオペレーションをチェーンし、
@transactionと@checkを使用して状態を検証します。 - (省略可) 地理空間検索とベクトル検索: PostGIS と pgvector を活用して、ユーザーの座標の近くにあるトレンドのアセットを見つけ、セマンティック検索を実行します。
- (省略可) カスタム リゾルバ: カスタム Cloud Run ロジックを GraphQL スキーマに接続して、AI トレードの見出しを生成します。
前提条件
JavaScript/TypeScript、React、基本的な SQL 構文をしっかりと理解している必要があります。
学習内容
- ネイティブ SQL を使用して、宣言型 GraphQL と生の PostgreSQL ロジックのギャップを埋める方法。
- PostGIS などの Postgres 拡張機能をデータベース クエリに直接統合する方法。
- アトミックな
@transactionブロックを使用して複雑なロジックを適用する方法。 - リーダーボードと統計情報の型安全な
@viewsを作成する方法。 @refreshを使用してリアルタイム サブスクリプションを設定する方法。
必要なもの
- Git
- Visual Studio Code
- Node.js をインストールする
- 従量課金制の Blaze 料金プランの Firebase プロジェクト(カスタム リゾルバと Vertex AI に必要)。
2. 開発環境を設定する
このステージでは、フロントエンドの設定と、上級者向け機能を使用するための Cloud SQL インスタンスの構成について説明します。
- プロジェクト リポジトリのクローンを作成し、アプリに必要な依存関係をインストールします。
git clone https://github.com/firebaseextended/codelab-dataconnect-web cd codelab-dataconnect-web git switch emoji-init npm install
- Visual Studio Code を使用してクローンしたフォルダを開き、Firebase SQL Connect Visual Studio 拡張機能をインストールします。
- ターミナルで、Firebase CLI が完全に最新の状態であることを確認します(これは、
@refreshやネイティブ SQL などの新機能に必要です)。
npm uninstall -g firebase-tools npm install -g firebase-tools firebase login firebase use your-project-id firebase init
([Hosting]、[Authentication]、[SQL Connect] を選択します)。
SQL Connect SDK を生成する: 次のコマンドを実行します。
firebase dataconnect:sdk:generate
- ウェブアプリを Firebase プロジェクトに接続する: Firebase コンソールを使用して、Firebase プロジェクトにウェブアプリを登録します。
- プロジェクトを開き、[アプリを追加](ウェブアイコンを選択)をクリックします。
- SDK の設定と構成は、今のところ無視してかまいませんが、生成された
firebaseConfigオブジェクトは必ずコピーしてください。 - コードエディタで
lib/firebase.tsxを開き、既存のプレースホルダをコピーした構成に置き換えます。
const firebaseConfig = {
apiKey: "API_KEY",
authDomain: "PROJECT_ID.firebaseapp.com",
projectId: "PROJECT_ID",
storageBucket: "PROJECT_ID.firebasestorage.app",
messagingSenderId: "SENDER_ID",
appId: "APP_ID"
};
- 開発サーバーを実行します。
npm run dev
3. スターター コードベースを確認する
このセクションでは、アプリのスターター コードベースの重要な領域について説明します。スキーマとクエリはスクラッチで作成しますが、フロントエンドが SQL Connect とやり取りするように接続されている仕組みを理解しておくと役立ちます。
フォルダとファイルの構造
dataconnect/ ディレクトリ
このフォルダには、データベース構造からアプリが実行できる特定の SQL クエリまで、バックエンドの定義がすべて含まれています。
schema/schema.gql: 標準の GraphQL 型を使用して、ベースの Postgres テーブルを定義します。schema/views.gql:@viewディレクティブを使用して、複雑な読み取り専用の SQL ビュー(ランキングなど)を定義する場所。friendly-exchange/queries.gqlとmutations.gql: 「コネクタ」。ここで、アプリで許可される正確なクエリとネイティブ SQL(_execute、_select)を定義します。dataconnect.yaml: SDK の生成と Cloud SQL のデプロイ設定を決定する構成ファイル。
lib/ ディレクトリ
アプリケーション ロジック、認証、Firebase SQL Connect SDK とのやり取りが含まれています。
firebase.tsx: Firebase アプリ、Auth、SQL Connect インスタンスの初期化を処理します。ExchangeService.tsx: これは、React コンポーネントとデータベース間のブリッジです。生成された SDK 関数(buyStockやsellStockなど)を標準の非同期関数でラップし、エラー キャッチ、ビジネス ロジック、トースト通知を処理します。
生成された SDK
SQL Connect でクエリまたはミューテーションを記述すると、VS Code 拡張機能が厳密に型指定された SDK を自動的に生成します。このプロジェクトでは、フロントエンドはこれらの関数を @dataconnect/generated から直接インポートします。
4. 絵文字交換のスキーマを定義する
このセクションでは、取引アプリケーションの主要なエンティティの構造と関係を定義します。User、Emoji、StockOwnership、Event、PriceHistory などのエンティティはデータベース テーブルにマッピングされ、Firebase SQL Connect と GraphQL スキーマ ディレクティブを使用してリレーションシップが確立されます。
このスキーマが設定されると、アプリは売買取引の実行、グローバル リーダーボードの更新、ローカルな地理空間トレンドのマッピングなど、あらゆる処理に対応できるようになります。
コア エンティティとリレーションシップ
- 絵文字: アプリが市場を表示するために使用する、シンボル、名前、価格、トレンドなどの重要な詳細情報を保持します。
- ユーザー: トレーダーのプロフィール、利用可能なポイント(通貨)、ローカル レーダー スキャン用の地理座標を追跡します。
- 関係:
StockOwnership結合テーブルは、特定のユーザーが特定の絵文字を何個所有しているかを正確に追跡します。Event型とPriceHistory型は、変更不能な台帳として機能し、市場への影響と過去の価格ポイントを時系列で記録します。
User テーブルを設定する
User 型は、システム内のトレーダーを定義し、残高、ロール、地理空間クエリの物理的な位置を追跡します。
次のコード スニペットをコピーして dataconnect/schema/schema.gql ファイルに貼り付けます。
# Users
# user-stockOwnership is a one-to-many relationship, user-events is a one-to-many relationship
# Utilizes the Firebase Auth uid expression as the primary key
type User @table {
id: String! @default(expr: "auth.uid")
username: String!
profileImage: String
role: String! @default(value: "USER")
points: Float! @default(value: 100.0)
city: String @default(value: "Las Vegas")
latitude: Float @default(value: 36.1699)
longitude: Float @default(value: -115.1398)
}
重要なポイント:
id:@default(expr: "auth.uid")を使用して Firebase Authentication に直接バインドします。これにより、データベース ID と Auth ID が安全に 1 対 1 で関連付けられ、ユーザーが ID をスプーフィングできなくなります。points: 取引に使用される仮想通貨。新規ユーザーの場合はデフォルトで100.0に設定されます。
絵文字テーブルを設定する
Emoji 型は、標準テキスト検索のフィールドなど、取引されるメイン アセットを定義します。
次のコード スニペットをコピーして dataconnect/schema/schema.gql ファイルに貼り付けます。
# Emojis
# emoji-stockOwnership is a one-to-many relationship, emoji-priceHistory is a one-to-many relationship
# Implements @searchable directives for full-text search
type Emoji @table {
id: UUID! @default(expr: "uuidV4()")
symbol: String!
name: String! @searchable
tags: [String!]
description: String! @searchable
currentPrice: Float! @default(value: 10.0)
trend: Float! @default(value: 0.0)
}
重要なポイント:
nameとdescription:@searchableディレクティブを使用して、これらの列を標準の全文検索用に最適化します。
StockOwnership テーブルを設定する
StockOwnership 型は、ユーザーと所有する絵文字の多対多の関係を処理する結合テーブルです。このスニペットをコピーして dataconnect/schema/schema.gql ファイルに貼り付けます。
# Join table for many-to-many relationship between users and emojis
# The 'key' param signifies the primary key(s) of this table
# In this case, the keys are [user, emoji], the generated fields of the reference types
type StockOwnership @table(key: ["user", "emoji"]) {
user: User!
emoji: Emoji!
shares: Int! @default(value: 0)
}
重要なポイント:
key: ["user", "emoji"]: 複合主キーを作成します。ユーザーが同じ絵文字に対して 2 つの別々のレコードを持つことはできません。ペアごとに一意性が適用されます。- 暗黙的な参照:
User型とEmoji型を直接参照すると、SQL Connect はバックグラウンドで外部キーuserId: String!とemojiId: UUID!を自動的に生成します。
Event テーブルと PriceHistory テーブルを設定する
これらのタイプは、アプリケーションの台帳を表し、何が起こったか、価格がどのように変化したかを正確に記録します。最終的なスニペットをコピーして dataconnect/schema/schema.gql ファイルに貼り付けます。
# Events
# Event-User is a many-to-one relationship, Event-Emoji is a many-to-one relationship
# Evaluates the createdAt timestamp purely on the server side using the request.time expression
type Event @table {
id: UUID! @default(expr: "uuidV4()")
user: User!
emoji: Emoji!
impact: Float!
description: String!
createdAt: Timestamp! @default(expr: "request.time")
}
# Price History
# PriceHistory-Emoji is a many-to-one relationship
type PriceHistory @table {
id: UUID! @default(expr: "uuidV4()")
emoji: Emoji!
price: Float!
recordedAt: Timestamp! @default(expr: "request.time")
}
重要なポイント:
createdAtとrecordedAt:@default(expr: "request.time")を使用して、データベース トランザクションが発生した正確な時刻に自動的に設定されます。これにより、クライアントがタイムスタンプを操作することを防ぐことができます。
自動生成されるフィールドとデフォルト値
スキーマは @default(expr: "uuidV4()") や @default(expr: "auth.uid") などの式に依存して、クライアント アプリケーションが提供しなくても、一意の ID を自動的に生成し、所有権を強制します。
5. 市場データとユーザーデータを取得する
このセクションでは、モックの市場データをデータベースに挿入し、コネクタ(クエリ)と TypeScript コードを実装して、ウェブ アプリケーション全体でこれらのコネクタを呼び出します。このチュートリアルを終えると、アプリでライブ絵文字マーケット、ユーザー プロフィール、リーダーボードをデータベースから直接動的に取得して表示できるようになります。
モックの市場データとユーザーデータを挿入する
- VSCode で
dataconnect/seed.gqlを開きます。 - Firebase SQL Connect 拡張機能のエミュレータが実行されていること(または Cloud SQL インスタンスが接続されていること)を確認します。
- ファイルの先頭に [Run (local)] または [Run (Production)] CodeLens ボタンが表示されます。これをクリックすると、データベースにモックの絵文字データと初期の価格履歴が挿入されます。
- SQL Connect Execution ターミナルで、データが正常に追加されたことを確認します。
基本的なクエリを実装する
まず、スキーマで定義した標準テーブルをクエリします。
- 開く
dataconnect/friendly-exchange/queries.gql。 - 次のクエリを追加して、ダッシュボード データ、ユーザー プロファイル、基本的な価格履歴を取得します。
# Get dashboard data including top emojis by price and recent market events
query GetDashboardData
@auth(
level: PUBLIC
insecureReason: "This operation is safe to expose to the public."
) {
emojis(orderBy: [{ currentPrice: DESC }]) {
id
symbol
name
description
currentPrice
trend
}
events(orderBy: [{ createdAt: DESC }], limit: 15) {
id
description
impact
createdAt
user {
username
profileImage
}
emoji {
symbol
}
}
}
# Get current authenticated user profile and their stock ownership using auth.uid
query GetUserProfile @auth(level: USER) {
user(id_expr: "auth.uid") {
points
username
profileImage
role
stockOwnerships_on_user {
shares
emoji {
id
symbol
currentPrice
name
}
}
city
latitude
longitude
}
}
# Get price history for a specific emoji ordered by time
query GetPriceHistory($emojiId: UUID!, $limit: Int)
@auth(
level: PUBLIC
insecureReason: "This operation is safe to expose to the public."
) {
priceHistories(
where: { emojiId: { eq: $emojiId } }
orderBy: [{ recordedAt: ASC }]
limit: $limit
) {
price
recordedAt
}
}
重要なポイント:
emojis()/events(): テーブルからデータを直接取得するために自動的に生成される GraphQL クエリ フィールド。id_expr: "auth.uid": 現在認証されている Firebase ユーザーのトークンに一致するユーザー プロファイルを取得して、アクセスを保護します。_on_: 外部キー関係を持つ関連付けられた型のフィールドに直接アクセスできます。stockOwnerships_on_userは、1 つのクエリでユーザーのポートフォリオ全体を取得します。insecureReason: オペレーションをPUBLICに公開する場合は必須です。このデータが認証なしで公開しても安全な理由を明示的に文書化します。
型安全な SQL ビューを作成する
カスタム SQL を記述する前に、Firebase SQL Connect がクエリを処理するさまざまな方法を理解しておくことが重要です。
- 標準 GraphQL: 厳格なエンドツーエンドの型安全性を備えた基本的な CRUD と単純なリレーションに最適です。
- SQL ビュー(
@view): 厳密な型安全な GraphQL オブジェクトをクライアントに返したい場合に、読み取り専用の複雑な SQL(ウィンドウ関数を使用するリーダーボードなど)に最適です。 - ネイティブ SQL(
_execute/_select): DML、CTE、PostGIS 拡張機能を直接実行する場合に最適です。厳密なコンパイル時の型指定と、実行時の最大限の柔軟性(動的 JSON を返す)をトレードオフします。
ランキングとスパークライン グラフを作成するには、移動平均を計算してユーザーをランク付けする必要があります。これは @view のユースケースです。
- 開く
dataconnect/schema/views.gql。 - サーバーで必要な統計情報を計算するために、次のビューを追加します。
# Rank users on a leaderboard based on their total net worth
type TopTrader
@view(
sql: """
SELECT
u.id,
u.username,
u.profile_image,
(u.points + COALESCE(SUM(so.shares * e.current_price), 0)) AS net_worth,
RANK() OVER (ORDER BY (u.points + COALESCE(SUM(so.shares * e.current_price), 0)) DESC) AS rank
FROM "user" u
LEFT JOIN stock_ownership so ON u.id = so.user_id
LEFT JOIN emoji e ON so.emoji_id = e.id
WHERE u.id != 'system_market_maker'
GROUP BY u.id, u.username, u.profile_image, u.points
"""
) {
id: String
username: String
profileImage: String
netWorth: Float
rank: Int
}
# Identify the top shareholder (whale) for each emoji and their total ownership percentage
type EmojiWhaleStat
@view(
sql: """
WITH total_shares AS (
SELECT emoji_id, SUM(shares) AS total_supply
FROM stock_ownership WHERE shares > 0 GROUP BY emoji_id
),
ranked_holders AS (
SELECT
so.emoji_id, u.username AS whale_username, u.profile_image AS whale_profile_image,
so.shares AS whale_shares, ts.total_supply,
ROUND((so.shares::DECIMAL / NULLIF(ts.total_supply, 0)) * 100, 2) AS whale_percentage,
RANK() OVER (PARTITION BY so.emoji_id ORDER BY so.shares DESC) AS holder_rank
FROM stock_ownership so
JOIN "user" u ON u.id = so.user_id
JOIN total_shares ts ON ts.emoji_id = so.emoji_id
WHERE so.shares > 0
)
SELECT emoji_id, whale_username, whale_profile_image, whale_shares, total_supply, whale_percentage
FROM ranked_holders WHERE holder_rank = 1
"""
) {
emojiId: UUID
whaleUsername: String
whaleProfileImage: String
whaleShares: Int
totalSupply: Int
whalePercentage: Float
}
# Calculate the moving average of historical prices for each emoji
type EmojiHistoryStat
@view(
sql: """
SELECT
emoji_id, price, recorded_at,
AVG(price) OVER (PARTITION BY emoji_id ORDER BY recorded_at ROWS BETWEEN 4 PRECEDING AND CURRENT ROW) as moving_average
FROM price_history
"""
) {
emojiId: UUID
price: Float
recordedAt: Timestamp
movingAverage: Float
}
# Combine recent price updates and major news events into a single chronological feed
type TickerFeed
@view(
sql: """
WITH latest_prices AS (
SELECT emoji_id, MAX(recorded_at) as last_trade_time
FROM price_history GROUP BY emoji_id
)
SELECT
'PRICE' as type, e.symbol, e.name, e.current_price, e.trend,
'' as description, lp.last_trade_time as event_time
FROM emoji e JOIN latest_prices lp ON e.id = lp.emoji_id
UNION ALL
SELECT
'NEWS' as type, e.symbol, '' as name, 0 as current_price, 0 as trend,
ev.description, ev.created_at as event_time
FROM event ev JOIN emoji e ON ev.emoji_id = e.id
"""
) {
type: String
symbol: String
name: String
currentPrice: Float
trend: Float
description: String
eventTime: Timestamp
}
# Retrieve the 15 most recent price points for each emoji to render sparkline charts
type EmojiSparkline
@view(
sql: """
WITH RankedPrices AS (
SELECT
emoji_id, price, recorded_at,
ROW_NUMBER() OVER(PARTITION BY emoji_id ORDER BY recorded_at DESC) as rn
FROM price_history
)
SELECT emoji_id, price, recorded_at
FROM RankedPrices WHERE rn <= 15 ORDER BY recorded_at ASC
"""
) {
emojiId: UUID
price: Float
recordedAt: Timestamp
}
次に、dataconnect/friendly-exchange/queries.gql を開き、TODO を置き換えて新しいビューからデータを取得します。
# Get emoji whale statistics to identify top shareholders from emojiWhaleStats view
query GetEmojiWhaleStats
@auth(
level: PUBLIC
insecureReason: "This operation is safe to expose to the public."
) {
emojiWhaleStats {
emojiId
whaleUsername
whaleProfileImage
whaleShares
totalSupply
whalePercentage
}
}
# Get historical price and moving average stats for a specific emoji from emojiHistoryStats view
query GetEmojiHistoryStats($emojiId: UUID!)
@auth(
level: PUBLIC
insecureReason: "This operation is safe to expose to the public."
) {
emojiHistoryStats(
where: { emojiId: { eq: $emojiId } }
orderBy: [{ recordedAt: ASC }]
limit: 50
) {
price
movingAverage
recordedAt
}
}
# List top traders ordered by rank from topTraders view
query GetTopTraders
@auth(
level: PUBLIC
insecureReason: "This operation is safe to expose to the public."
) {
topTraders(orderBy: [{ rank: ASC }]) {
id
username
profileImage
netWorth
rank
}
}
# Get chronological market ticker feed of recent events from tickerFeeds view
query GetChronologicalTicker
@auth(
level: PUBLIC
insecureReason: "This operation is safe to expose to the public."
) {
tickerFeeds(orderBy: [{ eventTime: DESC }], limit: 30) {
type
symbol
name
currentPrice
trend
description
eventTime
}
}
# Get simple price points for rendering emoji sparkline charts from emojiSparklines view
query GetEmojiSparklines
@auth(
level: PUBLIC
insecureReason: "This operation is safe to expose to the public."
) {
emojiSparklines {
emojiId
price
recordedAt
}
}
重要ポイント
@view: サーバー上の複雑なデータベース ロジックをカプセル化しながら、クライアントサイドのコードを厳密に型指定します。SQL Connect は、@view型の GraphQL フィールドをSELECTステートメントから返される列にマッピングします。- 読み取り専用: ビューには主キーがなく、直接変更することはできません。
- クエリの生成:
topTraders()とemojiSparklines()は、標準テーブルのクエリとまったく同じように動作します。
検索クエリを実装する
SQL Connect は、スキーマで @searchable ディレクティブがマークされたフィールドの標準検索クエリを自動的に生成します。
次のクエリを dataconnect/friendly-exchange/queries.gql に追加して、全文検索を有効にします。
# Search emojis using full-text search query
query SearchEmojis($query: String)
@auth(
level: PUBLIC
insecureReason: "This operation is safe to expose to the public."
) {
emojis_search(query: $query) {
id
symbol
name
description
currentPrice
trend
}
}
重要ポイント
emojis_search:Emojiスキーマのnameフィールドとdescriptionフィールドに@searchableを適用したため、自動生成されたクエリ フィールド。
SDK を生成する
GraphQL ファイルで新しいクエリとビューを定義したため、TypeScript フロントエンドで安全に使用できるように SDK ジェネレータを実行する必要があります。
ターミナルを開いて、次のコマンドを実行します。
firebase dataconnect:sdk:generate
ウェブアプリでクエリを統合する
Firebase SQL Connect コンパイラは、.gql ファイルに基づいて SDK を生成します。これはリアルタイム アプリとして設計されているため、複数のコンポーネントで生成されたクエリ参照とともに subscribe メソッドを使用します。
次のファイルの空の useEffect ブロックを次のロジックに置き換えます。
1. ホームページ(
app/page.tsx
)
import { subscribe } from "@firebase/data-connect";
import {
getDashboardDataRef,
searchEmojisRef,
getChronologicalTickerRef,
getUserProfileRef,
} from "@dataconnect/generated";
// Inside the Home component:
useEffect(() => {
// Subscribe to realtime updates for the main market dashboard data including top emojis and recent events
const unsubscribe = subscribe(
getDashboardDataRef(),
(res) => {
if (res.data) setDashboardData(res.data);
setIsDashboardLoading(false);
},
(err) => {
console.error("Dashboard Realtime Error:", err);
setIsDashboardLoading(false);
},
);
return () => unsubscribe();
}, [user]);
useEffect(() => {
// Subscribe to a realtime chronological ticker feed combining recent price updates and major news events
const unsubscribe = subscribe(
getChronologicalTickerRef(),
(res) => {
if (res.data) setTickerData(res.data);
},
(err) => console.error("Ticker Realtime Error:", err),
);
return () => unsubscribe();
}, []);
useEffect(() => {
if (loading || !user) return;
// Subscribe to realtime updates for the authenticated user's profile and stock ownership
const unsubscribe = subscribe(
getUserProfileRef(),
(res) => {
if (res.data) setProfileData(res.data);
},
(err) => console.error("Profile Error:", err),
);
return () => unsubscribe();
}, [user, loading]);
useEffect(() => {
if (!debouncedSearch) {
setSearchData(null);
return;
}
// Subscribe to realtime full-text search results for emojis based on user input
const unsubscribe = subscribe(
searchEmojisRef({ query: debouncedSearch }),
(res) => {
if (res.data) setSearchData(res.data.emojis_search);
setIsSearchLoading(false);
},
(err) => {
console.error("Text Search Error:", err);
setIsSearchLoading(false);
},
);
return () => unsubscribe();
}, [debouncedSearch]);
2. ユーザー プロファイル コンポーネント
app/profile/page.tsx
、フックを更新します。
import { subscribe } from "@firebase/data-connect";
import { getUserProfileRef } from "@dataconnect/generated";
useEffect(() => {
// Subscribe to realtime updates for the authenticated user's profile and stock ownership
const unsubscribe = subscribe(
getUserProfileRef(),
(res) => {
if (res.data) {
setData(res.data);
}
setIsLoading(false);
},
(err) => {
console.error("Profile Realtime Error:", err);
setIsLoading(false);
},
);
return () => unsubscribe();
}, []);
components/NavBar.tsx
:
useEffect(() => {
// Subscribe to realtime updates for the authenticated user's profile and stock ownership
const unsub = subscribe(
getUserProfileRef(),
(res) => {
if (res.data) setData(res.data);
},
(err) => console.error("Navbar Balance Realtime Error:", err),
);
return () => unsub();
}, []);
components/FloatingMenu.tsx の場合、手動の const { data } オブジェクトも生成されたフックに置き換えます。
const { data, refetch: refetchDashboard } = useGetDashboardData();
useEffect(() => {
if (!user) return;
// Subscribe to realtime updates for the authenticated user's profile
const unsub = subscribe(getUserProfileRef(), (res) => {
if (res.data) {
setProfileData(res.data);
setOptimisticRole(null);
}
});
return () => unsub();
}, [user]);
重要ポイント
getUserProfileRef/getDashboardDataRef: テーブルとビューで定義された厳密な型を保持しながら、実行用に GraphQL クエリを準備する自動生成関数。subscribe: クエリをリッスンする SQL Connect SDK メソッド。現時点では、コンポーネントがマウントされたときにデータを取得するだけですが、後のステップで、データベースが変更されるたびにこの関数を自動的にトリガーするようにバックエンドをアップグレードします。
- マーケット パネル(
components/MarketPanel.tsx): 同様に、MarketPanel コンポーネント(components/MarketPanel.tsx)でTODOを置き換えて、複数のクエリを同時に呼び出してサイドバーを構築できます。
import { subscribe } from "@firebase/data-connect";
import { getDashboardDataRef, getEmojiSparklinesRef } from "@dataconnect/generated";
// Inside the MarketPanel component:
useEffect(() => {
// Subscribe to realtime updates for the main market dashboard data including top emojis and recent events
const unsub = subscribe(
getDashboardDataRef(),
(res) => {
if (res.data) setData(res.data);
},
(err) => console.error("Market Panel Realtime Error:", err)
);
return () => unsub();
}, []);
useEffect(() => {
// Subscribe to realtime price history updates to render emoji sparkline charts
const unsub = subscribe(
getEmojiSparklinesRef(),
(res) => {
if (res.data?.emojiSparklines) {
setSparklineRawData(res.data.emojiSparklines);
}
},
(err) => console.error("Global Sparklines Error:", err)
);
return () => unsub();
}, []);
- リーダーボード ページ(
app/leaderboard/page.tsx)
import { subscribe } from "@firebase/data-connect";
import { getTopTradersRef } from "@dataconnect/generated";
// Inside the Leaderboard component:
useEffect(() => {
// Subscribe to realtime updates for the global leaderboard ranking top traders by net worth
const unsubscribe = subscribe(
getTopTradersRef(),
(res) => {
if (res.data) setData(res.data);
setIsLoading(false);
},
(err) => {
console.error("Leaderboard Realtime Error:", err);
setIsLoading(false);
},
);
return () => unsubscribe();
}, []);
- 絵文字モーダル(
components/EmojiModal.tsx)
import { subscribe } from "@firebase/data-connect";
import {
getEmojiHistoryStatsRef,
getEmojiWhaleStatsRef,
} from "@dataconnect/generated";
// Inside the EmojiModal component:
useEffect(() => {
if (!emoji?.id) return;
setStatsLoading(true);
// Subscribe to realtime historical price and moving average statistics for the selected emoji
const unsub = subscribe(
getEmojiHistoryStatsRef({ emojiId: emoji.id }),
(res) => {
if (res.data) setStatsData(res.data);
setStatsLoading(false);
},
(err) => {
console.error("History Realtime Error:", err);
setStatsLoading(false);
},
);
return () => unsub();
}, [emoji?.id]);
useEffect(() => {
// Subscribe to realtime whale statistics to identify the top shareholder for the selected emoji
const unsub = subscribe(
getEmojiWhaleStatsRef(),
(res) => {
if (res.data) setWhaleData(res.data);
},
(err) => console.error("Whale Realtime Error:", err),
);
return () => unsub();
}, []);
実例を見る
ウェブアプリを再読み込みして、クエリの動作を確認します。ホームページとサイドバーに、PostgreSQL データベースから直接取得した絵文字のリストが表示されるようになりました。
6. ユーザーの更新とマーケット トランザクションを処理する
このセクションでは、Firebase Authentication を使用してユーザー ログイン機能を実装し、Firebase SQL Connect でユーザー プロファイル(表示名や物理的な場所など)を upsert します。また、SQL Connect の @transaction ディレクティブと @check ディレクティブを使用して、アトミックなマルチステップ マーケット イベントを安全に実行します。
ユーザー コネクタと位置情報コネクタを実装する
dataconnect/friendly-exchange/mutations.gql を開きます。次のミューテーションを追加して、TODO を置き換えます。これにより、ユーザーの作成、更新、検索を処理できます。
# Upserts a user record using the Firebase Auth uid expression as the primary key
# Upsert (update or insert) a user's profile information
mutation UpsertUser($username: String!, $profileImage: String!)
@auth(level: USER) {
user_upsert(
data: {
id_expr: "auth.uid"
username: $username
profileImage: $profileImage
}
)
}
# Update a user's role
mutation UpdateUserRole($role: String!) @auth(level: USER) {
user_update(key: { id_expr: "auth.uid" }, data: { role: $role })
}
# Update a user's location
mutation UpdateUserLocation(
$city: String!
$latitude: Float!
$longitude: Float!
) @auth(level: USER) {
user_update(
key: { id_expr: "auth.uid" }
data: { city: $city, latitude: $latitude, longitude: $longitude }
)
}
# Trigger a new market event for an emoji
mutation TriggerEvent(
$emojiId: UUID!
$impact: Float!
$description: String!
$now: Timestamp!
) @auth(level: USER) {
event_insert(
data: {
userId_expr: "auth.uid"
emojiId: $emojiId
impact: $impact
description: $description
createdAt: $now
}
)
}
重要ポイント
id_expr: "auth.uid": これは、Firebase Authentication トークンから直接提供されるauth.uidを使用します。このサーバーサイドの評価により、ユーザーは自分のプロフィール データのみを更新できるようになり、セキュリティが強化されます。
@transaction を使用したチェーン ロジック
次に、管理者がトリガーしてランダムな市場活動をシミュレートできる「マーケット メーカー」を実装します。これには、絵文字の価格の更新、イベントのロギング、システムの株式所有権の更新をすべて同時に行う必要があるため、アトミック トランザクションが必要です。
次のミューテーションを mutations.gql ファイルに追加します。
# Execute a market maker trade to adjust emoji price and shares
mutation MarketMakerTrade(
$emojiId: UUID!
$priceImpact: Float!
$shareDelta: Int!
$eventDesc: String!
$newPrice: Float!
)
@auth(
level: USER
insecureReason: "This operation is safe to expose to any user."
)
@transaction {
query @redact {
user(key: { id_expr: "auth.uid" })
@check(
expr: "this != null && this.role == 'ADMIN'",
message: "Access Denied: You must have the ADMIN role to deploy the Market Maker bot."
) {
role
}
}
stockOwnership_upsert(
data: {
userId: "system_market_maker"
emojiId: $emojiId
shares_update: { inc: $shareDelta }
}
)
emoji_update(
id: $emojiId
data: { currentPrice_update: { inc: $priceImpact }, trend: $priceImpact }
)
event_insert(
data: {
userId: "system_market_maker"
emojiId: $emojiId
impact: $priceImpact
description: $eventDesc
}
)
priceHistory_insert(data: { emojiId: $emojiId, price: $newPrice })
}
重要ポイント
@transaction: すべてのデータベース オペレーション(在庫の upsert、絵文字の価格の更新、イベントのロギング)が同時に成功するか、同時に失敗するようにします。@check: 続行する前に条件を評価するディレクティブ。ここでは、認証されたユーザーのroleが'ADMIN'であるかどうかを確認します。ユーザーが標準の'USER'のみの場合、トランザクション全体が拒否され、ロールバックされます。@redact: クエリ結果(ユーザーのロールチェックなど)がレスポンス ペイロードでクライアントに返されないようにし、トランザクション レスポンスをクリーンに保ちます。
SDK を生成する
GraphQL ファイルで新しいミューテーションを定義したため、TypeScript フロントエンドから呼び出せるように SDK ジェネレータを実行する必要があります。
ターミナルを開いて、次のコマンドを実行します。
firebase dataconnect:sdk:generate
ウェブアプリでミューテーションを統合する
ウェブアプリでは、これらの生成された SDK ミューテーションを標準の非同期関数でラップして、エラー キャッチと UI 通知を処理します。
lib/ExchangeService.tsx を開き、ラッパー関数を確認します。TODO ブロックを次の実装に置き換えます。
import {
upsertUser,
updateUserLocation,
marketMakerTrade,
updateUserRole,
triggerMarketCrash,
} from "@dataconnect/generated";
// Upsert (update or insert) a user's profile information and log the event
export const executeUpsertUser = async (
username: string,
profileImage: string,
logEvent: (key: LogEventKey, params?: any) => void,
): Promise<void> => {
logEvent("UPSERT_USER_MUTATION", { username });
await upsertUser({ username, profileImage });
};
// Update a user's role and log the event
export const executeUpdateRole = async (
role: string,
logEvent: (key: LogEventKey, params?: any) => void
): Promise<void> => {
logEvent("UPDATE_USER_ROLE_MUTATION", { role });
await updateUserRole({ role });
};
// Update a user's city and geographic coordinates
export const executeUpdateLocation = async (
city: string,
latitude: number,
longitude: number,
): Promise<void> => {
await updateUserLocation({ city, latitude, longitude });
};
// Execute a random market maker trade and adjust an emoji's stock price
export const executeManualBotTrade = async (
randomEmoji: any,
username: string,
logEvent: (key: LogEventKey, params?: any) => void,
): Promise<{ isBuy: boolean; tradeAmount: number }> => {
logEvent("MARKET_MAKER_TRADE");
const isBuy = Math.random() > 0.5;
const tradeAmount = Number((Math.random() * (10 - 2) + 2).toFixed(2));
await marketMakerTrade({
emojiId: randomEmoji.id,
priceImpact: isBuy ? tradeAmount : -tradeAmount,
shareDelta: isBuy ? 10 : -10,
eventDesc: `Admin ${username} triggered market event: ${randomEmoji.symbol} went ${isBuy ? "up" : "down"} by $${tradeAmount.toFixed(2)}.`,
newPrice: Math.max(0.01, randomEmoji.currentPrice + (isBuy ? tradeAmount : -tradeAmount)),
});
return { isBuy, tradeAmount };
};
Triggering upsert on login: In app/src/components/Navbar.tsx, you can see how executeUpsertUser is called immediately after Firebase Authentication successfully signs a user in via Google Popup. This guarantees the SQL Connect database is synced with Firebase Auth.
See it in action
Now, click the Sign In button in the navbar. You can sign in using Firebase Authentication. After signing in:
- Navigate to your Profile and test out the Auto-Locate button. When you click Update Coordinates, the
UpdateUserLocationmutation will execute. - Open the Floating Control Panel (the purple icon in the bottom right corner).
- Click USER and switch your authorization level to ADMIN.
- Click Trigger random market activity. Because your role is now
'ADMIN', the@checkdirective passes, the@transactionexecutes, and you will instantly see the market prices update across your application!
7. Advanced operations with Native SQL
In this section, you will use Native SQL to execute complex Data Manipulation Language (DML) statements and leverage PostgreSQL-specific extensions.
While standard GraphQL and @views are ideal for strictly-typed CRUD and read-only operations, Native SQL provides execution-time flexibility. It allows you to use Common Table Expressions (CTEs) to chain multiple updates in a single database round-trip, and lets you query native PostgreSQL extensions directly.
Enable the PostGIS extension
Before we write geospatial queries, you need to enable the PostGIS extension on your Cloud SQL database.
- Navigate to the Google Cloud Console.
- Go to Cloud SQL -> select your provisioned instance -> click Cloud SQL Studio.
- Log into your database and execute the following command:
CREATE EXTENSION IF NOT EXISTS postgis;
Implement Native SQL Queries
Let's use Native SQL to find trending emojis near the user's physical location, and to calculate the top emojis per city using complex ranking.
- Open
dataconnect/friendly-exchange/queries.gql. - Add the following Native SQL queries using the
_selectfield:
# Get top trending emojis partitioned by user city using native SQL
query GetTopEmojisByCity
@auth(
level: PUBLIC
insecureReason: "This operation is safe to expose to the public."
) {
cityTrends: _select(
sql: """
WITH city_shares AS (
SELECT
u.city,
AVG(u.latitude) as latitude,
AVG(u.longitude) as longitude,
e.id as emoji_id,
e.symbol,
e.name,
SUM(so.shares) as total_shares,
RANK() OVER (PARTITION BY u.city ORDER BY SUM(so.shares) DESC) as rank
FROM stock_ownership so
JOIN "user" u ON so.user_id = u.id
JOIN emoji e ON so.emoji_id = e.id
WHERE u.city IS NOT NULL AND u.latitude IS NOT NULL AND so.shares > 0
GROUP BY u.city, e.id, e.symbol, e.name
)
SELECT city, latitude, longitude, emoji_id, symbol, name, total_shares
FROM city_shares
WHERE rank = 1
ORDER BY city ASC
"""
params: []
)
}
# Get trending emojis within a geographic radius using native SQL and PostGIS extension
query GetTrendingEmojisNearMe(
$userLng: Float!
$userLat: Float!
$radiusMeters: Float!
)
@auth(
level: PUBLIC
insecureReason: "This operation is safe to expose to the public."
) {
regionalTrends: _select(
sql: """
SELECT
e.id,
e.symbol,
e.name,
e.current_price,
e.trend,
COUNT(so.shares) AS regional_holders,
SUM(so.shares) AS regional_shares
FROM emoji e
JOIN stock_ownership so ON so.emoji_id = e.id
JOIN "user" u ON u.id = so.user_id
WHERE u.latitude IS NOT NULL
AND u.longitude IS NOT NULL
AND so.shares > 0
AND ST_DWithin(
ST_MakePoint(u.longitude, u.latitude)::geography,
ST_MakePoint($1, $2)::geography,
$3
)
GROUP BY e.id, e.symbol, e.name, e.current_price, e.trend
ORDER BY regional_shares DESC
LIMIT 10
"""
params: [$userLng, $userLat, $radiusMeters]
)
}
Key Takeaways
_select: Executes a Data Query Language (DQL) statement returning a JSON array ([Any]).ST_DWithin: A native PostGIS function that calculates distances on a sphere. Native SQL allows you to use this without mapping complex geometry types into your GraphQL schema.params: Variables like$userLngare bound to the SQL string via positional parameters ($1,$2,$3), preventing SQL injection.
Implement Native SQL Mutations
When a user buys or sells a stock, the system must validate their funds, deduct the cost, add the shares, update the global emoji price, and log the history. Doing this across multiple standard mutations could lead to race conditions. Instead, we can use a CTE (WITH) to do this atomically in one Native SQL execution.
Open dataconnect/friendly-exchange/mutations.gql and replace the TODOs with the following Native SQL mutations:
# Buy shares of an emoji stock
mutation BuyStock($emojiId: UUID!, $amount: Int!, $isDiscounted: Boolean!)
@auth(level: USER) {
buyStock: _execute(
sql: """
WITH validated_params AS (
SELECT
$1::uuid AS emoji_id,
$2::int AS amount,
$3::boolean AS is_discounted,
$4::text AS user_id
),
target_emoji AS (
SELECT
e.id,
(e.current_price * (CASE WHEN vp.is_discounted THEN 0.5 ELSE 1.0 END) * vp.amount) AS total_cost
FROM emoji e
CROSS JOIN validated_params vp
WHERE e.id = vp.emoji_id
AND vp.amount > 0
AND vp.amount <= 100
),
deduct_funds AS (
UPDATE "user" u
SET points = u.points - te.total_cost
FROM target_emoji te, validated_params vp
WHERE u.id = vp.user_id AND u.points >= te.total_cost
RETURNING u.id
),
upsert_ownership AS (
INSERT INTO stock_ownership (user_id, emoji_id, shares)
SELECT vp.user_id, vp.emoji_id, vp.amount
FROM validated_params vp
WHERE EXISTS (SELECT 1 FROM deduct_funds)
ON CONFLICT (user_id, emoji_id) DO UPDATE
SET shares = stock_ownership.shares + EXCLUDED.shares
RETURNING stock_ownership.emoji_id
),
update_emoji AS (
UPDATE emoji e
SET
current_price = GREATEST(0.01, e.current_price + (e.current_price * 0.01 * vp.amount)),
trend = GREATEST(0.01, e.current_price + (e.current_price * 0.01 * vp.amount)) - e.current_price
FROM validated_params vp
WHERE e.id = vp.emoji_id AND EXISTS (SELECT 1 FROM deduct_funds)
RETURNING e.id, e.current_price, e.trend
)
INSERT INTO price_history (id, emoji_id, price, recorded_at)
SELECT gen_random_uuid(), ue.id, ue.current_price, NOW()
FROM update_emoji ue;
"""
params: [$emojiId, $amount, $isDiscounted, { _expr: "auth.uid" }]
)
}
# Sell shares of an emoji stock
mutation SellStock($emojiId: UUID!, $amount: Int!) @auth(level: USER) {
sellStock: _execute(
sql: """
WITH validated_params AS (
SELECT
$1::uuid AS emoji_id,
$2::int AS amount,
$3::text AS user_id
),
target_emoji AS (
SELECT
e.id,
(e.current_price * vp.amount) AS total_revenue,
GREATEST(0.01, e.current_price * POWER(0.99, vp.amount)) AS new_price
FROM emoji e
CROSS JOIN validated_params vp
WHERE e.id = vp.emoji_id
AND vp.amount > 0
AND vp.amount <= 100
),
check_shares AS (
SELECT so.user_id
FROM stock_ownership so
CROSS JOIN validated_params vp
WHERE so.user_id = vp.user_id
AND so.emoji_id = vp.emoji_id
AND so.shares >= vp.amount
),
add_funds AS (
UPDATE "user" u
SET points = u.points + te.total_revenue
FROM target_emoji te, validated_params vp
WHERE u.id = vp.user_id AND EXISTS (SELECT 1 FROM check_shares)
RETURNING u.id
),
update_ownership AS (
UPDATE stock_ownership so
SET shares = so.shares - vp.amount
FROM validated_params vp
WHERE so.user_id = vp.user_id
AND so.emoji_id = vp.emoji_id
AND EXISTS (SELECT 1 FROM check_shares)
AND EXISTS (SELECT 1 FROM add_funds)
),
update_emoji AS (
UPDATE emoji e
SET
current_price = te.new_price,
trend = te.new_price - e.current_price
FROM target_emoji te, validated_params vp
WHERE e.id = vp.emoji_id
AND EXISTS (SELECT 1 FROM check_shares)
AND EXISTS (SELECT 1 FROM add_funds)
RETURNING e.id, e.current_price, e.trend
)
INSERT INTO price_history (id, emoji_id, price, recorded_at)
SELECT gen_random_uuid(), ue.id, ue.current_price, NOW()
FROM update_emoji ue;
"""
params: [$emojiId, $amount, { _expr: "auth.uid" }]
)
}
Key Takeaways
_execute: Executes a Data Manipulation Language (DML) statement, such asUPDATE,INSERT, orDELETE.- Common Table Expressions (
WITH): Each block in the CTE depends on the previous one. For example,add_fundswill only execute ifcheck_sharesreturns a result. This handles the complex conditions completely within Postgres. - Context Injection:
{ _expr: "auth.uid" }injects the authenticated user's ID into the query directly on the server, enforcing security.
Generate the SDK
Because you have defined new queries and mutations in your GraphQL files, you must run the SDK generator so your TypeScript frontend can call it.
Open your terminal and run:
firebase dataconnect:sdk:generate
Integrate Native SQL in the web app
- Native SQL returns a flexible JSON payload rather than a strictly typed object. Because of this, it's essential to manually validate the returned data shape in your client code to handle the dynamic response.
- Execute Trades: In
lib/ExchangeService.tsx, we wrap the generatedbuyStockandsellStockSDKs. Notice how the return typesbuyResultandsellResultmust be manually validated as arrays, because_executereturns dynamic JSON data based on your specificRETURNINGclauses in the SQL strings. - Replace the empty
executeBuyStockandexecuteSellStockfunctions with your original complete code:
import { buyStock, sellStock, generateTradeHeadline, triggerEvent } from "@dataconnect/generated";
import { LogEventKey } from "./InspectorContext";
// Execute a stock purchase, validating limits and potentially generating an AI news headline for large trades
export const executeBuyStock = async (
emoji: any,
amount: number,
isDiscounted: boolean,
user: any,
logEvent: (key: LogEventKey, params?: any) => void,
): Promise<void> => {
const MAX_AMOUNT = 100;
if (!Number.isInteger(amount) || amount <= 0 || amount > MAX_AMOUNT) {
throw new Error(`Amount must be an integer between 1 and ${MAX_AMOUNT}.`);
}
const singleSharePrice = isDiscounted
? emoji.currentPrice * 0.5
: emoji.currentPrice;
const estimatedCost = singleSharePrice * amount;
const estimatedImpact = emoji.currentPrice * 0.05 * amount;
logEvent("BUY_STOCK_TRANSACTION", { amount, symbol: emoji.symbol });
const response = await buyStock({
emojiId: emoji.id,
amount: amount,
isDiscounted: isDiscounted,
});
const buyResult = response.data?.buyStock as any;
if (
!buyResult ||
buyResult === 0 ||
(Array.isArray(buyResult) && buyResult.length === 0)
) {
throw new Error(
"Transaction denied: Insufficient funds or price mismatch.",
);
}
const actualCost = Array.isArray(buyResult)
? buyResult[0].actual_cost
: estimatedCost;
const actualImpact = Array.isArray(buyResult)
? buyResult[0].actual_impact
: estimatedImpact;
// TODO: Optionally add a custom resolver to call AI to generate headline for this purchase
};
// Execute a stock sale, validating ownership and potentially generating an AI news headline for large trades
export const executeSellStock = async (
emoji: any,
amount: number,
ownedShares: number,
user: any,
logEvent: (key: LogEventKey, params?: any) => void,
): Promise<void> => {
const MAX_AMOUNT = 100;
if (!Number.isInteger(amount) || amount <= 0 || amount > MAX_AMOUNT) {
throw new Error(`Amount must be an integer between 1 and ${MAX_AMOUNT}.`);
}
if (amount > ownedShares) {
throw new Error(
"INSUFFICIENT SHARES: You cannot sell more shares than you own.",
);
}
const estimatedRevenue = emoji.currentPrice * amount;
const dropRatePerShare = 0.05;
const targetPrice =
emoji.currentPrice * Math.pow(1 - dropRatePerShare, amount);
const estimatedImpact = Math.max(0.01, targetPrice) - emoji.currentPrice;
logEvent("SELL_STOCK_TRANSACTION", { amount, symbol: emoji.symbol });
const response = await sellStock({
emojiId: emoji.id,
amount: amount,
});
const sellResult = response.data?.sellStock as any;
if (
!sellResult ||
sellResult === 0 ||
(Array.isArray(sellResult) && sellResult.length === 0)
) {
throw new Error("Transaction denied: Insufficient shares.");
}
const actualRevenue = Array.isArray(sellResult)
? sellResult[0].actual_revenue
: estimatedRevenue;
const actualImpact = Array.isArray(sellResult)
? sellResult[0].actual_impact
: estimatedImpact;
// TODO: Optionally add a custom resolver to call AI to generate headline for this sale
};
Query Geospatial Data (Local Radar): In app/src/components/LocalRadar.tsx, we subscribe to the getTrendingEmojisNearMeRef query. The dynamic JSON array from the _select execution maps directly to the UI list, utilizing PostGIS's distance calculations.
import { subscribe } from "@firebase/data-connect";
import { getTrendingEmojisNearMeRef } from "@dataconnect/generated";
// ... inside the component
useEffect(() => {
if (!location) return;
setIsLoadingTrends(true);
// Subscribe to realtime updates for trending emojis within a 50km radius
const unsub = subscribe(
getTrendingEmojisNearMeRef({
userLat: location.lat,
userLng: location.lng,
radiusMeters: 50000, // 50km
}),
(res) => {
if (res.data) setLocalData(res.data);
setIsLoadingTrends(false);
},
(err) => {
console.error("Local Radar Realtime Error:", err);
setIsLoadingTrends(false);
},
);
return () => unsub();
}, [location?.lat, location?.lng]);
Query Geospatial Data (Global Assets Map): In app/src/app/map/page.tsx (the Insights Page), we use Native SQL's complex window functions (RANK() OVER) to find the single most popular emoji for every city in the database.
import { subscribe } from "@firebase/data-connect";
import { getTopEmojisByCityRef, getTrendingEmojisNearMeRef, getUserProfileRef } from "@dataconnect/generated";
// ... inside the component
useEffect(() => {
// Subscribe to realtime updates for the authenticated user's profile and stock ownership
const unsub = subscribe(getUserProfileRef(), (res) => {
if (res.data) setProfileData(res.data);
});
return () => unsub();
}, []);
useEffect(() => {
// Subscribe to realtime updates for top trending emojis partitioned by user city
const unsub = subscribe(getTopEmojisByCityRef(), (res) => {
if (res.data) setCityData(res.data);
});
return () => unsub();
}, []);
useEffect(() => {
setRadarLoading(true);
// Subscribe to realtime updates for trending emojis within a specified geographic radius
const unsub = subscribe(
getTrendingEmojisNearMeRef({
userLat: coords.lat,
userLng: coords.lng,
radiusMeters: radiusKm * 1000,
}),
(res) => {
if (res.data) setRadarData(res.data);
setRadarLoading(false);
},
);
return () => unsub();
}, [coords.lat, coords.lng, radiusKm]);
See it in action
- In your browser, navigate to the Geo page from the top navigation bar.
- If your location is correctly set in your Profile, the Global Top Assets map will ping the
GetTopEmojisByCitynative query to drop pins on cities with high trade volumes. - Click Scan Local Network. The
Local Radar Scannerwill ask for your browser's location and ping theGetTrendingEmojisNearMenative query, utilizing PostGIS to find the top assets specifically traded within 50km of your coordinates! - Navigate to the Home page or Profile page and purchase some assets to see your balance deduct and the emoji price update automatically via your atomic
_executequeries.
8. Realtime subscriptions and caching
In the previous section, we used the subscribe() method in our React components to fetch data. While that successfully retrieved the initial state, a true stock exchange needs to feel alive. If another user buys a massive amount of emoji stock, your screen should update instantly.
This is where Firebase SQL Connect's Realtime features come in.
What is Realtime and how does it work?
Realtime support allows your application to receive proactive notifications from the server whenever data your app is using has been updated.
Here is the underlying mechanism:
- Trigger (
@refresh): You tell the SQL Connect backend which specific mutations should trigger a data refresh for a given query. - Broadcast: When one of those mutations executes (e.g., someone runs
BuyStock), the server proactively broadcasts a realtime notification to any connected clients listening to that query. - Cache Update: When the notification arrives, the JS SDK treats it just like an ad-hoc query execution. The local cache is instantly updated with the new data.
- UI Reactivity: The SDK automatically fires the
onNextcallbacks for all active subscribers, causing your React state to update and your UI to re-render "in real time".
Add @refresh triggers to your queries
To enable this on the backend, we need to add the @refresh directive to our queries.
- Open
dataconnect/friendly-exchange/queries.gql. - Update your existing queries by attaching
@refreshdirectives for every market-altering mutation. For example, updateGetDashboardDataandGetUserProfile:
# Get dashboard data including top emojis by price and recent market events
query GetDashboardData
@auth(
level: PUBLIC
insecureReason: "This operation is safe to expose to the public."
)
@refresh(onMutationExecuted: { operation: "BuyStock" })
@refresh(onMutationExecuted: { operation: "SellStock" })
@refresh(onMutationExecuted: { operation: "TriggerEvent" })
@refresh(onMutationExecuted: { operation: "MarketMakerTrade" }) {
emojis(orderBy: [{ currentPrice: DESC }]) {
id
symbol
name
description
currentPrice
trend
}
events(orderBy: [{ createdAt: DESC }], limit: 15) {
id
description
impact
createdAt
user {
username
profileImage
}
emoji {
symbol
}
}
}
# Get current authenticated user profile and their stock ownership using auth.uid
query GetUserProfile
@auth(level: USER)
@refresh(onMutationExecuted: { operation: "BuyStock" })
@refresh(onMutationExecuted: { operation: "SellStock" })
@refresh(onMutationExecuted: { operation: "UpdateUserLocation" })
@refresh(onMutationExecuted: { operation: "UpdateUserRole" }) {
user(id_expr: "auth.uid") {
points
username
profileImage
role
stockOwnerships_on_user {
shares
emoji {
id
symbol
currentPrice
name
}
}
city
latitude
longitude
}
}
Key Takeaways
@refresh(onMutationExecuted: ...): Instructs the server to re-evaluate this query and push new data to subscribers whenever the specified mutation occurs.
Generate the SDK
Because you have defined new queries and mutations in your GraphQL files, you must run the SDK generator so your TypeScript frontend can call it.
Open your terminal and run:
firebase dataconnect:sdk:generate
Handle Realtime Subscriptions in the Web App
We already laid the groundwork for this in the previous section by using the subscribe method. Let's look closer at how the generated SDK handles this in React.
If you open app/src/app/page.tsx (the Home page), you will see the useEffect hook managing the dashboard data:
import { subscribe } from "@firebase/data-connect";
import { getDashboardDataRef } from "@dataconnect/generated";
// ... inside the component
useEffect(() => {
const queryRef = getDashboardDataRef();
// The subscribe function registers the QueryRef and callbacks
const unsubscribe = subscribe(
queryRef,
(res) => {
// onNext: Fires initially, AND whenever a @refresh trigger occurs
if (res.data) setDashboardData(res.data);
setIsDashboardLoading(false);
},
(err) => {
// onError: Handles any server or permission errors
console.error("Dashboard Realtime Error:", err);
setIsDashboardLoading(false);
}
);
// onComplete/Cleanup: Unregisters the callbacks when the component unmounts
return () => unsubscribe();
}, [user]);
Key Takeaways
subscribe(queryRef, onNext, onError): Enables Realtime notifications for the specificQueryRef.unsubscribe(): Callingsubscribereturns a cleanup function. It is critical to return this in youruseEffectso that when the component unmounts (e.g., the user navigates away), the subscription is canceled and memory leaks are prevented.- Caching Efficiency: If multiple components subscribe to the same query (like
GetDashboardData), the SDK shares the cached result. When a Realtime notification arrives, the cache updates once, and all active subscribers are notified automatically.
See it in action
Because you've added @refresh to your backend and subscribe to your frontend, your app is now fully reactive.
- Open your web app in two separate browser windows side-by-side.
- In one window, purchase a few shares of an emoji.
- Watch the second window—without refreshing the page, you will instantly see the emoji's price increase!
9. Conclusion
Congratulations, you've successfully built and deployed a realtime, highly complex trading platform directly on top of PostgreSQL using Firebase SQL Connect!
By utilizing SQL Connect, you were able to:
- Define a strictly-typed GraphQL schema that maps directly to PostgreSQL.
- Enforce granular, row-level security using Firebase Authentication and @auth directives.
- Leverage advanced Native SQL to query geospatial data with PostGIS and write atomic market transactions via CTEs.
- Make your entire application reactive using the @refresh directive for realtime subscriptions.
- Seamlessly generate frontend SDKs to keep your client code synced with your database.
If you want to play with your own market data, feel free to insert your own mock emojis, locations, and pricing histories using the Firebase SQL Connect extension by mimicking the .gql seed files, or add them through the SQL Connect execution pane in VS Code.
10. Deploy to Cloud
Now that you've worked through the local development iteration, it's time to deploy your schema, data, and queries to the server. This can be done using the Firebase SQL Connect VS Code extension or the Firebase CLI.
Set up Firebase Authentication in your Firebase project
- Set up Firebase Authentication with Google Sign-In.
- (Optional) Allow domains for Firebase Authentication using the Firebase console (for example,
http://127.0.0.1).- In the Authentication settings, go to Authorized Domains.
- Click "Add Domain" and include your local domain in the list.
Enable required PostgreSQL Extensions
Because this app utilizes PostgreSQL extensions for vector search and location tracking, you must manually enable them on your provisioned Cloud SQL instance before deploying your schema.
- Navigate to the Google Cloud Console.
- Go to Cloud SQL -> select your provisioned instance -> click Cloud SQL Studio.
- Log into your database and execute the following commands:
# Required for the Geo Map page
CREATE EXTENSION IF NOT EXISTS postgis;
# Required for Vector Search
CREATE EXTENSION IF NOT EXISTS "vector";
# Required for automatic Vector Search embedding generation
CREATE EXTENSION IF NOT EXISTS "google_ml_integration";
Build your web app for hosting
Back in VS Code, ensure you have placed your firebaseConfig variables in lib/firebase.tsx (as done in the setup section).
Next, guarantee that your frontend is using the latest generated hooks by running:
firebase dataconnect:sdk:generate
Then, build the React web app for hosting deployment:
npm run build
Deploy with the Firebase CLI
In dataconnect/dataconnect.yaml, ensure that your instance ID, database, and service ID match your actual Google Cloud project identifiers, and use the v1 specification:
specVersion: v1
serviceId: your-project-id-service
location: us-west4
schemas:
- source: ./schema
datasource:
postgresql:
database: your-project-id-database
cloudSql:
instanceId: your-project-id-instance
connectorDirs:
- ./friendly-exchange
In your terminal, run the following command to deploy:
firebase deploy --only dataconnect,hosting
For updates or refactors, run this command to compare your schema changes:
firebase dataconnect:sql:diff
If the changes are acceptable, apply them with:
firebase dataconnect:sql:migrate
Your Cloud SQL for PostgreSQL instance will be updated with the final deployed schema and data. You should now be able to see your app live at your-project.web.app/.
Learn more
11. Optional: Vector search with Firebase SQL Connect (billing required)
In this section, you'll enable vector search in your emoji exchange using Firebase SQL Connect. This feature allows for semantic, content-based searches, such as finding emojis that match a vibe or concept using vector embeddings.
This step requires that you completed the last step of this codelab to deploy to Google Cloud.
Update the schema to include embeddings for a field
In dataconnect/schema/schema.gql, add the descriptionEmbedding field to your Emoji table. Replace your existing Emoji type with this updated version:
# Emojis
# emoji-stockOwnership is a one-to-many relationship, emoji-priceHistory is a one-to-many relationship
# Implements @searchable directives for full-text search
# Optional: implements Vector type for semantic search
type Emoji @table {
id: UUID! @default(expr: "uuidV4()")
symbol: String!
name: String! @searchable
tags: [String!]
description: String! @searchable
descriptionEmbedding: Vector @col(size: 768)
currentPrice: Float! @default(value: 10.0)
trend: Float! @default(value: 0.0)
}
Key Takeaways
descriptionEmbedding: Vector @col(size: 768): This field stores the semantic embeddings of your emoji descriptions, enabling vector-based content search in your app.
Add a vector search query
In dataconnect/friendly-exchange/queries.gql, add the following query to perform vector searches:
# Search emoji descriptions using Vertex AI embeddings
query VectorSearchEmojis($query: String!)
@auth(
level: PUBLIC
insecureReason: "This operation is safe to expose to the public."
)
@refresh(onMutationExecuted: { operation: "BuyStock" })
@refresh(onMutationExecuted: { operation: "SellStock" })
@refresh(onMutationExecuted: { operation: "TriggerEvent" })
@refresh(onMutationExecuted: { operation: "MarketMakerTrade" }) {
emojis_descriptionEmbedding_similarity(
compare_embed: { model: "text-multilingual-embedding-002", text: $query }
method: COSINE
within: 2
limit: 15
) {
id
symbol
name
description
currentPrice
trend
_metadata {
distance
}
}
}
Key Takeaways:
compare_embed: Specifies the embedding model (text-multilingual-embedding-002) and the input text ($query) for comparison.method: Specifies the similarity method (COSINE), measuring the cosine similarity between the vectors.within: Limits the search to emojis with a distance of 2 or less, focusing on close content matches.
Generate the SDK
Because you have defined new queries and mutations in your GraphQL files, you must run the SDK generator so your TypeScript frontend can call it.
Open your terminal and run:
firebase dataconnect:sdk:generate
Activate Vertex AI and re-deploy
- Follow the prerequisites guide to set up Vertex AI APIs from Google Cloud. This step is essential to support the embedding generation.
- Re-deploy your schema to activate
pgvectorand vector search by runningfirebase deploy --only dataconnector clicking "Deploy to Production" using the Firebase SQL Connect VS Code extension.
Populate the database with embeddings
- Open the
dataconnectfolder in VS Code. - Click Run (Production) in
optional_vector_seed.gqlto populate your deployed database with the 768-dimensional embeddings for the emojis.
Implement the vector search function in your app
Now that the schema and query are set up, integrate the vector search into your app's frontend.
In app/src/app/page.tsx (your Home component), review the useEffect that listens to the search input and swaps dynamically between full-text search and vector search based on the user's selected searchMode:
import { subscribe } from "@firebase/data-connect";
import {
getDashboardDataRef,
searchEmojisRef,
vectorSearchEmojisRef, // <-- Add this!
getChronologicalTickerRef,
getUserProfileRef,
} from "@dataconnect/generated";
// Inside Home component, find the search useeffect
useEffect(() => {
if (!debouncedSearch) {
setSearchData(null);
return;
}
let unsubscribe: () => void;
if (searchMode === "TEXT") {
// Subscribe to realtime full-text search results for emojis based on user input
unsubscribe = subscribe(
searchEmojisRef({ query: debouncedSearch }),
(res) => {
if (res.data) setSearchData(res.data.emojis_search);
setIsSearchLoading(false);
},
(err) => {
console.error("Text Search Error:", err);
setIsSearchLoading(false);
},
);
} else {
// Subscribe to realtime vector search results using semantic similarity for emojis based on user input
unsubscribe = subscribe(
vectorSearchEmojisRef({ query: debouncedSearch }),
(res) => {
if (res.data)
setSearchData(res.data.emojis_descriptionEmbedding_similarity);
setIsSearchLoading(false);
},
(err) => {
console.error("Vector Search Error:", err);
setIsSearchLoading(false);
},
);
}
return () => {
if (unsubscribe) unsubscribe();
};
}, [debouncedSearch, searchMode]);
See it in action
Navigate to the search bar on your app's homepage. Type in abstract phrases like "happy", "nature", or "technology". Toggle the search mode from TEXT to VECTOR and notice how the results shift from exact string matches to contextual, semantic matches returned directly from Vertex AI and PostgreSQL!
12. Optional: Custom Resolvers with Vertex AI (billing required)
10:00
By writing Custom Resolvers, you can extend Firebase SQL Connect to support other data sources and combine them into your unified GraphQL schema. In this section, you'll write a Firebase Cloud Function that uses Vertex AI (Gemini) to generate a satirical financial news headline whenever a user makes a large trade, and expose that function through SQL Connect.
Initialize the custom resolver
Instead of creating all the boilerplate files manually, the Firebase CLI has a built-in generator for custom resolvers.
Open your terminal in the root of your project and run:
firebase init dataconnect:resolver
When prompted by the CLI:
- Enter
generateTradeHeadlineas the name for your custom resolver. - Select TypeScript to generate the example implementation.
The CLI will automatically create a new dataconnect/schema_generateTradeHeadline/schema.gql file, initialize a functions directory with sample code, and link the resolver in your dataconnect.yaml configuration!
Define the custom resolver schema
Next, you need to define the exact shape of your custom endpoint using a GraphQL schema.
Open the newly generated dataconnect/schema_generateTradeHeadline/schema.gql file and replace its contents with the following code:
# Custom resolver fields can be defined on root Query and Mutation types.
type Mutation {
# This field will be backed by your Cloud Function.
generateTradeHeadline(
emojiSymbol: String!
emojiName: String!
username: String!
tradeAmount: Int!
tradeCost: Float!
tradeType: String!
): String!
}
Key Takeaways:
- By placing this inside the root
type Mutation, you are telling SQL Connect that this operation might have side-effects (like calling an AI API) rather than just reading data.
Implement the custom resolver logic
Next, implement your resolver using Cloud Functions. Under the hood, you are creating a GraphQL server; however, Cloud Functions provides a helper method, onGraphRequest, that handles the boilerplate so you only need to write the core logic.
Open your Firebase Functions file (functions/src/index.ts), which the CLI generated for you. Replace the entire file with the Gemini API implementation:
import { setGlobalOptions } from "firebase-functions";
import {
FirebaseContext,
onGraphRequest,
} from "firebase-functions/dataconnect/graphql";
import { initializeApp, getApps } from "firebase-admin/app";
import { GoogleGenAI } from "@google/genai";
setGlobalOptions({
maxInstances: 10,
region: "us-west4",
});
if (getApps().length === 0) {
initializeApp();
}
const ai = new GoogleGenAI({
vertexai: true,
project: process.env.GCLOUD_PROJECT || "your-project-id",
location: process.env.GCLOUD_LOCATION || "us-west4",
});
const headlineOpts = {
// Points to the schema you defined earlier
schemaFilePath: "dataconnect/schema_generateTradeHeadline/schema.gql",
resolvers: {
mutation: {
// Generate a satirical financial news headline for a stock trade using Vertex AI
async generateTradeHeadline(
_parent: unknown,
args: Record<string, unknown>,
_contextValue: FirebaseContext,
_info: unknown,
): Promise<string> {
const {
emojiSymbol,
emojiName,
username,
tradeAmount,
tradeCost,
tradeType,
} = args;
try {
const prompt = `You are a hype-driven, satirical financial news bot.
A user named '${username}' just executed a massive ${tradeType} of ${tradeAmount} shares of ${emojiSymbol} (${emojiName}) for $${tradeCost}.
Write a single, punchy, dramatic news headline (under 12 words) about this market move, use puns wherever possible, but don't round or exagerate the numbers. Include the asset symbol.`;
const response = await ai.models.generateContent({
model: "gemini-2.5-flash-lite",
contents: prompt,
});
if (!response.text) {
throw new Error("No text returned from Vertex AI");
}
return response.text.trim();
} catch (error) {
console.error("Vertex AI generation failed:", error);
return `BREAKING: Massive ${tradeType} detected on ${emojiSymbol}! Market reacting.`;
}
},
},
},
};
export const generateTradeHeadline = onGraphRequest(headlineOpts);
重要なポイント:
onGraphRequest: Cloud Functions を SQL Connect カスタム リゾルバ スキーマにマッピングする特殊な Firebase Functions ラッパー。args: GraphQL ミューテーションから渡された引数は、ここで自動的に型指定されて抽出され、Gemini プロンプトに挿入されます。
コネクタにミューテーションを追加する
カスタム リゾルバ ロジックが存在するようになったので、フロントエンドが呼び出せるように、アプリケーションのコネクタを介して公開します。
dataconnect/friendly-exchange/mutations.gql を開き、ミューテーションを追加します。
# Generate an AI headline for a stock trade
mutation GenerateTradeHeadline(
$emojiSymbol: String!
$emojiName: String!
$username: String!
$tradeAmount: Int!
$tradeCost: Float!
$tradeType: String!
)
@auth(
level: USER
insecureReason: "This operation is safe to expose to any authenticated user."
) {
aiHeadline: generateTradeHeadline(
emojiSymbol: $emojiSymbol
emojiName: $emojiName
username: $username
tradeAmount: $tradeAmount
tradeCost: $tradeCost
tradeType: $tradeType
)
}
SDK をデプロイして生成する
カスタム リゾルバは Cloud Functions を介して実行されるため、エンドポイントを有効にするには、関数を Google Cloud にデプロイする必要があります。
ターミナルを開いて、関数をデプロイします。
firebase deploy --only functions
デプロイしたら、新しい AI ミューテーションを含めるようにフロントエンド SDK を生成します。
firebase dataconnect:sdk:generate
ウェブアプリに AI Resolver を統合する
10 株以上の取引があった場合に速報アラートがトリガーされるように設定しましょう。
lib/ExchangeService.tsx を開きます。まず、上部に generateTradeHeadline と triggerEvent をインポートします。
import {
buyStock,
sellStock,
generateTradeHeadline,
triggerEvent
} from "@dataconnect/generated";
次に、executeBuyStock 関数の下部までスクロールし、関数の終了直前の TODO を AI トリガー ブロックに置き換えます。
// ... (existing executeBuyStock code)
const actualImpact = Array.isArray(buyResult)
? buyResult[0].actual_impact
: estimatedImpact;
if (amount >= 10 && user) {
setTimeout(() => {
logEvent("GENERATE_HEADLINE_RESOLVER");
}, 2000);
const headlineResult = await generateTradeHeadline({
emojiSymbol: emoji.symbol,
emojiName: emoji.name,
username: user.displayName || "Anonymous Whale",
tradeAmount: amount,
tradeCost: actualCost.toFixed(2),
tradeType: "BUY",
});
await triggerEvent({
emojiId: emoji.id,
impact: actualImpact.toFixed(2),
description: `GEMINI REPORT: ${headlineResult.data?.aiHeadline}`,
now: new Date().toISOString(),
});
}
};
executeSellStock 関数の末尾でも同じ処理を行います。
// ... (existing executeSellStock code)
const actualImpact = Array.isArray(sellResult)
? sellResult[0].actual_impact
: estimatedImpact;
if (amount >= 10 && user) {
const headlineResult = await generateTradeHeadline({
emojiSymbol: emoji.symbol,
emojiName: emoji.name,
username: user.displayName || "Anonymous Whale",
tradeAmount: amount,
tradeCost: actualRevenue.toFixed(2),
tradeType: "SELL",
});
await triggerEvent({
emojiId: emoji.id,
impact: actualImpact.toFixed(2),
description: `GEMINI REPORT: ${headlineResult.data?.aiHeadline}`,
now: new Date().toISOString(),
});
}
};
実例を見る
- ウェブアプリを再読み込みします。
- ログインしていることと、十分な通貨があることを確認してください。
- 絵文字を選択して、10 個以上のシェアを一度に購入します。
- ダッシュボードの右側にあるグローバル マーケット ティッカーを確認します。数秒以内に、Gemini が生成したカスタムの風刺的なニュースの見出しが表示されます。