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 구문에 대한 확실한 이해가 필요합니다.
학습 내용
- 선언적 GraphQL과 원시 PostgreSQL 로직 간의 격차를 해소하기 위해 네이티브 SQL을 사용하는 방법
- 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
(호스팅, 인증, SQL 연결 선택)
SQL Connect SDK 생성: 다음 명령어를 실행합니다.
firebase dataconnect:sdk:generate
- 웹 앱을 Firebase 프로젝트에 연결: Firebase Console을 사용하여 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 앱, 인증, 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 유형은 시스템의 거래자를 정의하여 지리 공간 쿼리를 위해 잔액, 역할, 실제 위치를 추적합니다.
다음 코드 스니펫을 복사하여 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 인증에 직접 바인딩됩니다. 이렇게 하면 데이터베이스 ID와 인증 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"]: 복합 기본 키를 만듭니다. 사용자는 동일한 그림 이모티콘에 대해 별도의 두 레코드를 가질 수 없습니다. 쌍당 고유성이 적용됩니다.- 암시적 참조:
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 실행 터미널을 확인하여 데이터가 성공적으로 추가되었는지 확인합니다.
기본 쿼리 구현
먼저 스키마에서 정의한 표준 테이블을 쿼리해 보겠습니다.
- 열기
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는 하나의 쿼리로 사용자의 전체 포트폴리오를 가져옵니다.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 인증을 사용하여 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 인증 토큰에서 직접 제공하는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: 모든 데이터베이스 작업 (재고 삽입, 그림 이모티콘 가격 업데이트, 이벤트 로깅)이 함께 성공하거나 함께 실패하도록 합니다.@check: 계속하기 전에 조건을 평가하는 지시문입니다. 여기서는 인증된 사용자의role이'ADMIN'인지 확인합니다. 사용자가 표준'USER'인 경우 전체 트랜잭션이 거부되고 롤백됩니다.@redact: 쿼리 결과 (예: 사용자의 역할 확인)가 응답 페이로드에서 클라이언트로 반환되지 않도록 하여 트랜잭션 응답을 깔끔하게 유지합니다.
SDK 생성
GraphQL 파일에 새 변이를 정의했으므로 TypeScript 프런트엔드에서 이를 호출할 수 있도록 SDK 생성기를 실행해야 합니다.
터미널을 열고 다음을 실행합니다.
firebase dataconnect:sdk:generate
웹 앱에 변이 통합
웹 앱에서는 오류 포착 및 UI 알림을 처리하기 위해 생성된 SDK 변이를 표준 비동기 함수로 래핑합니다.
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 함수를 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
배포가 완료되면 프런트엔드 SDK를 생성하여 새 AI 변형을 포함합니다.
firebase dataconnect:sdk:generate
웹 앱에 AI 리졸버 통합
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가 생성한 맞춤 풍자 뉴스 헤드라인이 표시됩니다.