إنشاء تطبيق تداول في الوقت الفعلي باستخدام Firebase SQL Connect (على الويب)

1. قبل البدء

في هذا الدرس التطبيقي حول الترميز، ستدمج Firebase SQL Connect مع قاعدة بيانات Cloud SQL لإنشاء Friendly Exchange، وهو تطبيق ويب لسوق الأسهم في الوقت الفعلي.

يعرض التطبيق المكتمل ميزات SQL Connect المتقدّمة، بما في ذلك:

  • SQL الأصلي: يمكنك تنفيذ عبارات معقّدة بلغة معالجة البيانات (DML) وتعبيرات الجداول المشتركة (CTE) بأمان باستخدام _execute و_select.
  • طرق عرض SQL: يمكنك إنشاء عناصر GraphQL صارمة وآمنة من حيث النوع تستند إلى طلبات بحث Postgres ديناميكية باستخدام التوجيه @view.
  • الاشتراكات في الوقت الفعلي: يمكنك الحفاظ على مزامنة واجهة المستخدم الأمامية باستخدام مشغّلات @refresh.
  • المعاملات الذرية: يمكنك ربط عمليات متعددة والتحقّق من الحالة باستخدام @transaction و@check.
  • (اختياري) البحث الجغرافي المكاني والبحث المتّجه: يمكنك الاستفادة من PostGIS وpgvector للعثور على مواد عرض رائجة بالقرب من إحداثيات المستخدم وإجراء عمليات بحث دلالية.
  • (اختياري) أدوات تحليل مخصّصة: يمكنك ربط منطق Cloud Run المخصّص بمخطط GraphQL لإنشاء عناوين أخبار تجارية مستنِدة إلى الذكاء الاصطناعي.

المتطلبات الأساسية

يجب أن يكون لديك فهم جيد لـ JavaScript/TypeScript وReact وبنية SQL الأساسية.

ما ستتعلمه

  • كيفية استخدام Native SQL لسدّ الفجوة بين GraphQL الإعلاني ومنطق PostgreSQL الأولي
  • كيفية دمج إضافات Postgres، مثل PostGIS، مباشرةً في طلبات البحث في قاعدة البيانات
  • كيفية فرض منطق معقّد باستخدام كتل @transaction ذرية
  • كيفية إنشاء @views آمنة من حيث النوع للوحات الصدارة والإحصاءات
  • كيفية إعداد الاشتراكات في الوقت الفعلي باستخدام @refresh

المتطلبات

  • Git
  • Visual Studio Code
  • تثبيت Node.js
  • مشروع Firebase على خطة التسعير Blaze بنظام الدفع حسب الاستخدام (مطلوب لخدمتَي Custom Resolvers وVertex AI)

2. إعداد بيئة التطوير

ترشدك هذه المرحلة خلال عملية إعداد الواجهة الأمامية وضبط نسخة Cloud SQL الافتراضية للاستفادة من الميزات المتقدّمة.

  1. أنشئ نسخة طبق الأصل من مستودع المشروع وثبِّت التبعيات المطلوبة للتطبيق:
git clone https://github.com/firebaseextended/codelab-dataconnect-web
cd codelab-dataconnect-web
git switch emoji-init
npm install
  1. افتح المجلد المستنسخ باستخدام Visual Studio Code وثبِّت إضافة Firebase SQL Connect Visual Studio.
  2. في نافذة الأوامر، تأكَّد من أنّ أداة Firebase CLI محدّثة بالكامل (هذا الإجراء ضروري للاستفادة من الميزات الجديدة، مثل @refresh وNative SQL):
npm uninstall -g firebase-tools
npm install -g firebase-tools
firebase login
firebase use your-project-id
firebase init

(اختَر "الاستضافة" و"المصادقة" و"الاتصال بلغة SQL").

إنشاء حِزم SDK لـ SQL Connect: نفِّذ الأمر التالي:

firebase dataconnect:sdk:generate
  1. ربط تطبيق الويب بمشروع Firebase: سجِّل تطبيق الويب في مشروع Firebase باستخدام وحدة تحكّم Firebase:
    1. افتح مشروعك، ثم انقر على إضافة تطبيق (اختَر رمز الويب).
    2. تجاهَل إعداد حزمة تطوير البرامج (SDK) وإعدادات الضبط في الوقت الحالي، ولكن احرص على نسخ العنصر firebaseConfig الذي تم إنشاؤه.
    3. افتح ملف 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"
};
  1. شغِّل خادم التطوير:
npm run dev

3- مراجعة قاعدة الرموز البرمجية الأولية

في هذا القسم، سنتعرّف على المجالات الرئيسية لقاعدة الرموز الأولية للتطبيق. مع أنّك ستكتب المخطط والاستعلامات من البداية، من المفيد فهم كيفية ربط الواجهة الأمامية للتفاعل مع SQL Connect.

بنية المجلدات والملفات

دليل dataconnect/

يحتوي هذا المجلد على تعريف الخلفية، أي كل شيء بدءًا من بنية قاعدة البيانات إلى طلبات بحث SQL المحدّدة التي يُسمح لتطبيقك بتنفيذها.

  • schema/schema.gql: المكان الذي ستحدّد فيه جداول Postgres الأساسية باستخدام أنواع GraphQL العادية.
  • schema/views.gql: المكان الذي ستحدّد فيه طرق عرض SQL المعقّدة للقراءة فقط (مثل لوحات الصدارة) باستخدام التوجيه @view.
  • friendly-exchange/queries.gql وmutations.gql: هما "الموصلان". هنا ستحدّد الاستعلامات الدقيقة وNative SQL (_execute و_select) التي يسمح بها تطبيقك.
  • dataconnect.yaml: ملف الإعداد الذي يحدّد إعدادات إنشاء حزمة تطوير البرامج (SDK) ونشر Cloud SQL.

دليل lib/

يحتوي على منطق التطبيق والمصادقة والتفاعل مع حزمة تطوير البرامج (SDK) الخاصة بـ Firebase SQL Connect.

  • 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 تاجرًا في النظام، ويتتبّع رصيده ودوره وموقعه الجغرافي للاستعلامات الجغرافية المكانية.

انسخ مقتطف الرمز التالي وألصقه في ملف 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: يتم الربط مباشرةً بخدمة "مصادقة Firebase" باستخدام @default(expr: "auth.uid"). يضمن ذلك أن تكون هوية قاعدة البيانات وهوية Auth متطابقتَين بشكل آمن، ما يمنع المستخدمين من تزييف المعرّفات.
  • 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") لإنشاء أرقام تعريف فريدة تلقائيًا وفرض الملكية بدون الحاجة إلى أن يقدّم تطبيق العميل هذه التعبيرات.

5- استرداد بيانات السوق والمستخدمين

في هذا القسم، ستُدرج بيانات سوق وهمية في قاعدة البيانات، ثم ستنفّذ أدوات الربط (طلبات البحث) ورمز TypeScript لاستدعاء أدوات الربط هذه في جميع أنحاء تطبيق الويب. في النهاية، سيتمكّن تطبيقك من استرداد وعرض سوق رموز الإيموجي المباشر وملفات المستخدمين الشخصية وقوائم الصدارة بشكل ديناميكي مباشرةً من قاعدة البيانات.

إدراج بيانات سوق وبيانات المستخدمين الوهمية

  1. في VSCode، افتح dataconnect/seed.gql.
  2. تأكَّد من أنّ المحاكيات في إضافة Firebase SQL Connect تعمل (أو أنّ مثيل Cloud SQL مرتبط).
  3. من المفترض أن يظهر لك زر تشغيل (محلي) أو تشغيل (إنتاج) CodeLens في أعلى الملف. انقر على هذا الزر لإدراج بيانات الرموز التعبيرية الوهمية وسجلّات الأسعار الأولية في قاعدة البيانات.
  4. راجِع نافذة "تنفيذ اتصال SQL" للتأكّد من أنّه تمت إضافة البيانات بنجاح.

تنفيذ طلبات البحث الأساسية

أولاً، لنطلب البحث في الجداول العادية التي حدّدتها في المخطط.

  1. افتح dataconnect/friendly-exchange/queries.gql.
  2. أضِف طلبات البحث التالية لاسترداد بيانات لوحة البيانات والملفات الشخصية للمستخدمين وسجلّات الأسعار الأساسية:
# 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 العادي: الأفضل لعمليات الإنشاء والقراءة والتعديل والحذف الأساسية والعلاقات البسيطة مع منع أخطاء الكتابة الصارم من البداية إلى النهاية.
  • طرق عرض SQL (@view): هي الأنسب للاستعلامات المعقّدة بلغة SQL للقراءة فقط (مثل لوحات الصدارة التي تستخدم دوال النافذة) حيث تريد أن يتم عرض عنصر GraphQL صارم وآمن من حيث النوع للعميل.
  • SQL الأصلي (_execute / _select): هو الخيار الأفضل لتنفيذ لغة معالجة البيانات (DML) أو تعبيرات الجدول الشائعة (CTE) أو إضافات PostGIS مباشرةً. يمكنك استبدال الكتابة الصارمة في وقت الترجمة بمرونة قصوى في وقت التنفيذ (إرجاع JSON ديناميكي).

لإنشاء قوائم الصدارة ومخططات الخطوط البيانية، علينا احتساب المتوسطات المتحركة وترتيب المستخدمين. هذه حالة استخدام لـ @view.

  1. افتح dataconnect/schema/views.gql.
  2. أضِف طرق العرض التالية لاحتساب الإحصاءات اللازمة على الخادم:
# 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 واستبدِل TODOs لجلب البيانات من طرق العرض الجديدة:

# 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 حقول GraphQL في نوع @view بالأعمدة التي تعرضها عبارة 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: حقل طلب بحث تم إنشاؤه تلقائيًا لأنّك طبّقت @searchable على الحقلَين name وdescription في مخطط Emoji.

إنشاء حزمة تطوير البرامج (SDK)

بما أنّك حدّدت طلبات بحث وطرق عرض جديدة في ملفات GraphQL، عليك تشغيل أداة إنشاء حزمة SDK حتى يتمكّن تطبيق TypeScript من استخدامها بأمان.

افتح الوحدة الطرفية ونفِّذ الأمر التالي:

firebase dataconnect:sdk:generate

دمج طلبات البحث في تطبيق الويب

يُنشئ برنامج التجميع Firebase SQL Connect حِزم تطوير البرامج (SDK) استنادًا إلى ملفات .gql. بما أنّ هذا التطبيق مصمّم ليكون تطبيقًا في الوقت الفعلي، ستستخدم طريقة 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

, uعدِّل الخطافات:

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: هي إحدى طرق حزمة تطوير البرامج (SDK) الخاصة بـ SQL Connect التي تستمع إلى طلب البحث. في الوقت الحالي، يتم جلب البيانات ببساطة عند تحميل المكوّن، ولكن في خطوة لاحقة، سنعمل على ترقية الخلفية لتشغيل هذه الوظيفة تلقائيًا كلما تغيرت قاعدة البيانات.
  1. لوحة السوق (Market Panel) (components/MarketPanel.tsx): بالمثل، في مكوّن MarketPanel (components/MarketPanel.tsx)، يمكنك استبدال TODOs لطلب طلبات بحث متعددة في وقت واحد لإنشاء الشريط الجانبي.
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();
  }, []);


  1. صفحة قائمة الصدارة (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();
  }, []);

  1. نافذة رموز الإيموجي (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. ستستخدم أيضًا توجيهَي @transaction و@check في SQL Connect لتنفيذ حدث سوق ذي خطوات متعددة بشكل آمن.

تنفيذ موصّلات المستخدمين والمواقع الجغرافية

فتح 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": تستخدِم هذه السمة auth.uid، والتي يتم توفيرها مباشرةً من خلال الرمز المميّز لخدمة "مصادقة Firebase". من خلال تقييم ذلك من جهة الخادم، يمكنك التأكّد من أنّ المستخدم يمكنه تعديل بيانات ملفه الشخصي فقط، ما يضيف طبقة أمان غير قابلة للاختراق.

ربط العمليات المنطقية باستخدام @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، عليك تشغيل أداة إنشاء حزمة تطوير البرامج (SDK) حتى يتمكّن الواجهة الأمامية TypeScript من استدعائها.

افتح الوحدة الطرفية ونفِّذ الأمر التالي:

firebase dataconnect:sdk:generate

دمج التغييرات في تطبيق الويب

في تطبيق الويب، ستغلّف عمليات التعديل التي تم إنشاؤها في حزمة تطوير البرامج (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:

  1. Navigate to your Profile and test out the Auto-Locate button. When you click Update Coordinates, the UpdateUserLocation mutation will execute.
  2. Open the Floating Control Panel (the purple icon in the bottom right corner).
  3. Click USER and switch your authorization level to ADMIN.
  4. Click Trigger random market activity. Because your role is now 'ADMIN', the @check directive passes, the @transaction executes, 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.

  1. Navigate to the Google Cloud Console.
  2. Go to Cloud SQL -> select your provisioned instance -> click Cloud SQL Studio.
  3. 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.

  1. Open dataconnect/friendly-exchange/queries.gql.
  2. Add the following Native SQL queries using the _select field:
# 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 $userLng are 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 as UPDATE, INSERT, or DELETE.
  • Common Table Expressions (WITH): Each block in the CTE depends on the previous one. For example, add_funds will only execute if check_shares returns 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

  1. 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.
  2. Execute Trades: In lib/ExchangeService.tsx, we wrap the generated buyStock and sellStock SDKs. Notice how the return types buyResult and sellResult must be manually validated as arrays, because _execute returns dynamic JSON data based on your specific RETURNING clauses in the SQL strings.
  3. Replace the empty executeBuyStock and executeSellStock functions 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

  1. In your browser, navigate to the Geo page from the top navigation bar.
  2. If your location is correctly set in your Profile, the Global Top Assets map will ping the GetTopEmojisByCity native query to drop pins on cities with high trade volumes.
  3. Click Scan Local Network. The Local Radar Scanner will ask for your browser's location and ping the GetTrendingEmojisNearMe native query, utilizing PostGIS to find the top assets specifically traded within 50km of your coordinates!
  4. 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 _execute queries.

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:

  1. Trigger (@refresh): You tell the SQL Connect backend which specific mutations should trigger a data refresh for a given query.
  2. 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.
  3. 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.
  4. UI Reactivity: The SDK automatically fires the onNext callbacks 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.

  1. Open dataconnect/friendly-exchange/queries.gql.
  2. Update your existing queries by attaching @refresh directives for every market-altering mutation. For example, update GetDashboardData and GetUserProfile:
# 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 specific QueryRef.
  • unsubscribe(): Calling subscribe returns a cleanup function. It is critical to return this in your useEffect so 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.

  1. Open your web app in two separate browser windows side-by-side.
  2. In one window, purchase a few shares of an emoji.
  3. 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

  1. Set up Firebase Authentication with Google Sign-In.
  2. (Optional) Allow domains for Firebase Authentication using the Firebase console (for example, http://127.0.0.1).
    1. In the Authentication settings, go to Authorized Domains.
    2. 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.

  1. Navigate to the Google Cloud Console.
  2. Go to Cloud SQL -> select your provisioned instance -> click Cloud SQL Studio.
  3. 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

  1. Follow the prerequisites guide to set up Vertex AI APIs from Google Cloud. This step is essential to support the embedding generation.
  2. Re-deploy your schema to activate pgvector and vector search by running firebase deploy --only dataconnect or clicking "Deploy to Production" using the Firebase SQL Connect VS Code extension.

Populate the database with embeddings

  1. Open the dataconnect folder in VS Code.
  2. Click Run (Production) in optional_vector_seed.gql to 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:

  1. Enter generateTradeHeadline as the name for your custom resolver.
  2. 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: برنامج تضمين مخصّص لـ وظائف Firebase يربط إحدى دوال Cloud Function بمخطط SQL Connect Custom Resolver.
  • 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 للواجهة الأمامية لتضمين التغيير الجديد في الذكاء الاصطناعي:

firebase dataconnect:sdk:generate

دمج أداة حلّ المشاكل المستندة إلى الذكاء الاصطناعي في تطبيق الويب

لنضبط هذا الإعداد حتى يؤدي أي تداول لـ 10 أسهم أو أكثر إلى إطلاق تنبيه بشأن الأخبار العاجلة.

فتح lib/ExchangeService.tsx تأكَّد أولاً من استيراد generateTradeHeadline وtriggerEvent في أعلى الصفحة:

import { 
  buyStock, 
  sellStock, 
  generateTradeHeadline, 
  triggerEvent 
} from "@dataconnect/generated";

بعد ذلك، انتقِل للأسفل إلى نهاية دالة executeBuyStock واستبدِل TODO بمجموعة مشغّل الذكاء الاصطناعي قبل انتهاء الدالة مباشرةً:

// ... (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(),
    });
  }
};

مثال عملي

  1. أعِد تحميل تطبيق الويب.
  2. تأكَّد من تسجيل الدخول وتوفّر عملة كافية.
  3. اختَر إيموجي واشترِ 10 مشاركات أو أكثر في المرة الواحدة.
  4. شاهِد "شريط أسعار الأسواق العالمية" على يسار لوحة البيانات. في غضون بضع ثوانٍ، سيظهر عنوان رئيسي مخصّص للأخبار الساخرة من إنشاء Gemini.