SKILL DETAIL
firebase-data-connect
firebase/agent-skills/firebase-data-connect
Firebase SQL Connect (formerly Firebase Data Connect) is a relational database service built on Cloud SQL for PostgreSQL, offering a GraphQL schema, auto-generated queries and mutations, and type-safe SDKs. This skill guides users through designing schemas with tables and relations, writing authorized queries and mutations, configuring real-time data updates, and generating type-safe SDKs. The skill covers project structure (dataconnect directory), development workflow (defining data model, operations, SDK generation), deployment and CLI commands, and a feature capability map (e.g., data modeling, vector search, full-text search, transactions, real-time subscriptions). It defaults to native GraphQL operations, using native SQL only when advanced database features (e.g., PostGIS, window functions) are required.
Installation
npx skills add https://github.com/firebase/agent-skills --skill firebase-data-connect
스킬 파일
SKILL.md
최근 동기화 · 2026. 8. 29.
examples.md›
# Examples
Complete, working examples for common SQL Connect use cases.
______________________________________________________________________
## Movie Review App
A complete schema for a movie database with reviews, actors, and user
authentication.
### Schema
```graphql
# schema.gql
# Users
type User @table(key: "uid") {
uid: String! @default(expr: "auth.uid")
email: String! @unique
displayName: String
createdAt: Timestamp! @default(expr: "request.time")
}
# Movies
type Movie @table {
id: UUID! @default(expr: "uuidV4()")
title: String!
releaseYear: Int
genre: String @index
rating: Float
description: String
posterUrl: String
createdAt: Timestamp! @default(expr: "request.time")
}
# Movie metadata (one-to-one)
type MovieMetadata @table {
movie: Movie! @unique
director: String
runtime: Int
budget: Int64
}
# Actors
type Actor @table {
id: UUID! @default(expr: "uuidV4()")
name: String!
birthDate: Date
}
# Movie-Actor relationship (many-to-many)
type MovieActor @table(key: ["movie", "actor"]) {
movie: Movie!
actor: Actor!
role: String! # "lead" or "supporting"
character: String
}
# Reviews (user-owned)
type Review @table @unique(fields: ["movie", "user"]) {
id: UUID! @default(expr: "uuidV4()")
movie: Movie!
user: User!
rating: Int!
text: String
createdAt: Timestamp! @default(expr: "request.time")
}
```
### Queries
```graphql
# queries.gql
# Public: List movies with filtering
query ListMovies($genre: String, $minRating: Float, $limit: Int)
@auth(level: PUBLIC) {
movies(
where: {
genre: { eq: $genre },
rating: { ge: $minRating }
},
orderBy: [{ rating: DESC }],
limit: $limit
) {
id title genre rating releaseYear posterUrl
}
}
# Public: Get movie with full details
query GetMovie($id: UUID!) @auth(level: PUBLIC) {
movie(id: $id) {
id title genre rating releaseYear description
metadata: movieMetadata_on_movie { director runtime }
actors: actors_via_MovieActor { name }
reviews: reviews_on_movie(orderBy: [{ createdAt: DESC }], limit: 10) {
rating text createdAt
user { displayName }
}
}
}
# User: Get my reviews
query MyReviews @auth(level: USER) {
reviews(where: { user: { uid: { eq_expr: "auth.uid" }}}) {
id rating text createdAt
movie { id title posterUrl }
}
}
```
### Mutations
```graphql
# mutations.gql
# User: Create/update profile on first login
mutation UpsertUser($email: String!, $displayName: String) @auth(level: USER) {
user_upsert(data: {
uid_expr: "auth.uid",
email: $email,
displayName: $displayName
})
}
# User: Add review (one per movie per user)
mutation AddReview($movieId: UUID!, $rating: Int!, $text: String)
@auth(level: USER) {
review_upsert(data: {
movie: { id: $movieId },
user: { uid_expr: "auth.uid" },
rating: $rating,
text: $text
})
}
# User: Delete my review
mutation DeleteReview($id: UUID!) @auth(level: USER) {
review_delete(
first: { where: {
id: { eq: $id },
user: { uid: { eq_expr: "auth.uid" }}
}}
)
}
```
### Realtime Queries
```graphql
# queries.gql (realtime additions)
# Auto-refresh: this single-entity lookup refreshes automatically
# when any mutation modifies this specific movie. No @refresh needed.
query GetMovie($id: UUID!) @auth(level: PUBLIC) {
movie(id: $id) {
id title genre rating releaseYear description
metadata: movieMetadata_on_movie { director runtime }
reviews: reviews_on_movie(orderBy: [{ createdAt: DESC }], limit: 10) {
rating text createdAt
user { displayName }
}
}
}
# Event-driven: Simple refresh when any movie is added
query ListMoviesSimple @auth(level: PUBLIC) @refresh(onMutationExecuted: { operation: "AddMovie" }) {
movies { id title }
}
# Counterpart mutation for ListMoviesSimple
mutation AddMovie($title: String!) @auth(level: USER) {
movie_insert(data: { title: $title })
}
# Event-driven: Refresh only when a movie of the same genre is added
# Demonstrates the use of 'condition' and 'mutation.variables'
query ListMoviesByGenre($genre: String!) @auth(level: PUBLIC)
@refresh(onMutationExecuted: {
operation: "AddMovieWithGenre",
condition: "mutation.variables.genre == request.variables.genre"
}) {
movies(where: { genre: { eq: $genre } }) { id title }
}
# Counterpart mutation for ListMoviesByGenre
mutation AddMovieWithGenre($title: String!, $genre: String!) @auth(level: USER) {
movie_insert(data: { title: $title, genre: $genre })
}
# Event-driven: Refresh user profile when updated
# Demonstrates condition based on auth context
query MyProfile @auth(level: USER)
@refresh(onMutationExecuted: {
operation: "UpdateProfile",
condition: "mutation.auth.uid == request.auth.uid"
}) {
user(uid_expr: "auth.uid") { id name }
}
# Counterpart mutation for MyProfile
mutation UpdateProfile($name: String!) @auth(level: USER) {
user_update(id_expr: "auth.uid", data: { name: $name })
}
# Time-based: live leaderboard refreshing every 30 seconds
query MovieLeaderboard
@auth(level: PUBLIC)
@refresh(every: { seconds: 30 }) {
movies(orderBy: [{ rating: DESC }], limit: 10) {
id title rating
}
}
```
```typescript
import { listMoviesRef, movieLeaderboardRef } from '@movie-app/dataconnect';
import { subscribe } from 'firebase/data-connect';
// Subscribe to movie list — refreshes when AddReview mutation runs
const unsubMovies = subscribe(listMoviesRef({ genre: 'Action' }), {
onNext: (result) => updateMovieList(result.data.movies),
onError: (error) => console.error(error)
});
// Subscribe to leaderboard — refreshes every 30 seconds
const unsubLeaderboard = subscribe(movieLeaderboardRef(), {
onNext: (result) => updateLeaderboard(result.data.movies),
onError: (error) => console.error(error)
});
// Cleanup
// unsubMovies();
// unsubLeaderboard();
```
______________________________________________________________________
## E-Commerce Store
Products, orders, and cart management with user authentication.
### Schema
```graphql
# schema.gql
type User @table(key: "uid") {
uid: String! @default(expr: "auth.uid")
email: String! @unique
name: String
shippingAddress: String
}
type Product @table {
id: UUID! @default(expr: "uuidV4()")
name: String! @index
description: String
price: Float!
stock: Int! @default(value: 0)
category: String @index
imageUrl: String
}
type CartItem @table(key: ["user", "product"]) {
user: User!
product: Product!
quantity: Int!
}
enum OrderStatus {
PENDING
PAID
SHIPPED
DELIVERED
CANCELLED
}
type Order @table {
id: UUID! @default(expr: "uuidV4()")
user: User!
status: OrderStatus! @default(value: PENDING)
total: Float!
shippingAddress: String!
createdAt: Timestamp! @default(expr: "request.time")
}
type OrderItem @table {
id: UUID! @default(expr: "uuidV4()")
order: Order!
product: Product!
quantity: Int!
priceAtPurchase: Float!
}
```
### Operations
```graphql
# Public: Browse products
query ListProducts($category: String, $search: String) @auth(level: PUBLIC) {
products(where: {
category: { eq: $category },
name: { contains: $search },
stock: { gt: 0 }
}) {
id name price stock imageUrl
}
}
# User: View cart
query MyCart @auth(level: USER) {
cartItems(where: { user: { uid: { eq_expr: "auth.uid" }}}) {
quantity
product { id name price imageUrl stock }
}
}
# User: Add to cart
mutation AddToCart($productId: UUID!, $quantity: Int!) @auth(level: USER) {
cartItem_upsert(data: {
user: { uid_expr: "auth.uid" },
product: { id: $productId },
quantity: $quantity
})
}
# User: Checkout (transactional)
mutation Checkout($shippingAddress: String!)
@auth(level: USER)
@transaction {
# Query cart items
query @redact {
cartItems(where: { user: { uid: { eq_expr: "auth.uid" }}})
@check(expr: "this.size() > 0", message: "Cart is empty") {
quantity
product { id price }
}
}
# Create order (in real app, calculate total from cart)
order_insert(data: {
user: { uid_expr: "auth.uid" },
shippingAddress: $shippingAddress,
total: 0 # Calculate in app logic
})
}
```
______________________________________________________________________
## Blog with Permissions
Multi-author blog with role-based permissions.
### Schema
```graphql
# schema.gql
type User @table(key: "uid") {
uid: String! @default(expr: "auth.uid")
email: String! @unique
name: String!
bio: String
}
enum UserRole {
VIEWER
AUTHOR
EDITOR
ADMIN
}
type BlogPermission @table(key: ["user"]) {
user: User!
role: UserRole! @default(value: VIEWER)
}
enum PostStatus {
DRAFT
PUBLISHED
ARCHIVED
}
type Post @table {
id: UUID! @default(expr: "uuidV4()")
author: User!
title: String! @searchable
content: String! @searchable
status: PostStatus! @default(value: DRAFT)
publishedAt: Timestamp
createdAt: Timestamp! @default(expr: "request.time")
updatedAt: Timestamp! @default(expr: "request.time")
}
type Comment @table {
id: UUID! @default(expr: "uuidV4()")
post: Post!
author: User!
content: String!
createdAt: Timestamp! @default(expr: "request.time")
}
```
### Operations with Role Checks
```graphql
# Public: Read published posts
query PublishedPosts @auth(level: PUBLIC) {
posts(
where: { status: { eq: PUBLISHED }},
orderBy: [{ publishedAt: DESC }]
) {
id title content publishedAt
author { name }
}
}
# Author+: Create post
mutation CreatePost($title: String!, $content: String!)
@auth(level: USER)
@transaction {
# Check user is at least AUTHOR
query @redact {
blogPermission(key: { user: { uid_expr: "auth.uid" }})
@check(expr: "this != null", message: "No permission record") {
role @check(expr: "this in ['AUTHOR', 'EDITOR', 'ADMIN']", message: "Must be author+")
}
}
post_insert(data: {
author: { uid_expr: "auth.uid" },
title: $title,
content: $content
})
}
# Editor+: Publish any post
mutation PublishPost($id: UUID!)
@auth(level: USER)
@transaction {
query @redact {
blogPermission(key: { user: { uid_expr: "auth.uid" }}) {
role @check(expr: "this in ['EDITOR', 'ADMIN']", message: "Must be editor+")
}
}
post_update(id: $id, data: {
status: PUBLISHED,
publishedAt_expr: "request.time"
})
}
# Admin: Grant role
mutation GrantRole($userUid: String!, $role: UserRole!)
@auth(level: USER)
@transaction {
query @redact {
blogPermission(key: { user: { uid_expr: "auth.uid" }}) {
role @check(expr: "this == 'ADMIN'", message: "Must be admin")
}
}
blogPermission_upsert(data: {
user: { uid: $userUid },
role: $role
})
}
```
______________________________________________________________________
## Native SQL Examples
For scenarios where standard GraphQL cannot express the required database logic,
use Native SQL.
### Basic SELECT with field aliasing
```graphql
query GetMoviesByGenre($genre: String!, $limit: Int!) @auth(level: PUBLIC) {
movies: _select(
sql: """
SELECT id, title, release_year, rating
FROM movie
WHERE genre = $1
ORDER BY release_year DESC
LIMIT $2
""",
params: [$genre, $limit]
)
}
```
### Basic UPDATE
```graphql
mutation UpdateMovieRating($movieId: UUID!, $newRating: Float!) @auth(level: USER) {
_execute(
sql: """
UPDATE movie
SET rating = $2
WHERE id = $1
""",
params: [$movieId, $newRating]
)
}
```
### Advanced aggregation with RANK
```graphql
query GetMoviesRankedByRating @auth(level: PUBLIC) {
_select(
sql: """
SELECT
id,
title,
rating,
RANK() OVER (ORDER BY rating DESC) as rank
FROM movie
WHERE rating IS NOT NULL
LIMIT 20
""",
params: []
)
}
```
### UPDATE with RETURNING and Auth Context
```graphql
mutation UpdateMyReviewText($movieId: UUID!, $newText: String!) @auth(level: USER) {
updatedReview: _executeReturningFirst(
sql: """
UPDATE review
SET text = $2
WHERE movie_id = $1 AND user_uid = $3
RETURNING movie_id, user_uid, rating, text
""",
params: [$movieId, $newText, {_expr: "auth.uid"}]
)
}
```
### Advanced CTE with upserts (atomic get-or-create)
*Note: Data-modifying CTEs are only supported by `_execute`, not
`_executeReturning`.*
```graphql
mutation CreateMovieCTE($movieId: UUID!, $userUid: String!, $reviewId: UUID!) @auth(level: USER) {
_execute(
sql: """
WITH
new_user AS (
INSERT INTO "user" (uid, email, display_name)
VALUES ($2, '[email protected]', 'Auto-Generated User')
ON CONFLICT (uid) DO NOTHING
RETURNING uid
),
movie AS (
INSERT INTO movie (id, title, poster_url, release_year, genre)
VALUES ($1, 'Auto-Generated Movie', 'https://placeholder.com', 2025, 'Sci-Fi')
ON CONFLICT (id) DO NOTHING
RETURNING id
)
INSERT INTO review (id, movie_id, user_uid, rating, text, created_at)
VALUES (
$3,
$1,
$2,
5,
'Good!',
NOW()
)
""",
params: [$movieId, $userUid, $reviewId]
)
}
```
### Multi-statement Transactions
Because `mutation` operations are single requests, you can chain multiple
`_execute` commands within a `@transaction` to ensure they all succeed or fail
together.
```graphql
mutation SafeTransfer($from: UUID!, $to: UUID!, $amount: Float!) @auth(level: USER) @transaction {
deduct: _execute(
sql: "UPDATE account SET balance = balance - $2 WHERE id = $1",
params: [$from, $amount]
)
add: _execute(
sql: "UPDATE account SET balance = balance + $2 WHERE id = $1",
params: [$to, $amount]
)
}
```
### Use of extensions (e.g. PostGIS for geospatial data)
*Prerequisite:* You must enable the extension on your underlying Cloud SQL
instance by connecting to your database as the postgres user and running:
```sql
CREATE EXTENSION IF NOT EXISTS postgis;
```
```graphql
query GetNearbyActiveRestaurants($userLong: Float!, $userLat: Float!, $maxDistanceMeters: Float!) @auth(level: USER) {
nearby: _select(
sql: """
SELECT
id,
name,
tags,
ST_Distance(
ST_MakePoint((metadata->>'longitude')::float, (metadata->>'latitude')::float)::geography,
ST_MakePoint($1, $2)::geography
) as distance_meters
FROM restaurant
WHERE active = true
AND metadata ? 'longitude' AND metadata ? 'latitude'
AND ST_DWithin(
ST_MakePoint((metadata->>'longitude')::float, (metadata->>'latitude')::float)::geography,
ST_MakePoint($1, $2)::geography,
$3
)
ORDER BY distance_meters ASC
LIMIT 10
""",
params: [$userLong, $userLat, $maxDistanceMeters]
)
}
```
*After running the query using a client SDK, the result will be in
`data.nearby`.*
reference/cloud_functions.md›
# Cloud Functions Integration Reference
Use this reference to handle database events in SQL Connect by triggering Cloud
Functions in response to mutation executions.
______________________________________________________________________
## Core Trigger Configuration
To handle a mutation execution, define the `onMutationExecuted` event handler.
### 🚨 Critical Infinite Loop Constraint
Unlike document-based database triggers (like Firestore or Realtime Database),
**SQL Connect event triggers do not provide a "before" snapshot of the data.**
Because SQL Connect proxies requests directly to PostgreSQL, "before" states
cannot be resolved transactionally.
- **Warning**: If `onMutationExecuted` executes a SQL Connect mutation, it can
trigger another `onMutationExecuted` trigger in a cascading loop. Make sure
that `onMutationExecuted` has a filter on `operation` to reduce the chance of
infinite loops.
- **Rule**: Ensure that no mutation executed inside the function can ever
trigger the handler itself, even indirectly.
### Location & Region Matching Rule
**The Cloud Function region option must match your SQL Connect service
location.** You **must** explicitly configure the `region` parameter (e.g.,
`'us-central1'`) in the trigger options to match the `location` specified in
`dataconnect.yaml`.
```typescript
import { onMutationExecuted } from "firebase-functions/dataconnect";
import { logger } from "firebase-functions";
export const logMutation = onMutationExecuted(
{
region: "europe-west1" // Must match the SQL Connect service location
},
(event) => {
logger.info("A mutation was executed!", {
eventId: event.id,
type: event.type
});
}
);
```
______________________________________________________________________
## Event Filtering
To prevent unnecessary function invocations and infinite execution loops,
**always specify narrow filters** using `service` and `operation` attributes.
- **`service` & `operation` (Recommended)**: Always specify these to restrict
the trigger to a specific mutation in your project.
- **`connector` (Optional)**: Can be omitted if you want to trigger on the same
operation name across multiple connectors. Specify it only if you need to
restrict the trigger to a specific connector.
### Comprehensive Example
```typescript
import { onMutationExecuted } from "firebase-functions/dataconnect";
import { logger } from "firebase-functions";
// Triggers for "CreateUser" mutation in "myAppService" service.
// 'connector' is omitted (optional), meaning it matches "CreateUser" in any connector.
export const onUserCreate = onMutationExecuted(
{
service: "myAppService",
operation: "CreateUser",
// region: "us-central1" // Optional: defaults to us-central1, change if database is elsewhere
},
(event) => {
logger.info("A new user was created!");
}
);
// Advanced: Trigger using wildcards or capture variables
export const onMutationCaptures = onMutationExecuted(
{
service: "myAppService",
operation: "{operation}", // Captures matching operation name dynamically
},
(event) => {
const triggeredOp = event.params.operation;
logger.info(`Captured operation execution: ${triggeredOp}`);
}
);
```
______________________________________________________________________
## Accessing User Authentication Context
Extract security credentials about the caller who executed the mutation using
`event.authType` and `event.authId`.
### Auth Context Mappings
| Triggered Principal | `event.authType` | `event.authId` |
| :----------------------------------- | :------------------ | :----------------------------------------------- |
| **Authenticated end user** | `"app_user"` | Firebase Auth token UID |
| **Unauthenticated end user** | `"unauthenticated"` | Empty |
| **Admin SDK (Impersonating User)** | `"app_user"` | Firebase Auth token UID of the impersonated user |
| **Admin SDK (Impersonating Unauth)** | `"unauthenticated"` | Empty |
| **Admin SDK (Full privileges)** | `"admin"` | Empty |
### Auth Extraction Example
```typescript
export const processSensitiveMutation = onMutationExecuted(
{ operation: "UpdateFinancials" },
(event) => {
if (event.authType === "admin") {
console.log("Elevated admin mutation execution.");
} else {
console.log(`Mutation initiated by user: ${event.authId}`);
}
}
);
```
______________________________________________________________________
## Parsing Event Data Payloads
The trigger payload provides inputs passed to the mutation (`payload.variables`)
and return values generated from the execution (`payload.data`).
### Event Payload Structure
```json
{
"authType": "app_user",
"authId": "user-123",
"data": {
"payload": {
"variables": {
"movieId": "m-1",
"rating": 5
},
"data": {
"review_insert": {
"id": "r-99"
}
},
"errors": []
}
}
}
```
- **`event.data.payload.variables`**: Inputs passed to the mutation.
- **`event.data.payload.data`**: Fields returned by the mutation execution.
- **`event.data.payload.errors`**: Array of execution errors. Empty if
successful.
### Payload Extraction Example
```typescript
import { onMutationExecuted } from "firebase-functions/dataconnect";
import { logger } from "firebase-functions";
export const onNewReview = onMutationExecuted(
{
service: "myAppService",
connector: "reviews",
operation: "CreateReview",
},
(event) => {
// Extract input variables passed to the mutation
const inputVariables = event.data.payload.variables;
// Extract returned fields from the database write
const returnedFields = event.data.payload.data;
logger.info(`Processed review ${returnedFields.review_insert.id} for movie ${inputVariables.movieId}`);
}
);
```
reference/config.md›
# Configuration Reference
## Contents
- [Project Structure](#project-structure)
- [dataconnect.yaml](#dataconnectyaml)
- [connector.yaml](#connectoryaml)
- [Firebase CLI Commands](#firebase-cli-commands)
- [Emulator](#emulator)
- [Deployment](#deployment)
______________________________________________________________________
## Project Structure
```
project-root/
├── firebase.json # Firebase project config
└── dataconnect/
├── dataconnect.yaml # Service configuration
├── schema/
│ └── schema.gql # Data model (types, relationships)
└── connector/
├── connector.yaml # Connector config + SDK generation
├── queries.gql # Query operations
└── mutations.gql # Mutation operations (optional separate file)
```
______________________________________________________________________
## dataconnect.yaml
Main SQL Connect service configuration:
```yaml
specVersion: "v1"
serviceId: "my-service"
location: "us-central1"
schemaValidation: "STRICT" # or "COMPATIBLE"
schema:
source: "./schema"
datasource:
postgresql:
database: "fdcdb"
cloudSql:
instanceId: "my-instance"
connectorDirs: ["./connector"]
```
| Field | Description |
| ------------------- | ---------------------------------------------------------------------------------------- |
| `specVersion` | Always `"v1"` |
| `serviceId` | Unique identifier for the service |
| `location` | GCP region (us-central1, us-east4, europe-west1, etc.) |
| `schemaValidation` | Deployment mode: `"STRICT"` (must match exactly) or `"COMPATIBLE"` (backward compatible) |
| `schema.source` | Path to schema directory |
| `schema.datasource` | PostgreSQL connection config |
| `connectorDirs` | List of connector directories |
### Cloud SQL Configuration
```yaml
schema:
datasource:
postgresql:
database: "my-database" # Database name
cloudSql:
instanceId: "my-instance" # Cloud SQL instance ID
```
______________________________________________________________________
## connector.yaml
Connector configuration and SDK generation:
```yaml
connectorId: "default"
generate:
javascriptSdk:
outputDir: "../web/src/lib/dataconnect"
package: "@myapp/dataconnect"
kotlinSdk:
outputDir: "../android/app/src/main/kotlin/com/myapp/dataconnect"
package: "com.myapp.dataconnect"
swiftSdk:
outputDir: "../ios/MyApp/DataConnect"
```
### SDK Generation Options
| SDK | Fields |
| --------------- | -------------------------------------- |
| `javascriptSdk` | `outputDir`, `package` |
| `kotlinSdk` | `outputDir`, `package` |
| `swiftSdk` | `outputDir` |
| `nodeAdminSdk` | `outputDir`, `package` (for Admin SDK) |
______________________________________________________________________
## Firebase CLI Commands
### Initialize SQL Connect
```bash
# Interactive setup
npx -y firebase-tools@latest init dataconnect
# Set project
npx -y firebase-tools@latest use <project-id>
```
### Local Development
```bash
# Start emulator
npx -y firebase-tools@latest emulators:start --only dataconnect
# Start with database seed data
npx -y firebase-tools@latest emulators:start --only dataconnect --import=./seed-data
# Generate SDKs
npx -y firebase-tools@latest dataconnect:sdk:generate
# Watch for schema changes (auto-regenerate)
npx -y firebase-tools@latest dataconnect:sdk:generate --watch
```
### Schema Management
```bash
# Compare local schema to production
npx -y firebase-tools@latest dataconnect:sql:diff
# Apply migration
npx -y firebase-tools@latest dataconnect:sql:migrate
```
### Deployment
```bash
# Deploy SQL Connect service
npx -y firebase-tools@latest deploy --only dataconnect
# Deploy specific connector
npx -y firebase-tools@latest deploy --only dataconnect:connector-id
# Deploy with schema migration
npx -y firebase-tools@latest deploy --only dataconnect --force
```
______________________________________________________________________
## Emulator
### Start Emulator
```bash
npx -y firebase-tools@latest emulators:start --only dataconnect
```
Default ports:
- SQL Connect: `9399`
- PostgreSQL: `9939` (local PostgreSQL instance)
### Emulator Configuration (firebase.json)
```json
{
"emulators": {
"dataconnect": {
"port": 9399
}
}
}
```
### Connect from SDK
```typescript
// Web
import { connectDataConnectEmulator } from 'firebase/data-connect';
connectDataConnectEmulator(dc, 'localhost', 9399);
// Android
connector.dataConnect.useEmulator("10.0.2.2", 9399)
// iOS
connector.useEmulator(host: "localhost", port: 9399)
```
### Seed Data
Create seed data files and import:
```bash
# Export current emulator data
npx -y firebase-tools@latest emulators:export ./seed-data
# Start with seed data
npx -y firebase-tools@latest emulators:start --only dataconnect --import=./seed-data
```
______________________________________________________________________
## Deployment
### Deploy Workflow
1. **Test locally** with emulator
1. **Generate SQL diff**: `npx -y firebase-tools@latest dataconnect:sql:diff`
1. **Review migration**: Check breaking changes
1. **Deploy**: `npx -y firebase-tools@latest deploy --only dataconnect`
### Schema Migrations
SQL Connect auto-generates PostgreSQL migrations:
```bash
# Preview migration
npx -y firebase-tools@latest dataconnect:sql:diff
# Apply migration (interactive)
npx -y firebase-tools@latest dataconnect:sql:migrate
# Force migration (non-interactive)
npx -y firebase-tools@latest dataconnect:sql:migrate --force
```
### Breaking Changes
Some schema changes require special handling:
- Removing required fields
- Changing field types
- Removing tables
Use `--force` flag to acknowledge breaking changes during deploy.
### CI/CD Integration
```yaml
# GitHub Actions example
- name: Deploy SQL Connect
run: |
npx -y firebase-tools@latest deploy --only dataconnect --token ${{ secrets.FIREBASE_TOKEN }} --force
```
______________________________________________________________________
## VS Code Extension
Install "Firebase SQL Connect" extension for:
- Schema intellisense and validation
- GraphQL operation testing
- Emulator integration
- SDK generation on save
### Extension Settings
```json
{
"firebase.dataConnect.autoGenerateSdk": true,
"firebase.dataConnect.emulator.port": 9399
}
```
reference/data_seeding.md›
# Data Seeding & Bulk Operations Reference
Use this reference to populate local development databases for prototyping,
execute CI/CD tests, and perform bulk data migrations in production
environments.
______________________________________________________________________
## 1. Local Prototyping: Data Seeding
Local database seeding allows developer agents to test queries, mutations,
complex joins, and role-based access control (RBAC) under realistic conditions.
### The `seed_data.gql` Workflow
**Always write prototyping seed mutations to `dataconnect/seed_data.gql`**
(located at the project root, not inside `connector/`). This file is excluded
from production deployments and client SDK generation.
#### ⚠️ Seeding Directives Rule
**Do not declare `@auth` directives inside `seed_data.gql` mutations.** Since
this file runs locally to establish a test state and is not an exposed API
connector endpoint, authorization directives are completely unnecessary and
should be omitted.
### Seeding Independent Tables (FK Order)
When executing standard bulk insertions (`_insertMany`) across multiple tables,
**always insert parent tables before referencing them in child or join tables.**
```graphql
# dataconnect/seed_data.gql
mutation SeedIndependentTables @transaction {
# Step 1: Seed parent tables
movie_insertMany(data: [
{ id: "m-1", title: "Inception", genre: "sci-fi" },
{ id: "m-2", title: "The Matrix", genre: "action" }
])
actor_insertMany(data: [
{ id: "a-1", name: "Leonardo DiCaprio" },
{ id: "a-2", name: "Keanu Reeves" }
])
# Step 2: Seed join table (depends on pre-existing parent IDs)
movieActor_insertMany(data: [
{ movie: { id: "m-1" }, actor: { id: "a-1" }, role: "main" },
{ movie: { id: "m-2" }, actor: { id: "a-2" }, role: "main" }
])
}
```
### Seeding Related Tables (Nested Relational Inserts)
**To seed parent-child relationships atomically, perform a nested relational
insert using literal payloads.** This avoids the need to manage foreign keys
manually.
- **Omit Parent Foreign Keys**: **Do not specify the parent foreign key** (e.g.
`movieId`) inside the nested child objects. The database engine automatically
maps and resolves them.
```graphql
# dataconnect/seed_data.gql
mutation SeedMoviesAndReviews @transaction {
movie_insert(data: {
id: "m-1",
title: "Inception",
genre: "sci-fi",
# Nested reviews are inserted atomically without manual movieId mapping
reviews_on_movie: [
{
id: "r-1",
rating: 5,
reviewText: "Mind-bending masterpiece!",
user: { id: "user-123" } # Links to pre-existing user
},
{
id: "r-2",
rating: 4,
reviewText: "Visually stunning but complex.",
user: { id: "user-456" }
}
]
})
}
```
### Resetting Seed Data
For continuous testing or CI/CD flows, return the database to a zero state using
one of the following strategies:
- **Strategy A: Upsert Many (Idempotent)**: Re-run seeds using `_upsertMany`
mutations. This overrides existing records or inserts missing ones in a single
step.
- **Strategy B: Delete and Re-Insert**: Call `_deleteMany(all: true)` on your
tables in **reverse foreign key order** (child/join tables first, then parent
tables) followed by your seed `_insertMany` operations.
```graphql
# dataconnect/seed_data.gql
mutation ResetDatabaseToOriginalState @transaction {
# Delete child tables first to prevent FK constraint violations
movieActor_deleteMany(all: true)
actor_deleteMany(all: true)
movie_deleteMany(all: true)
# (Optional) Follow up with new _insertMany steps
}
```
______________________________________________________________________
## 2. Production: Admin SDK Bulk Operations
**Use the Firebase Admin SDK for Node.js for bulk data loading and production
migrations.** Avoid running large mutations directly via raw GraphQL endpoints
in production.
The Admin SDK provides direct, type-safe methods: `dc.insert`, `dc.insertMany`,
`dc.upsert`, and `dc.upsertMany`.
### SDK Bulk APIs Features:
- **No Manual GraphQL Strings**: Do not write raw `mutation {...}` strings when
executing privileged batch operations. Pass Javascript objects directly.
- **Relational Support**: The bulk helper methods natively support nested 1:Many
relationships inside the input arrays.
### SDK Bulk Operations Example
```typescript
import { initializeApp } from 'firebase-admin/app';
import { getDataConnect } from 'firebase-admin/data-connect';
const app = initializeApp();
const dc = getDataConnect({ location: "us-west2", serviceId: "my-service" });
const bulkMoviesData = [
{
id: "m-1",
title: "Inception",
genre: "sci-fi",
// Atomic nested relational inserts are fully supported
reviews_on_movie: [
{
rating: 5,
reviewText: "Incredible concept.",
user: { id: "user-123" }
}
]
},
{
id: "m-2",
title: "The Matrix",
genre: "action",
reviews_on_movie: [
{
rating: 5,
reviewText: "A classic.",
user: { id: "user-456" }
}
]
}
];
// Atomically load thousands of records (parent and child tables combined)
const response = await dc.insertMany("movie", bulkMoviesData);
```
______________________________________________________________________
## 3. Production: Bulk Operations via raw SQL
When working with a stable schema in production, you can use standard SQL tools
(like `psql` or Cloud SQL import pipelines) to execute bulk data updates
directly on the PostgreSQL instance.
### 🚨 Critical SQL Operations Constraint
**Never modify your database schema directly using SQL tools.** Direct schema
alterations (`ALTER TABLE`, `CREATE INDEX`, etc.) outside of your `schema.gql`
file will bypass SQL Connect's schema compiler, breaking connector mappings, and
causing active client SDK integrations to fail.
reference/native_sql.md›
# Native SQL Operations
Always default to Native GraphQL. Use Native SQL **only** when you need
database-specific features not available in GraphQL (e.g., PostGIS, Window
Functions, Complex Aggregations, or specific DML CTEs).
## Core Agent Constraints
When generating Native SQL operations, you are bypassing GraphQL and talking
directly to PostgreSQL. You **MUST** adhere to these strict constraints:
1. **Operation Syntax Isolation:** Never mix Native SQL positional parameters
(`$1`) with standard GraphQL named variables (`$id`). The `sql:` argument
MUST be a hardcoded string literal block (`"""SELECT..."""`), not a GraphQL
variable.
1. **Table & Column Mapping (Case Sensitivity):**
- **Default `snake_case` Conversion:** By default, SQL Connect converts
`PascalCase` types and `camelCase` fields to `snake_case` in the database.
- *Schema:* `type UserProfile { releaseYear: Int }` -> *Native SQL:*
`SELECT release_year FROM user_profile`
- **Explicit Overrides (Requires Double Quotes):** If the schema uses
`@table(name: "ExactName")` or `@col(name: "ExactCol")`, you **MUST wrap
the identifier in double quotes** if it contains capital letters (e.g.,
`SELECT * FROM "ExactName"`). Without quotes, Postgres folds it to
lowercase and fails validation.
## Syntax rules & limitations
Native SQL enforces strict parsing rules to ensure security and prevent SQL
injection:
- **String Literals Only:** The `sql` argument must be a hardcoded string
literal block (`"""SELECT..."""`) directly in the `.gql` file. It **cannot**
be a GraphQL variable.
- **Validation:** Do **NOT** use DDL in any operations (modify the `schema.gql`
file instead for table/column changes). Furthermore, `query` operations cannot
contain DML and must start with `SELECT`, `TABLE`, or `WITH`.
- **Parameters:** Use strict positional parameters (`$1`, `$2`) that match the
`params` array order. Named parameters (`$id`, `:name`) are **forbidden**.
- **Comments:** Use block comments (`/* ... */`). Line comments (`--`) are
**forbidden** because they can truncate subsequent clauses during query
compilation. If you comment out a line containing a parameter (e.g.,
`/* WHERE id = $1 */`), you must also remove that parameter from the `params`
list, or it will fail with `unused parameter: $1`.
- **Strings:** Extended string literals (`E'...'`) and dollar-quoted strings
(`$$...$$`) are supported.
- **Context Maps (`_expr`):** Variables **cannot** be used inside `_expr`
fields; to ensure security, `_expr` must be a static string (e.g.,
`{_expr: "auth.uid"}`, not `{_expr: $uidVar}`).
## Native SQL Root Fields
Operations are executed using the permissions granted to the SQL Connect service
account. You can alias the root field (e.g., `movies: _select`) to make the
client response cleaner (`data.movies` instead of `data._select`).
> **Note on `Any` Return Types:** Because Native SQL completely bypasses
> GraphQL's strong typing, queries like `_select` and `_executeReturning` return
> the generic `Any` scalar type. The generated client SDKs (TypeScript, Swift,
> Kotlin, Dart) will type this as `any` (or equivalent). **AGENT INSTRUCTION**:
> When you generate client-side code that consumes these operations, you MUST
> manually cast or validate the shape of the data, as the typical type safety of
> SQL Connect will not be present.
Use these root fields in `query` or `mutation` operations:
### Query Fields (Read-Only)
- `_select`: Executes a SQL query returning zero or more rows. Returns `[Any]`.
```graphql
query GetMovies($genre: String!) @auth(level: PUBLIC) {
movies: _select(
sql: "SELECT id, title FROM movie WHERE genre = $1",
params: [$genre]
)
}
```
- `_selectFirst`: Executes a SQL query expected to return zero or one row.
Returns `Any` or `null`.
```graphql
query GetTotalReviews @auth(level: PUBLIC) {
stats: _selectFirst(
sql: "SELECT COUNT(*) as total_reviews FROM review"
) # params can be omitted if empty
}
```
### Mutation Fields (DML)
- `_execute`: Executes DML (`INSERT`, `UPDATE`, `DELETE`). Returns `Int` (number
of rows affected).
- *Note 1:* `RETURNING` clauses are ignored in the result.
- *Note 2:* Only `_execute` supports Data-Modifying Common Table Expressions
(e.g., `WITH new_row AS (INSERT...)`).
```graphql
mutation UpdateRating($id: UUID!, $rating: Float!) @auth(level: USER) {
_execute(
sql: "UPDATE movie SET rating = $2 WHERE id = $1",
params: [$id, $rating]
)
}
```
- `_executeReturning`: Executes DML with a `RETURNING` clause. Returns `[Any]`.
Data-Modifying CTEs are **not** supported.
```graphql
mutation DeleteUserReviews($uid: String!) @auth(level: USER) {
deletedReviews: _executeReturning(
sql: "DELETE FROM review WHERE user_id = $1 RETURNING id, rating",
params: [{_expr: "auth.uid"}]
)
}
```
- `_executeReturningFirst`: Executes DML with `RETURNING`, expecting zero or one
row. Returns `Any` or `null`. Data-Modifying CTEs are **not** supported.
```graphql
mutation UpdateMyReview($movieId: UUID!, $text: String!) @auth(level: USER) {
updatedReview: _executeReturningFirst(
sql: """
UPDATE review SET text = $2
WHERE movie_id = $1 AND user_id = $3
RETURNING id, text
""",
params: [$movieId, $text, {_expr: "auth.uid"}]
)
}
```
### PostgreSQL Extensions
Native SQL allows you to directly query and utilize PostgreSQL extensions, such
as `PostGIS`, without needing to map complex geometry types into your GraphQL
schema or alter your underlying tables (e.g., using JSON operators to extract
values and pass them into `ST_MakePoint`).
*Note: You must enable the extension on your underlying Cloud SQL instance by
connecting as the `postgres` user and running
`CREATE EXTENSION IF NOT EXISTS ...;`*
*(See `examples.md` for a full `GetNearbyActiveRestaurants` implementation).*
## ⚠️ Security: Stored Procedures & Dynamic SQL
SQL Connect parameterizes inputs at the GraphQL boundary automatically. However,
if your Native SQL calls **custom PL/pgSQL stored procedures**, you must
manually prevent 2nd-order SQL injection:
- **NEVER** concatenate user input into an `EXECUTE` string
(`EXECUTE 'UPDATE ' || table || ' SET x=' || val;`).
- **DO** use the `USING` clause to bind data values safely.
- **DO** use `format('%I')` for safe database identifier injection.
- **DO** validate dynamic table/column names against a strict hardcoded
allowlist.
**Secure PL/pgSQL Pattern:**
```sql
CREATE OR REPLACE PROCEDURE secure_update(target_table TEXT, new_value TEXT, row_id INT)
LANGUAGE plpgsql AS $$
BEGIN
-- 1. Strict Allowlist for Identifiers
IF target_table NOT IN ('orders', 'users', 'inventory') THEN
RAISE EXCEPTION 'Invalid table name';
END IF;
-- 2. format(%I) for Identifiers, USING for Data
EXECUTE format('UPDATE %I SET status = $1 WHERE id = $2', target_table)
USING new_value, row_id;
END;
$$;
```
reference/operations.md›
# Operations Reference
## Contents
- [Generated Fields](#generated-fields)
- [Queries](#queries)
- [Mutations](#mutations)
- [Key Scalars](#key-scalars)
- [Multi-Step Operations](#multi-step-operations)
______________________________________________________________________
## Generated Fields
SQL Connect auto-generates fields for each `@table` type:
| Generated Field | Purpose | Example |
| --------------------------------------------------------------------------------------- | ------------------- | ------------------------------------------------ |
| `movie(id: UUID, key: Key, first: Row)` | Get single record | `movie(id: $id)` or `movie(first: {where: ...})` |
| `movies(where: ..., orderBy: ..., limit: ..., offset: ..., distinct: ..., having: ...)` | List/filter records | `movies(where: {...})` |
| `movie_insert(data: ...)` | Create record | Returns key |
| `movie_insertMany(data: [...])` | Bulk create | Returns keys |
| `movie_update(id: ..., data: ...)` | Update by ID | Returns key or null |
| `movie_updateMany(where: ..., data: ...)` | Bulk update | Returns count |
| `movie_upsert(data: ...)` | Insert or update | Returns key |
| `movie_delete(id: ...)` | Delete by ID | Returns key or null |
| `movie_deleteMany(where: ...)` | Bulk delete | Returns count |
### Relation Fields
For a `Post` with `author: User!`:
- `post.author` - Navigate to related User
- `user.posts_on_author` - Reverse: all Posts by User
For many-to-many via `MovieActor`:
- `movie.actors_via_MovieActor` - Get all actors
- `actor.movies_via_MovieActor` - Get all movies
______________________________________________________________________
## Referencing Generated GraphQL Schema
**Do not guess** available queries or mutations. Review the generated schema
files instead of trying to deduce them from the data model.
1. **Location**: `.dataconnect/schema/main/` (relative to project root).
1. **Action**: Scan this directory for generated files (`query.gql`,
`mutation.gql`, `relation.gql`, `input.gql`) to understand the exact shape of
the API and auto-generated types.
1. **Validation**: Always run `firebase dataconnect:compile` to verify
operations against the full schema.
______________________________________________________________________
## Queries
### Basic Query
```graphql
query GetMovie($id: UUID!) @auth(level: PUBLIC) {
movie(id: $id) {
id title genre releaseYear
}
}
```
### List with Filtering
```graphql
query ListMovies($genre: String, $minRating: Int) @auth(level: PUBLIC) {
movies(
where: {
genre: { eq: $genre },
rating: { ge: $minRating }
},
orderBy: [{ releaseYear: DESC }, { title: ASC }],
limit: 20,
offset: 0
) {
id title genre rating
}
}
```
### Filter Operators
| Operator | Description | Example |
| ------------ | ----------------------- | ------------------------------------------- |
| `eq` | Equals | `{ title: { eq: "Matrix" }}` |
| `ne` | Not equals | `{ status: { ne: "deleted" }}` |
| `gt`, `ge` | Greater than (or equal) | `{ rating: { ge: 4 }}` |
| `lt`, `le` | Less than (or equal) | `{ releaseYear: { lt: 2000 }}` |
| `in` | In list | `{ genre: { in: ["Action", "Drama"] }}` |
| `nin` | Not in list | `{ status: { nin: ["deleted", "hidden"] }}` |
| `isNull` | Is null check | `{ description: { isNull: true }}` |
| `contains` | String contains | `{ title: { contains: "war" }}` |
| `startsWith` | String starts with | `{ title: { startsWith: "The" }}` |
| `endsWith` | String ends with | `{ email: { endsWith: "@gmail.com" }}` |
| `includes` | Array includes | `{ tags: { includes: "sci-fi" }}` |
### Expression Operators (Compare with Server Values)
Use `_expr` suffix to compare with server-side values:
```graphql
query MyPosts @auth(level: USER) {
posts(where: { authorUid: { eq_expr: "auth.uid" }}) {
id title
}
}
query RecentPosts @auth(level: PUBLIC) {
posts(where: { publishedAt: { lt_expr: "request.time" }}) {
id title
}
}
```
### Logical Operators
```graphql
query ComplexFilter($genre: String, $minRating: Int) @auth(level: PUBLIC) {
movies(where: {
_or: [
{ genre: { eq: $genre }},
{ rating: { ge: $minRating }}
],
_and: [
{ releaseYear: { ge: 2000 }},
{ status: { ne: "hidden" }}
],
_not: { genre: { eq: "Horror" }}
}) { id title }
}
```
### Relational Queries
```graphql
# Navigate relationships
query MovieWithDetails($id: UUID!) @auth(level: PUBLIC) {
movie(id: $id) {
title
# One-to-one
metadata: movieMetadata_on_movie { director }
# One-to-many
reviews: reviews_on_movie { rating user { name }}
# Many-to-many
actors: actors_via_MovieActor { name }
}
}
# Filter by related data
query MoviesByDirector($director: String!) @auth(level: PUBLIC) {
movies(where: {
movieMetadata_on_movie: { director: { eq: $director }}
}) { id title }
}
# Filter by null relationship (e.g., top-level categories with no parent)
# Use the generated foreign key field (e.g., parentId)
query TopLevelCategories @auth(level: PUBLIC) {
categories(where: { parentId: { eq: null } }) {
id
name
}
}
```
### Aliases
```graphql
query CompareRatings($genre: String!) @auth(level: PUBLIC) {
highRated: movies(where: { genre: { eq: $genre }, rating: { ge: 8 }}) {
title rating
}
lowRated: movies(where: { genre: { eq: $genre }, rating: { lt: 5 }}) {
title rating
}
}
```
______________________________________________________________________
## Mutations
### Create
```graphql
mutation CreateMovie($title: String!, $genre: String) @auth(level: USER) {
movie_insert(data: {
title: $title,
genre: $genre
})
}
```
### Create with Server Values
```graphql
mutation CreatePost($title: String!, $content: String!) @auth(level: USER) {
post_insert(data: {
authorUid_expr: "auth.uid", # Current user
id_expr: "uuidV4()", # Auto-generate UUID
createdAt_expr: "request.time", # Server timestamp
title: $title,
content: $content
})
}
```
### Update
```graphql
mutation UpdateMovie($id: UUID!, $title: String, $genre: String) @auth(level: USER) {
movie_update(
id: $id,
data: {
title: $title,
genre: $genre,
updatedAt_expr: "request.time"
}
)
}
```
### Update Operators
```graphql
mutation IncrementViews($id: UUID!) @auth(level: PUBLIC) {
movie_update(id: $id, data: {
viewCount_update: { inc: 1 }
})
}
mutation AddTag($id: UUID!, $tag: String!) @auth(level: USER) {
movie_update(id: $id, data: {
tags_update: { add: [$tag] } # add, remove, append, prepend
})
}
```
| Operator | Types | Description |
| --------- | --------------------------- | ------------------------- |
| `inc` | Int, Float, Date, Timestamp | Increment value |
| `dec` | Int, Float, Date, Timestamp | Decrement value |
| `add` | Lists | Add items if not present |
| `remove` | Lists | Remove all matching items |
| `append` | Lists | Append to end |
| `prepend` | Lists | Prepend to start |
### Upsert
```graphql
mutation UpsertUser($email: String!, $name: String!) @auth(level: USER) {
user_upsert(data: {
uid_expr: "auth.uid",
email: $email,
name: $name
})
}
```
### Delete
```graphql
mutation DeleteMovie($id: UUID!) @auth(level: USER) {
movie_delete(id: $id)
}
mutation DeleteOldDrafts @auth(level: USER) {
post_deleteMany(where: {
status: { eq: "draft" },
createdAt: { lt_time: { now: true, sub: { days: 30 }}}
})
}
```
### Filtered Updates/Deletes (User-Owned)
```graphql
mutation UpdateMyPost($id: UUID!, $content: String!) @auth(level: USER) {
post_update(
first: { where: {
id: { eq: $id },
authorUid: { eq_expr: "auth.uid" } # Only own posts
}},
data: { content: $content }
)
}
```
______________________________________________________________________
## Key Scalars
Key scalars (`Movie_Key`, `User_Key`) are auto-generated types representing
primary keys:
```graphql
# Using key scalar
query GetMovie($key: Movie_Key!) @auth(level: PUBLIC) {
movie(key: $key) { title }
}
# Variable format
# { "key": { "id": "uuid-here" } }
# Composite key
# { "key": { "movieId": "...", "userId": "..." } }
```
Key scalars are returned by mutations:
```graphql
mutation CreateAndFetch($title: String!) @auth(level: USER) {
key: movie_insert(data: { title: $title })
# Returns: { "key": { "id": "generated-uuid" } }
}
```
______________________________________________________________________
## Multi-Step Operations
### @transaction
Ensures atomicity - all steps succeed or all rollback:
```graphql
mutation CreateUserWithProfile($name: String!, $bio: String!)
@auth(level: USER)
@transaction {
# Step 1: Create user
user_insert(data: {
uid_expr: "auth.uid",
name: $name
})
# Step 2: Create profile (uses response from step 1)
userProfile_insert(data: {
userId_expr: "response.user_insert.uid",
bio: $bio
})
}
```
### Using response Binding
Access results from previous steps:
```graphql
mutation CreateTodoWithItem($listName: String!, $itemText: String!)
@auth(level: USER)
@transaction {
todoList_insert(data: {
id_expr: "uuidV4()",
name: $listName
})
todoItem_insert(data: {
listId_expr: "response.todoList_insert.id", # From previous step
text: $itemText
})
}
```
### Embedded Queries
Run queries within mutations for validation:
```graphql
mutation AddToPublicList($listId: UUID!, $item: String!)
@auth(level: USER)
@transaction {
# Step 1: Verify list exists and is public
query @redact {
todoList(id: $listId) @check(expr: "this != null", message: "List not found") {
isPublic @check(expr: "this == true", message: "List is not public")
}
}
# Step 2: Add item
todoItem_insert(data: { listId: $listId, text: $item })
}
```
reference/realtime.md›
# Realtime Reference
## Contents
- [When to Use What](#when-to-use-what)
- [The @refresh Directive](#the-refresh-directive)
- [CEL Bindings in Conditions](#cel-bindings-in-conditions)
- [Implicit Entity Refresh signals](#implicit-entity-refresh-signals)
______________________________________________________________________
## When to Use What
SQL Connect provides three mechanisms for live data updates. Pick the right one
based on what you're querying:
| Scenario | Mechanism | Directive Needed? |
| ----------------------------------------------------------- | ------------------------ | ----------------------------------- |
| Single-entity lookup by ID (e.g., `movie(id: $id)`) | **Automatic refresh** | No — SQL Connect handles it |
| List query that should update when a specific mutation runs | **Event-driven refresh** | `@refresh(onMutationExecuted: ...)` |
| Any query that should poll at a fixed interval | **Time-based polling** | `@refresh(every: ...)` |
List queries require explicit `@refresh` to tell SQL Connect which mutations
affect the result set.
Clients consume all three using `subscribe()` instead of `execute()`. See
[sdks.md](sdks.md) for per-platform subscribe patterns.
______________________________________________________________________
## The @refresh Directive
`@refresh` is a **repeatable** directive applied to **queries**. It defines when
connected subscribers should receive updated data.
### Time-Based Polling (`every`)
Keep the query fresh with a recommended refresh interval. Note that `every` and
`mutation` signals can be used together; whichever signal arrives first will
trigger the refresh.
```graphql
query MovieLeaderboard
@auth(level: PUBLIC)
@refresh(every: { seconds: 30 }) {
movies(orderBy: [{ rating: DESC }], limit: 10) {
id title rating
}
}
```
**Constraints:**
- The `every` argument takes a duration object: `{ seconds: Int }`
- **Minimum**: `{ seconds: 10 }` — protects against excessive server load
- **Maximum**: `{ hours: 1 }` (3600 seconds)
- Values outside this range fail validation at deploy time
Use time-based polling when freshness matters but you don't have a specific
mutation to listen for (e.g., dashboards aggregating external data, stock
tickers, activity feeds).
### Explicit Mutation Signals (`onMutationExecuted`)
Trigger a query refresh when a specific mutation executes. This is the most
common pattern for keeping lists in sync.
```graphql
# Example with condition (refreshes only when the condition is met)
query ChatRoom($roomId: UUID!) @auth(level: PUBLIC)
@refresh(onMutationExecuted: {
operation: "SendMessage",
condition: "mutation.variables.roomId == request.variables.roomId"
}) {
messages(where: {roomId: {eq: $roomId}}, orderBy: [{createTime: DESC}], limit: 50) {
author content createTime
}
}
# Example without condition (refreshes on any execution of the named mutation)
query ListAllMessages
@auth(level: PUBLIC)
@refresh(onMutationExecuted: {
operation: "SendMessage"
}) {
messages { id content }
}
```
**Arguments:**
- **`operation`** (required): The name of the mutation operation to listen for.
Must match the mutation's operation name exactly.
- **`condition`** (optional): A CEL expression that must evaluate to `true` for
the refresh to fire. Without a condition, every execution of the named
mutation triggers a refresh.
It's highly recommended to define fine granular conditions. Inaccurate refresh
policies could consume Postgres resources and make your app slower.
Use conditions to scope refreshes precisely — a review list should only refresh
when the mutation targets the same movie, not every review across the entire
app.
### Combining Multiple @refresh Directives
Since `@refresh` is repeatable, you can combine strategies on a single query:
```graphql
query ActiveOrders($userId: UUID!)
@auth(level: USER)
@refresh(onMutationExecuted: {
operation: "UpdateOrderStatus",
condition: "request.variables.userId == mutation.variables.userId"
})
@refresh(every: { seconds: 60 }) {
orders(where: { user: { id: { eq: $userId }}, status: { ne: DELIVERED }}) {
id status total updatedAt
}
}
```
This query refreshes whenever an order status changes for this user, *and* polls
every 60 seconds as a fallback to catch any updates that might not have a direct
mutation trigger.
______________________________________________________________________
## CEL Bindings in Conditions
The `condition` expression in `onMutationExecuted` has access to two contexts:
### `request` — The Query Subscription
The state of the query being subscribed to.
| Binding | Description |
| -------------------- | ------------------------------------------------------------ |
| `request.variables` | Variables passed to the query (e.g., `request.variables.id`) |
| `request.auth.uid` | UID of the user who subscribed |
| `request.auth.token` | Full auth token claims of the subscriber |
### `mutation` — The Triggering Event
The mutation that just executed.
| Binding | Description |
| --------------------- | --------------------------------------------------------------------- |
| `mutation.variables` | Variables passed to the mutation (e.g., `mutation.variables.movieId`) |
| `mutation.auth.uid` | UID of the user who executed the mutation |
| `mutation.auth.token` | Full auth token claims of the mutation executor |
### Common Patterns
```text
# Refresh only when the mutation targets the same entity
"request.variables.id == mutation.variables.id"
# Refresh only when the same user who subscribed makes a change
"request.auth.uid == mutation.auth.uid"
# Refresh when a specific field value matches a condition
"request.auth.uid == mutation.auth.uid && mutation.variables.status == 'PUBLISHED'"
# Refresh when a specific flag is set in the mutation
"mutation.variables.isPublic == true"
```
______________________________________________________________________
## Implicit Entity Refresh signals
For single-entity lookups by unique identifier, SQL Connect handles refreshes
automatically — no `@refresh` directive needed.
**What qualifies:**
- Queries fetching one entity by its primary key: `movie(id: $id)`,
`user(key: { uid: $uid })`
- If a single-entity mutation modifies that specific entity, all active
subscribers automatically receive the update. Supported operations include:
- `_insert(data)` or `_insertMany(data)`
- `_upsert(data)` or `_upsertMany(data)`
- `_update(id)` or `_update(key)`
- `_delete(id)` or `_delete(key)`
- **Note**: Bulk operations like `_updateMany` and `_deleteMany` do **not**
trigger automatic entity refreshes.
**What does NOT qualify:**
- List queries: `movies(where: {...})`, `users { id name }` — these require
explicit `@refresh`
- Nested query with JOINs
- Aggregation
- Native SQL
- Customized Resolver (if supported)
```graphql
# When subscribed to, this query auto-refreshes when movie data changes — no @refresh needed
query GetMovie($id: UUID!) @auth(level: PUBLIC) {
movie(id: $id) {
id title rating description
reviews_on_movie { rating text user { displayName } }
}
}
```
To consume automatic refreshes on the client, use `subscribe()` instead of
`execute()` — the same client pattern works regardless of whether the refresh is
automatic or directive-driven.
reference/schema.md›
# Schema Reference
## Contents
- [Defining Types](#defining-types)
- [Core Directives](#core-directives)
- [Relationships](#relationships)
- [Data Types](#data-types)
- [Enumerations](#enumerations)
______________________________________________________________________
## Defining Types
Types with `@table` map to PostgreSQL tables. SQL Connect auto-generates an
implicit `id: UUID!` primary key.
```graphql
type Movie @table {
# id: UUID! is auto-added
title: String!
releaseYear: Int
genre: String
}
```
### Customizing Tables
```graphql
type Movie @table(name: "movies", key: "id", singular: "movie", plural: "movies") {
id: UUID! @col(name: "movie_id") @default(expr: "uuidV4()")
title: String!
releaseYear: Int @col(name: "release_year")
genre: String @col(dataType: "varchar(20)")
}
```
### User Table with Auth
```graphql
type User @table(key: "uid") {
uid: String! @default(expr: "auth.uid")
email: String! @unique
displayName: String @col(dataType: "varchar(100)")
createdAt: Timestamp! @default(expr: "request.time")
}
```
______________________________________________________________________
## Core Directives
### @table
Defines a database table.
| Argument | Description |
| ---------- | ------------------------------------------ |
| `name` | PostgreSQL table name (snake_case default) |
| `key` | Primary key field(s), default `["id"]` |
| `singular` | Singular name for generated fields |
| `plural` | Plural name for generated fields |
### @col
Customizes column mapping.
| Argument | Description |
| ---------- | ----------------------------------------------------- |
| `name` | Column name in PostgreSQL |
| `dataType` | PostgreSQL type: `serial`, `varchar(n)`, `text`, etc. |
| `size` | Required for `Vector` type |
### @default
Sets default value for inserts.
| Argument | Description |
| -------- | ------------------------------------------------------------------------------------------------------------ |
| `value` | Literal value: `@default(value: "draft")` |
| `expr` | CEL expression: `@default(expr: "uuidV4()")`, `@default(expr: "auth.uid")`, `@default(expr: "request.time")` |
| `sql` | Raw SQL: `@default(sql: "now()")` |
**Common expressions:**
- `uuidV4()` - Generate UUID
- `auth.uid` - Current user's Firebase Auth UID
- `request.time` - Server timestamp
### @unique
Adds unique constraint.
```graphql
type User @table {
email: String! @unique
}
# Composite unique
type Review @table @unique(fields: ["movie", "user"]) {
movie: Movie!
user: User!
rating: Int
}
```
### @index
Creates database index for query performance.
```graphql
type Movie @table @index(fields: ["genre", "releaseYear"], order: [ASC, DESC]) {
title: String! @index
genre: String
releaseYear: Int
}
```
| Argument | Description |
| -------- | ------------------------------------------------------------- |
| `fields` | Fields for composite index (on @table) |
| `order` | `[ASC]` or `[DESC]` for each field |
| `type` | `BTREE` (default), `GIN` (arrays), `HNSW`/`IVFFLAT` (vectors) |
### @searchable
Enables full-text search on String fields.
```graphql
type Post @table {
title: String! @searchable
body: String! @searchable(language: "english")
}
# Usage
query SearchPosts($q: String!) @auth(level: PUBLIC) {
posts_search(query: $q) { id title body }
}
```
______________________________________________________________________
## Relationships
### One-to-Many (Implicit Foreign Key)
```graphql
type Post @table {
id: UUID! @default(expr: "uuidV4()")
author: User! # Creates authorId foreign key
title: String!
}
type User @table {
id: UUID! @default(expr: "uuidV4()")
name: String!
# Auto-generated: posts_on_author: [Post!]!
}
```
### @ref Directive
Customizes foreign key reference.
```graphql
type Post @table {
author: User! @ref(fields: "authorId", references: "id")
authorId: UUID! # Explicit FK field
}
```
| Argument | Description |
| ---------------- | ----------------------------------- |
| `fields` | Local FK field name(s) |
| `references` | Target field(s) in referenced table |
| `constraintName` | PostgreSQL constraint name |
**Cascade behavior:**
- Required reference (`User!`): CASCADE DELETE (post deleted when user deleted)
- Optional reference (`User`): SET NULL (authorId set to null when user deleted)
### One-to-One
Use `@unique` on the reference field:
```graphql
type User @table { id: UUID! name: String! }
type UserProfile @table {
user: User! @unique # One profile per user
bio: String
avatarUrl: String
}
# Query: user.userProfile_on_user
```
### Many-to-Many
Use a join table with composite primary key:
```graphql
type Movie @table { id: UUID! title: String! }
type Actor @table { id: UUID! name: String! }
type MovieActor @table(key: ["movie", "actor"]) {
movie: Movie!
actor: Actor!
role: String! # Extra data on relationship
}
# Generated fields:
# - movie.actors_via_MovieActor: [Actor!]!
# - actor.movies_via_MovieActor: [Movie!]!
# - movie.movieActors_on_movie: [MovieActor!]!
```
______________________________________________________________________
## Data Types
| GraphQL Type | PostgreSQL Default | Other PostgreSQL Types |
| ------------ | ------------------ | --------------------------- |
| `String` | `text` | `varchar(n)`, `char(n)` |
| `Int` | `int4` | `int2`, `serial` |
| `Int64` | `bigint` | `bigserial`, `numeric` |
| `Float` | `float8` | `float4`, `numeric` |
| `Boolean` | `boolean` | |
| `UUID` | `uuid` | |
| `Date` | `date` | |
| `Timestamp` | `timestamptz` | Stored as UTC |
| `Any` | `jsonb` | |
| `Vector` | `vector` | Requires `@col(size: N)` |
| `[Type]` | Array | e.g., `[String]` → `text[]` |
______________________________________________________________________
## Enumerations
```graphql
enum Status {
DRAFT
PUBLISHED
ARCHIVED
}
type Post @table {
status: Status! @default(value: DRAFT)
allowedStatuses: [Status!]
}
```
**Rules:**
- Enum names: PascalCase, no underscores
- Enum values: UPPER_SNAKE_CASE
- Values are ordered (for comparison operations)
- Changing order or removing values is a breaking change
______________________________________________________________________
## Views (Advanced)
Map custom SQL queries to GraphQL types:
```graphql
type MovieStats @view(sql: """
SELECT
movie_id,
COUNT(*) as review_count,
AVG(rating) as avg_rating
FROM review
GROUP BY movie_id
""") {
movie: Movie @unique
reviewCount: Int
avgRating: Float
}
# Query movies with stats
query TopMovies @auth(level: PUBLIC) {
movies(orderBy: [{ rating: DESC }]) {
title
stats: movieStats_on_movie {
reviewCount avgRating
}
}
}
```
reference/sdk_admin_node.md›
# Admin Node SDK
Consult this file when writing server-side code (e.g., Cloud Functions) that
needs elevated privileges or needs to impersonate specific users.
### Best Practices for Agents
- **Understand Operation Storage**: SQL Connect queries and mutations are stored
on the server like Cloud Functions. Clients do not submit the raw operations.
Therefore, **whenever you update operations, you must regenerate the SDK and
redeploy services** that use it.
- **Follow Least Privilege**: Admin SDKs have unrestricted access by default.
Always use impersonation when possible to limit access.
- **Impersonation**: Use the `impersonate` parameter to run operations as a
specific user or as an unauthenticated user.
- **Impersonation Variables**: If you call an operation with optional variables
and want to pass impersonation options but without variables, you **MUST**
pass `undefined` as the first argument (variables) to clearly indicate no
variables are being provided.
- **Admin Operations**: If you create operations intended only for
administration, define them with `@auth(level: NO_ACCESS)`. This ensures they
can only be called via the Admin SDK with unrestricted access.
- **Resilient Enum Handling**: JavaScript/TypeScript does not enforce exhaustive
checks on enums. Always add a `default` branch to `switch` statements or an
`else` branch to handle unknown values gracefully when schemas evolve.
### Configuration in `connector.yaml`
To generate an Admin SDK, add the `adminNodeSdk` block to your `connector.yaml`:
```yaml
connectorId: my-connector
generate:
adminNodeSdk:
outputDir: "./admin-sdk"
package: "@dataconnect/admin-generated"
packageJsonDir: "." # Directory containing package.json
```
### Generation
Run the generation command:
```bash
npx -y firebase-tools@latest dataconnect:sdk:generate
```
### Usage Examples
#### 1. Impersonating an Unauthenticated User
Unauthenticated users can only run operations marked as `PUBLIC`.
```typescript
import { initializeApp } from "firebase-admin/app";
import { getDataConnect } from "firebase-admin/data-connect";
import { connectorConfig, getSongs } from "@dataconnect/admin-generated";
const adminApp = initializeApp();
const adminDc = getDataConnect(connectorConfig);
const songs = await getSongs(
adminDc,
{ limit: 4 },
{ impersonate: { unauthenticated: true } }
);
```
#### 2. Impersonating a Specific User (Cloud Functions)
When using callable Cloud Functions, the authentication token is automatically
verified.
```typescript
import { HttpsError, onCall } from "firebase-functions/https";
import { getMyFavoriteSongs } from "@dataconnect/admin-generated";
export const callableExample = onCall(async (req) => {
const authClaims = req.auth?.token;
if (!authClaims) {
throw new HttpsError("unauthenticated", "Unauthorized");
}
const favoriteSongs = await getMyFavoriteSongs(
adminDc,
undefined,
{ impersonate: { authClaims } }
);
return favoriteSongs;
});
```
#### 3. Impersonating a Specific User (Plain HTTP)
For non-callable endpoints, you must verify the token yourself.
```typescript
import { getAuth } from "firebase-admin/auth";
import { onRequest } from "firebase-functions/https";
import { getMyFavoriteSongs } from "@dataconnect/admin-generated";
const auth = getAuth();
export const httpExample = onRequest(async (req, res) => {
const token = req.header("authorization")?.replace(/^bearer\s+/i, "");
if (!token) {
res.sendStatus(401);
return;
}
let authClaims;
try {
authClaims = await auth.verifyIdToken(token);
} catch {
res.sendStatus(401);
return;
}
const favoriteSongs = await getMyFavoriteSongs(
adminDc,
undefined,
{ impersonate: { authClaims } }
);
res.send(favoriteSongs);
});
```
#### 4. Running with Unrestricted Access
Omit the `impersonate` parameter to run with full admin access. Only do this for
true administrative tasks.
```typescript
import { upsertSong } from "@dataconnect/admin-generated";
await upsertSong(adminDc, {
title: "New Song",
genre: "Rock"
});
```
reference/sdk_android.md›
# Android SDK
Consult this file when writing Android application code (Kotlin) that interacts
with the SQL Connect backend.
### Best Practices for Agents
- **Understand Operation Storage**: SQL Connect queries and mutations are stored
on the server like Cloud Functions. **Whenever you update operations, you must
regenerate the SDK and redeploy services** that use it to avoid breaking
clients.
- **Resilient Enum Handling**: The generated SDK forces handling of unknown
values by wrapping them in `EnumValue`. You must unwrap it into
`EnumValue.Known` or `EnumValue.Unknown` to handle schema updates gracefully.
- **Flow Behavior**: While you can collect a Flow from a query, note that **this
Flow is not updated in real-time automatically** by default. It only produces
a result when a new query result is retrieved using a call to the query's
`execute()` method.
- **Leverage Coroutines**: Call `.execute()` within a coroutine scope for
asynchronous operations.
### Dependencies (build.gradle.kts)
Ensure you have the Kotlin Serialization plugin and standard SQL Connect
dependencies:
```kotlin
plugins {
kotlin("plugin.serialization") version "1.8.22" // Must match Kotlin version
}
dependencies {
// [AGENT] Fetch the latest available BoM version from https://firebase.google.com/support/release-notes/android before adding this
implementation(platform("com.google.firebase:firebase-bom:34.12.0"))
implementation("com.google.firebase:firebase-dataconnect")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:1.5.1")
}
```
### Initialization
Retrieve the generated connector instance:
```kotlin
import com.google.firebase.dataconnect.generated.MoviesConnector
val connector = MoviesConnector.instance
// For local development with emulator
// Defaults to correct host for Android emulator (10.0.2.2)
connector.dataConnect.useEmulator()
// Or specify a non-default port:
// connector.dataConnect.useEmulator(port = 9999)
```
### Calling Operations
#### Basic Query
```kotlin
val result = connector.listMovies.execute()
result.data.movies.forEach { movie ->
println(movie.title)
}
```
#### Mutation
```kotlin
val newMovie = connector.createMovie.execute(
title = "Empire Strikes Back",
releaseYear = 1980,
genre = "Sci-Fi",
rating = 5
)
```
### Resilient Enum Handling
Unwrap the `EnumValue` to handle known and unknown cases safely.
```kotlin
val result = connector.listMovies.execute()
result.data.movies.forEach { movie ->
when (val aspect = movie.aspectratio) {
is EnumValue.Known -> println("Known aspect: ${aspect.value.name}")
is EnumValue.Unknown -> println("Unknown aspect: ${aspect.stringValue}")
}
}
```
### Client-Side Caching
Enable caching in `connector.yaml` to reduce requests and support offline
scenarios.
```yaml
generate:
kotlinSdk:
outputDir: "../android"
package: "com.google.firebase.dataconnect.generated"
clientCache:
maxAge: 5s
storage: persistent # Default for Android is persistent
```
Use policies in code:
```kotlin
val queryResult = queryRef.execute(QueryRef.FetchPolicy.CACHE_ONLY)
val queryResult = queryRef.execute(QueryRef.FetchPolicy.SERVER_ONLY)
```
### Data Type Mapping Reference
- GraphQL `String` -> Kotlin `String`
- GraphQL `Int` -> Kotlin `Int` (32-bit)
- GraphQL `Float` -> Kotlin `Double` (64-bit)
- GraphQL `Boolean` -> Kotlin `Boolean`
- GraphQL `UUID` -> Kotlin `java.util.UUID`
- GraphQL `Date` -> Kotlin `com.google.firebase.dataconnect.LocalDate`
- GraphQL `Timestamp` -> Kotlin `com.google.firebase.Timestamp`
- GraphQL `Int64` -> Kotlin `Long`
- GraphQL `Any` -> Kotlin `com.google.firebase.dataconnect.AnyValue`
reference/sdk_flutter.md›
# Flutter SDK
Consult this file when writing Flutter application code (Dart) that interacts
with the SQL Connect backend.
### Best Practices for Agents
- **Understand Operation Storage**: SQL Connect queries and mutations are stored
on the server like Cloud Functions. **Whenever you update operations, you must
regenerate the SDK and redeploy services** that use it to avoid breaking
clients.
- **Resilient Enum Handling**: The generated SDK forces handling of unknown
values for enumerations. Client code must unwrap the `EnumValue` object into
either `Known` or `Unknown` to handle schema updates gracefully.
- **Use Ref for Subscriptions**: Call `.ref()` on operation methods to get a
`QueryRef` for advanced usage like subscriptions.
- **Builder Pattern for Optionals**: Use the builder pattern for mutations with
optional fields.
### Installation
```bash
flutter pub add firebase_data_connect
```
### Imports
```dart
import 'package:firebase_data_connect/firebase_data_connect.dart';
// Import generated connector
import 'generated/movies.dart';
```
### Initialization
```dart
// For local development with emulator
MoviesConnector.instance.dataConnect.useDataConnectEmulator('127.0.0.1', 9399);
```
### Calling Operations
#### Basic Query
```dart
final response = await MoviesConnector.instance.listMovies().execute();
print(response.data.movies);
```
#### Mutation with Optional Fields (Builder Pattern)
```dart
await MoviesConnector.instance.createMovie(
title: 'Empire Strikes Back',
releaseYear: 1980,
genre: 'Sci-Fi'
).rating(5).execute();
```
### Resilient Enum Handling
When dealing with schema enumerations, use the forced unwrapping pattern to
handle unknown values (e.g., when a new value is added to the backend but client
is old).
```dart
final result = await MoviesConnector.instance.listMovies().execute();
if (result.data != null && result.data!.isNotEmpty) {
handleEnumValue(result.data![0].aspectratio);
}
void handleEnumValue(EnumValue<AspectRatio> aspectValue) {
if (aspectValue.value != null) {
switch(aspectValue.value!) {
case AspectRatio.ACADEMY:
print("Academy aspect");
break;
case AspectRatio.WIDESCREEN:
print("Widescreen aspect");
break;
// Add other known cases...
}
} else {
print("Unknown aspect ratio detected: ${aspectValue.stringValue}");
}
}
```
### Client-Side Caching
Enable caching in `connector.yaml` to reduce requests and support offline
scenarios.
```yaml
generate:
dartSdk: # Or the appropriate block for your project
outputDir: ../dart/
package: "dataconnect_generated"
clientCache:
maxAge: 5s
storage: memory # Or persistent for native
```
Use policies in code:
```dart
// Only serve cached values
await queryRef.execute(fetchPolicy: QueryFetchPolicy.cacheOnly);
// Unconditionally fetch fresh values
await queryRef.execute(fetchPolicy: QueryFetchPolicy.serverOnly);
```
### Real-time Subscriptions
```dart
final queryRef = MoviesConnector.instance.getMovieById(id: "<MOVIE_ID>").ref();
final subscription = queryRef.subscribe().listen((result) {
final movie = result.data.movie;
if (movie != null) {
updateUi(movie.title);
}
});
```
### Data Type Mapping Reference
- GraphQL `Timestamp` -> Dart `firebase_data_connect.Timestamp`
- GraphQL `Int` -> Dart `int`
- GraphQL `Date` -> Dart `DateTime`
- GraphQL `UUID` -> Dart `string`
- GraphQL `Float` -> Dart `double`
- GraphQL `Boolean` -> Dart `bool`
reference/sdk_ios.md›
# iOS SDK
Consult this file when writing iOS application code (Swift) that interacts with
the SQL Connect backend.
### Best Practices for Agents
- **Understand Operation Storage**: SQL Connect queries and mutations are stored
on the server like Cloud Functions. **Whenever you update operations, you must
regenerate the SDK and redeploy services** that use it to avoid breaking
clients.
- **Resilient Enum Handling**: The generated SDK forces handling of unknown
values by adding an `._UNKNOWN` case. Swift enforces exhaustive switch
statements, so you must handle this case.
- **Observable Macro**: By default, query refs support the `@Observable` macro
(iOS 17+), making them ideal for binding to SwiftUI views. The bindable query
results are available in the `data` variable of the query ref.
- **Handle Errors**: Use `try await` with operation execution as they are
asynchronous and may throw errors.
### Dependencies (Package.swift or SPM)
Configure the generated SDK as a package dependency in Xcode.
### Initialization
Retrieve the generated connector instance:
```swift
import FirebaseCore
import FirebaseDataConnect
// Assuming connector name is 'movies' in connector.yaml
// The connector name is the lower camel case connectorId defined in connector.yaml suffixed with the word 'Connector'
let connector = DataConnect.moviesConnector
// For local development with emulator
// Defaults to 127.0.0.1:9399
connector.useEmulator()
// Or specify a non-default port:
// connector.useEmulator(port: 9999)
```
### Calling Operations
#### Basic Query
```swift
let result = try await connector.listMovies.execute()
for movie in result.data.movies {
print(movie.title)
}
```
#### Mutation
```swift
let mutationResult = try await connector.createMovieMutation.execute(
title: "Empire Strikes Back",
releaseYear: 1980,
genre: "Sci-Fi",
rating: 5
)
```
### Resilient Enum Handling
Handle generated enums exhaustively, including the `._UNKNOWN` case.
```swift
do {
let result = try await DataConnect.moviesConnector.listMovies.execute()
if let data = result.data {
for movie in data.movies {
switch movie.aspectratio {
case .ACADEMY: print("academy")
case .WIDESCREEN: print("widescreen")
case .ANAMORPHIC: print("anamorphic")
case ._UNKNOWN(let unknownAspect): print("Unknown: \(unknownAspect)")
}
}
}
} catch {
// handle error
}
```
### Client-Side Caching
Enable caching in `connector.yaml` to reduce requests, support offline
scenarios, enable realtime support for queries.
```yaml
generate:
swiftSdk:
outputDir: "../ios"
package: "FirebaseDataConnectGenerated"
clientCache:
maxAge: 5s
storage: persistent # Default for iOS is persistent
```
Use cache policies in code:
```swift
try await execute(fetchPolicy: .cacheOnly)
try await execute(fetchPolicy: .serverOnly)
```
### Subscriptions (Realtime)
#### SwiftUI Example
```swift
import Combine
import SwiftUI
struct ListMovieView: View {
// QueryRef has the Observable attribute, so its properties will
// automatically trigger updates on changes.
private var queryRef = connector.listMoviesByGenreQuery.ref(genre: "Sci-Fi")
// Store the handle to unsubscribe from query updates.
@State private var querySub: AnyCancellable?
var body: some View {
VStack {
// Use the query results in a View.
ForEach(queryRef.data?.movies ?? [], id: \.id) { movie in
Text(movie.title)
}
}
.onAppear {
// Subscribe to the query for updates using the Observable macro.
Task {
do {
querySub = try await queryRef.subscribe().sink { _ in }
} catch {
print("Error subscribing to query: \(error)")
}
}
}
.onDisappear {
querySub?.cancel()
}
}
}
```
### Data Type Mapping Reference
- GraphQL `UUID` -> Swift `UUID`
- GraphQL `Date` -> Swift `FirebaseDataConnect.LocalDate`
- GraphQL `Timestamp` -> Swift `FirebaseCore.Timestamp`
- GraphQL `Int` -> Swift `Int`
- GraphQL `Float` -> Swift `Double`
- GraphQL `Boolean` -> Swift `Bool`
reference/sdk_web.md›
# Web SDK
Consult this file when writing client-side web code (TypeScript/JavaScript) that
interacts with the SQL Connect backend.
### Best Practices for Agents
- **Understand Operation Storage**: SQL Connect queries and mutations are stored
on the server like Cloud Functions. **Whenever you update operations, you must
regenerate the SDK and redeploy services** that use it to avoid breaking
clients.
- **Resilient Enum Handling**: JavaScript/TypeScript does not enforce exhaustive
checks on enums. Always add a `default` branch to `switch` statements or an
`else` branch to handle unknown values gracefully when schemas evolve.
- **TanStack Query vs. Native**: You can generate hooks for React/Angular using
TanStack Query. Choose either TanStack or SQL Connect's built-in real-time and
caching support, but do not use both in the same project. SQL Connect offers
normalized caching and remote invalidation.
- **Emulator Connection**: `connectDataConnectEmulator` is only required if
connecting to the emulator. Otherwise, the generated SDK auto-creates the
instance.
### Installation
```bash
npm install firebase
firebase init dataconnect:sdk
```
### Initialization
```typescript
import { connectDataConnectEmulator, getDataConnect } from 'firebase/data-connect';
import { connectorConfig } from '@dataconnect/generated';
const dataConnect = getDataConnect(connectorConfig);
// Configure the SDK to use local emulator
connectDataConnectEmulator(dataConnect, 'localhost', 9399);
```
### Calling Operations
#### Using `executeQuery` (Preferred for clarity)
```typescript
import { executeQuery } from 'firebase/data-connect';
import { listMoviesRef } from '@dataconnect/generated';
const ref = listMoviesRef();
const { data } = await executeQuery(ref);
console.log(data.movies);
```
#### Using Action Shortcuts
```typescript
import { listMovies } from '@dataconnect/generated';
listMovies().then(data => showInUI(data));
```
### Resilient Enum Handling
Use a `default` case or check against `Object.values`.
```typescript
import { getOldestMovie } from '@dataconnect/generated';
const queryResult = await getOldestMovie();
if (queryResult.data) {
const oldestMovieAspectRatio = queryResult.data.originalAspectRatio;
switch (oldestMovieAspectRatio) {
case AspectRatio.ACADEMY:
case AspectRatio.WIDESCREEN:
console.log('Filmed in Academy or Widescreen!');
break;
default:
// The default case will catch FULLSCREEN, etc.
console.log('Not filmed in Academy or Widescreen.');
break;
}
}
```
### Client-Side Caching
Enable caching in `connector.yaml`:
```yaml
generate:
javascriptSdk:
outputDir: ../web/
package: "@dataconnect/generated"
clientCache:
maxAge: 5s
storage: memory # Only memory is supported on Web
```
Use policies in code:
```typescript
await executeQuery(queryRef, QueryFetchPolicy.CACHE_ONLY);
await executeQuery(queryRef, QueryFetchPolicy.SERVER_ONLY);
```
### Subscriptions (Realtime)
Use `subscribe()` to receive live updates.
#### Web (Vanilla JS)
```typescript
import { subscribe } from 'firebase/data-connect';
import { getMovieByIdRef } from '@dataconnect/generated';
const queryRef = getMovieByIdRef({ id: "<MOVIE_ID>" });
const unsubscribe = subscribe(queryRef, (result) => {
console.log("Updated result:", result);
});
```
### TanStack Query Support (React)
To use React hooks, re-run `firebase init dataconnect:sdk` after adding React.
#### Usage
```typescript
import { useListAllMovies } from "@dataconnect/generated/react";
function MyComponent() {
const { isLoading, data, error } = useListAllMovies();
// handle loading, error, and data
}
```
### Data Type Mapping Reference
- GraphQL `Timestamp` -> TypeScript `string`
- GraphQL `Date` -> TypeScript `string`
- GraphQL `UUID` -> TypeScript `string`
- GraphQL `Int64` -> TypeScript `string`
- GraphQL `Double` -> TypeScript `number`
- GraphQL `Float` -> TypeScript `number`
reference/search.md›
# Search Solutions Reference (Vector & Full-Text Search)
Use this reference to design, configure, and implement search capabilities in
SQL Connect. SQL Connect supports three types of search:
1. **Vector Similarity Search (Semantic)**: Best for finding
conceptually/semantically similar rows (e.g., recommendations, "more like
this"). Requires Vertex AI.
1. **Full-Text Search (Lexical)**: Best for keyword and phrase search across
single or multiple columns. Supports lexical stemming.
1. **String Pattern Filters (Exact/Regex)**: Best for simple prefix, exact
match, or basic wildcard queries (uses standard Postgres indexing).
______________________________________________________________________
## Search Selection Guide
Use this comparative guide to choose the optimal search strategy for the user's
task:
| Feature / Capability | Vector Similarity Search | Full-Text Search | String Pattern Filters |
| :------------------- | :-------------------------------------------------- | :--------------------------------------------- | :----------------------------------------------------- |
| **Use Case** | Semantic search, recommendations, RAG pipelines. | Keyword search, parsing large text fields. | Exact matches, regular expressions, simple wildcards. |
| **Engine Support** | Vertex AI Embeddings + `pgvector` extension. | Native PostgreSQL full-text engine. | Native PostgreSQL indexing (`LIKE`, `ILIKE`). |
| **Matching Style** | Semantic/concept proximity. | Lexical stemming (tenses, root words). | Exact character sequence. |
| **Column Support** | Single column per query. | Multiple columns combined. | Multiple columns via standard logical filters (`_or`). |
| **Overhead** | High (API execution costs & vector column storage). | Medium (generates indices & tsvector columns). | Low (uses standard index / minimal storage). |
______________________________________________________________________
## 1. Vector Similarity Search (Semantic)
Perform semantic matching by generating vector embeddings representing the
semantic meaning of text.
### Schema Setup
- **Configure Column Dimensions**: Define the column dimension size using the
`@col(size: X)` directive — SQL Connect requires an explicit size for Vector
fields to allocate storage.
- **Match Model Specifications**: Ensure the column size matches the output
dimension of your chosen embedding model (e.g., **768** for Google Vertex AI's
`textembedding-gecko` models) to prevent runtime type mismatches.
```graphql
type Movie @table {
id: UUID! @default(expr: "uuidV4()")
title: String!
description: String
# Vector field for description embeddings (Vertex AI gecko size is 768)
descriptionEmbedding: Vector! @col(size: 768)
}
```
### Automatic Embedding Generation (`_embed` server value)
Ensure you use the exact same embedding model across all queries and mutations
on a given vector field — vector embeddings generated from different model
versions are incompatible and will result in poor search relevance or errors.
#### A. Generation on Insert
Use the `${vectorFieldName}_embed` input parameter to automatically generate and
store embeddings on creation.
```graphql
# connector/mutations.gql
mutation CreateMovieWithEmbedding($title: String!, $description: String!) @auth(level: USER) {
movie_insert(data: {
title: $title,
description: $description,
descriptionEmbedding_embed: {
model: "textembedding-gecko@003",
text: $description
}
})
}
```
#### B. Generation on Update
```graphql
# connector/mutations.gql
mutation UpdateMovieDescription($id: UUID!, $description: String!) @auth(level: USER) {
movie_update(
id: $id,
data: {
description: $description,
descriptionEmbedding_embed: {
model: "textembedding-gecko@003",
text: $description
}
}
)
}
```
### Similarity Search Queries
SQL Connect automatically generates a similarity query function for every
`Vector` field in the format: `${pluralType}_${vectorFieldName}_similarity`
#### A. Auto-Embedding Search
Use `compare_embed` to automatically convert the search query string into an
embedding on the fly using Vertex AI.
```graphql
# connector/queries.gql
query SearchMoviesByDescription($query: String!) @auth(level: PUBLIC) {
movies_descriptionEmbedding_similarity(
compare_embed: { model: "textembedding-gecko@003", text: $query },
limit: 5
) {
id
title
description
}
}
```
#### B. Custom Vector Search
Use `compare` to pass raw pre-computed float arrays (cast as a `Vector!`)
directly to the search without calling Vertex AI.
```graphql
# connector/queries.gql
query SearchMoviesByCustomVector($vector: Vector!, $limit: Int!) @auth(level: PUBLIC) {
movies_descriptionEmbedding_similarity(
compare: $vector,
method: L2,
limit: $limit
) {
id
title
}
}
```
### Tuning Vector Proximity
- **Distance Thresholding**: Select the `_metadata { distance }` field to
evaluate how close the results are, then define a tight threshold using the
`within` parameter.
- **Distance Metric Gotcha**: `L2` and `COSINE` return different distance
scales. Re-tune your `within` threshold if you change the `method` parameter,
as their distance ranges are not compatible.
```graphql
# connector/queries.gql
query SearchMoviesCosineSimilarity($query: String!) @auth(level: PUBLIC) {
movies_descriptionEmbedding_similarity(
compare_embed: { model: "textembedding-gecko@003", text: $query },
method: COSINE,
within: 0.5, # Maximum distance threshold
limit: 5
) {
id
title
_metadata { distance }
}
}
```
______________________________________________________________________
## 2. Full-Text Search (Lexical)
Perform fast, stemmed keyword/phrase searches over single or multiple text
columns in your table.
### Schema Setup
To index columns for full-text search, declare the `@searchable` directive on
the string fields inside your table schema.
```graphql
type Movie @table {
id: UUID! @default(expr: "uuidV4()")
title: String! @searchable # Default language (English)
genre: String @searchable
description: String @searchable(language: "french") # Custom language
rating: Float
}
```
- **Stemming Language**: By default, parsing uses English stemming. Configure
custom stemming using `@searchable(language: "languagename")`.
- **Multi-Column Stemming Gotcha**: Ensure all indexed columns use the exact
same language when searching over multiple columns in a single query —
PostgreSQL requires matching text search configurations for multi-column
queries.
______________________________________________________________________
### Full-Text Search Queries
SQL Connect automatically generates a full-text query function for each `@table`
containing `@searchable` fields in the format: `${pluralType}_search`
```graphql
# connector/queries.gql
query SearchMoviesLexical($query: String!) @auth(level: PUBLIC) {
movies_search(query: $query, limit: 10) {
id
title
genre
description
}
}
```
______________________________________________________________________
### Tuning Full-Text Queries
Configuring query arguments optimizes match relevance and search styles.
#### 1. Query Formats (`queryFormat` argument)
Configure the search interpretation using the `queryFormat` parameter:
- **`QUERY` (Default)**: Web-style search (e.g., `inception OR matrix`,
`-"space-travel"`, quotes for exact matches).
- **`PLAIN`**: Matches all words in the query string in any lexical order (e.g.,
`"brown dog"` matches `"the dog was brown"`).
- **`PHRASE`**: Matches the exact, contiguous phrase sequence (e.g.,
`"brown dog"` matches `"the brown dog"`, but NOT `"dog is brown"`).
- **`ADVANCED`**: Allows standard, complex PostgreSQL `tsquery` operators (e.g.
`inception & (matrix | sci-fi)`).
```graphql
# connector/queries.gql
query SearchMoviesExactPhrase($query: String!) @auth(level: PUBLIC) {
movies_search(query: $query, queryFormat: PHRASE) {
id
title
}
}
```
#### 2. Relevance Thresholding (`relevanceThreshold` and `_metadata.relevance`)
Results default to sorting by descending relevance rank. Select
`_metadata { relevance }` to inspect match rankings, then set a minimum
`relevanceThreshold` value to prune loose or irrelevant matches.
```graphql
# connector/queries.gql
query SearchMoviesHighRelevance($query: String!, $threshold: Float!) @auth(level: PUBLIC) {
movies_search(
query: $query,
relevanceThreshold: $threshold, # E.g., 0.05
limit: 5
) {
id
title
_metadata {
relevance
}
}
}
```
reference/security.md›
# Security Reference
## Contents
- [@auth Directive](#auth-directive)
- [Access Levels](#access-levels)
- [CEL Expressions](#cel-expressions)
- [@check and @redact](#check-and-redact)
- [Authorization Patterns](#authorization-patterns)
- [Anti-Patterns](#anti-patterns)
______________________________________________________________________
## @auth Directive
Every deployable query/mutation must have `@auth`. Without it, operations
default to `NO_ACCESS`.
```graphql
query PublicData @auth(level: PUBLIC) { ... }
query UserData @auth(level: USER) { ... }
query AdminOnly @auth(expr: "auth.token.admin == true") { ... }
```
| Argument | Description |
| ---------------- | -------------------------------------------------- |
| `level` | Preset access level |
| `expr` | CEL expression (alternative to level) |
| `insecureReason` | Suppress deploy warning for PUBLIC/unfiltered USER |
______________________________________________________________________
## Access Levels
| Level | Who Can Access | CEL Equivalent |
| --------------------- | -------------------------------------------- | ------------------------------------------------------------------------ |
| `PUBLIC` | Anyone, authenticated or not | `true` |
| `USER_ANON` | Any authenticated user (including anonymous) | `auth.uid != nil` |
| `USER` | Authenticated users (excludes anonymous) | `auth.uid != nil && auth.token.firebase.sign_in_provider != 'anonymous'` |
| `USER_EMAIL_VERIFIED` | Users with verified email | `auth.uid != nil && auth.token.email_verified` |
| `NO_ACCESS` | Admin SDK only | `false` |
> **Important:** Levels like `USER` are starting points. Always add filters or
> expressions to verify the user can access specific data.
______________________________________________________________________
## CEL Expressions
### Available Bindings
| Binding | Description |
| ----------------------- | ------------------------------------------ |
| `auth.uid` | Current user's Firebase UID |
| `auth.token` | Auth token claims (see below) |
| `vars` | Operation variables (e.g., `vars.movieId`) |
| `request.time` | Server timestamp |
| `request.operationName` | "query" or "mutation" |
### auth.token Fields
| Field | Description |
| --------------------------- | ------------------------------------------- |
| `email` | User's email address |
| `email_verified` | Boolean: email verified |
| `phone_number` | User's phone |
| `name` | Display name |
| `sub` | Firebase UID (same as auth.uid) |
| `firebase.sign_in_provider` | `password`, `google.com`, `anonymous`, etc. |
| `<custom_claim>` | Custom claims set via Admin SDK |
### Expression Examples
```graphql
# Check custom claim
@auth(expr: "auth.token.role == 'admin'")
# Check verified email domain
@auth(expr: "auth.token.email_verified && auth.token.email.endsWith('@company.com')")
# Check multiple conditions
@auth(expr: "auth.uid != nil && (auth.token.role == 'editor' || auth.token.role == 'admin')")
# Check variable
@auth(expr: "has(vars.status) && vars.status in ['draft', 'published']")
```
### Using eq_expr in Filters
Compare database fields with auth values:
```graphql
query MyPosts @auth(level: USER) {
posts(where: { authorUid: { eq_expr: "auth.uid" }}) {
id title
}
}
mutation UpdateMyPost($id: UUID!, $title: String!) @auth(level: USER) {
post_update(
first: { where: {
id: { eq: $id },
authorUid: { eq_expr: "auth.uid" }
}},
data: { title: $title }
)
}
```
______________________________________________________________________
## @check and @redact
Use `@check` to validate data and `@redact` to hide results from client:
### @check
Validates a field value; aborts if check fails.
```graphql
@check(expr: "this != null", message: "Not found")
@check(expr: "this == 'editor'", message: "Must be editor")
@check(expr: "this.exists(p, p.role == 'admin')", message: "No admin found")
```
| Argument | Description |
| ---------- | -------------------------------------------- |
| `expr` | CEL expression; `this` = current field value |
| `message` | Error message if check fails |
| `optional` | If `true`, pass when field not present |
### @redact
Hides field from response (still evaluated for @check):
```graphql
query @redact { ... } # Query result hidden but @check still runs
```
### Authorization Data Lookup
Check database permissions before allowing mutation:
```graphql
mutation UpdateMovie($id: UUID!, $title: String!)
@auth(level: USER)
@transaction {
# Step 1: Check user has permission
query @redact {
moviePermission(
key: { movieId: $id, userId_expr: "auth.uid" }
) @check(expr: "this != null", message: "No access to movie") {
role @check(expr: "this == 'editor'", message: "Must be editor")
}
}
# Step 2: Update if authorized
movie_update(id: $id, data: { title: $title })
}
```
### Validate Key Exists
```graphql
mutation MustDeleteMovie($id: UUID!) @auth(level: USER) @transaction {
movie_delete(id: $id)
@check(expr: "this != null", message: "Movie not found")
}
```
______________________________________________________________________
## Authorization Patterns
### User-Owned Resources
```graphql
# Create with owner
mutation CreatePost($content: String!) @auth(level: USER) {
post_insert(data: {
authorUid_expr: "auth.uid",
content: $content
})
}
# Read own data only
query MyPosts @auth(level: USER) {
posts(where: { authorUid: { eq_expr: "auth.uid" }}) {
id content
}
}
# Update own data only
mutation UpdatePost($id: UUID!, $content: String!) @auth(level: USER) {
post_update(
first: { where: { id: { eq: $id }, authorUid: { eq_expr: "auth.uid" }}},
data: { content: $content }
)
}
# Delete own data only
mutation DeletePost($id: UUID!) @auth(level: USER) {
post_delete(
first: { where: { id: { eq: $id }, authorUid: { eq_expr: "auth.uid" }}}
)
}
```
### Role-Based Access
```graphql
# Admin-only query
query AllUsers @auth(expr: "auth.token.admin == true") {
users { id email name }
}
# Role from database
mutation AdminAction($id: UUID!) @auth(level: USER) @transaction {
query @redact {
user(key: { uid_expr: "auth.uid" }) {
role @check(expr: "this == 'admin'", message: "Admin required")
}
}
# ... admin action
}
```
### Public Data with Filters
```graphql
query PublicPosts @auth(level: PUBLIC) {
posts(where: {
visibility: { eq: "public" },
publishedAt: { lt_expr: "request.time" }
}) {
id title content
}
}
```
### Tiered Access (Pro Content)
```graphql
query ProContent @auth(expr: "auth.token.plan == 'pro'") {
posts(where: { visibility: { in: ["public", "pro"] }}) {
id title content
}
}
```
______________________________________________________________________
## Anti-Patterns
### ❌ Don't Pass User ID as Variable
```graphql
# BAD - any user can pass any userId
query GetUserPosts($userId: String!) @auth(level: USER) {
posts(where: { authorUid: { eq: $userId }}) { ... }
}
# GOOD - use auth.uid
query GetMyPosts @auth(level: USER) {
posts(where: { authorUid: { eq_expr: "auth.uid" }}) { ... }
}
```
### ❌ Don't Use USER Without Filters
```graphql
# BAD - any authenticated user sees all documents
query AllDocs @auth(level: USER) {
documents { id title content }
}
# GOOD - filter to user's documents
query MyDocs @auth(level: USER) {
documents(where: { ownerId: { eq_expr: "auth.uid" }}) { ... }
}
```
### ❌ Don't Trust Unverified Email
```graphql
# BAD - email not verified
@auth(expr: "auth.token.email.endsWith('@company.com')")
# GOOD - verify email first
@auth(expr: "auth.token.email_verified && auth.token.email.endsWith('@company.com')")
```
### ❌ Don't Use PUBLIC/USER for Prototyping
During development, set operations to `NO_ACCESS` until you implement proper
authorization. Use emulator and VS Code extension for testing.
SKILL.md›
---
name: firebase-data-connect
description: Builds and deploys Firebase SQL Connect (aka Firebase Data Connect) backends with PostgreSQL securely. Use when designing schemas with tables and relations, writing authorized queries and mutations, configuring real-time data updates, or generating type-safe SDKs. Use when you need a relational database with Firebase, or when the user mentions SQL Connect or Data Connect.
metadata:
category: Databases
---
# Firebase SQL Connect
Firebase SQL Connect is a relational database service using Cloud SQL for
PostgreSQL with GraphQL schema, auto-generated queries/mutations, and type-safe
SDKs.
> [!NOTE] **Product Rename**: Firebase Data Connect was renamed to **Firebase
> SQL Connect**. All instructions, references, and examples in this skill
> repository referring to "Data Connect" or "Firebase Data Connect" apply to
> "SQL Connect" and "Firebase SQL Connect" as well.
## Project Structure
```text
dataconnect/
├── dataconnect.yaml # Service configuration
├── seed_data.gql # LOCAL ONLY — prototype/test data
├── schema/
│ └── schema.gql # Data model (types with @table)
└── connector/
├── connector.yaml # Connector config + SDK generation
├── queries.gql # Queries
└── mutations.gql # Mutations
```
## Key Tools for Validation
Rely on these two mechanisms to ensure project correctness:
1. **Review GraphQL Schema**: Both user-defined and generated extensions (in
`.dataconnect/schema/main/`).
1. **Validate Operations**: Run
`npx -y firebase-tools@latest dataconnect:compile` against the schema.
## Operation Strategies: GraphQL vs. Native SQL
Always default to **Native GraphQL**. **Native SQL lacks type safety** and
bypasses schema-enforced structures. Only use **Native SQL** when the user
explicitly requests it or when the task requires advanced database features.
| Strategy | When to use | Implementation |
| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| **Native GraphQL** (Default) | Almost all use cases. Standard CRUD, basic filtering/sorting, simple relational joins. Requires full type safety. | Auto-generated fields (`movie_insert`, `movies`). Strong typing and schema enforcement. |
| **Native SQL** (Advanced) | PostgreSQL extensions (e.g., PostGIS), window functions (`RANK()`), complex aggregations, or highly tuned sub-queries. | Raw SQL string literals via `_select`, `_execute`, etc. Requires strict positional parameters (`$1`). No type safety. |
## Development Workflow
Follow this strict workflow to build your application. You **must** read the
linked reference files for each step to understand the syntax and available
features.
### 1. Define Data Model (`schema/schema.gql`)
Define your GraphQL types, tables, and relationships (which map to a Postgres
schema).
> **Read [reference/schema.md](reference/schema.md)** for:
>
> - `@table`, `@col`, `@default`
> - Relationships (`@ref`, one-to-many, many-to-many)
> - Data types (UUID, Vector, JSON, etc.)
### 2. Define Authorized Operations (`connector/queries.gql`, `connector/mutations.gql`)
Write the queries and mutations your client will use, including authorization
logic. SQL Connect is secure by default.
> **Read [reference/operations.md](reference/operations.md)** for:
>
> - **Queries**: Filtering (`where`), Ordering (`orderBy`), Pagination
> (`limit`/`offset`).
> - **Mutations**: Create (`_insert`), Update (`_update`), Delete (`_delete`).
> - **Upserts**: Use `_upsert` to "insert or update" records (CRITICAL for user
> profiles).
> - **Transactions**: Use `@transaction` for multi-step atomic operations. Use
> `_expr: "response.<prevStep>"` to pass data between steps.
>
> **Read [reference/security.md](reference/security.md)** for authorization:
>
> - `@auth(level: ...)` for PUBLIC, USER, or NO_ACCESS.
> - `@check` and `@redact` for row-level security and validation.
>
> **Read [reference/realtime.md](reference/realtime.md)** for real-time
> subscriptions:
>
> - `@refresh` directive for time-based polling and event-driven updates.
> - CEL conditions to scope refresh triggers precisely.
>
> **Read [reference/native_sql.md](reference/native_sql.md)** for Native SQL
> operations:
>
> - Embedding raw SQL with `_select`, `_selectFirst`, `_execute`
> - Strict rules for positional parameters (`$1`, `$2`), quoting, and CTEs
> - Advanced PostgreSQL features (PostGIS, Window Functions)
### 3. Use type-safe SDK in your apps
Generate type-safe code for your client platform.
Configure SDK generation in `connector.yaml`:
```yaml
connectorId: my-connector
generate:
javascriptSdk:
outputDir: "../web-app/src/lib/dataconnect"
package: "@movie-app/dataconnect"
kotlinSdk:
outputDir: "../android-app/app/src/main/kotlin/com/example/dataconnect"
package: "com.example.dataconnect"
swiftSdk:
outputDir: "../ios-app/DataConnect"
```
Generate SDKs:
```bash
npx -y firebase-tools@latest dataconnect:sdk:generate
```
For platform-specific instructions on how to use the generated SDKs, read:
- **Web (TypeScript)**: [reference/sdk_web.md](reference/sdk_web.md)
- **Android (Kotlin)**: [reference/sdk_android.md](reference/sdk_android.md)
- **iOS (Swift)**: [reference/sdk_ios.md](reference/sdk_ios.md)
- **Admin (Node.js)**:
[reference/sdk_admin_node.md](reference/sdk_admin_node.md)
- **Flutter (Dart)**: [reference/sdk_flutter.md](reference/sdk_flutter.md)
______________________________________________________________________
## Feature Capability Map
If you need to implement a specific feature, consult the mapped reference file:
| Feature | Reference File | Key Concepts |
| :------------------------------ | :----------------------------------------------------------- | :------------------------------------------------- |
| **Data Modeling** | [reference/schema.md](reference/schema.md) | `@table`, `@unique`, `@index`, Relations |
| **Vector Search** | [reference/search.md](reference/search.md) | `Vector`, `@col(dataType: "vector")`, embeddings |
| **Full-Text Search** | [reference/search.md](reference/search.md) | `@searchable`, `movies_search` |
| **Upserting Data** | [reference/operations.md](reference/operations.md) | `_upsert` mutations |
| **Complex Filters** | [reference/operations.md](reference/operations.md) | `_or`, `_and`, `_not`, `eq`, `contains` |
| **Transactions** | [reference/operations.md](reference/operations.md) | `@transaction`, `response` binding |
| **Environment Config** | [reference/config.md](reference/config.md) | `dataconnect.yaml`, `connector.yaml` |
| **Realtime Subscriptions** | [reference/realtime.md](reference/realtime.md) | `@refresh`, `subscribe()`, auto-refresh |
| **Cloud Functions Integration** | [reference/cloud_functions.md](reference/cloud_functions.md) | `onMutationExecuted`, triggering events |
| **Data Seeding & Migrations** | [reference/data_seeding.md](reference/data_seeding.md) | `seed_data.gql`, `_insertMany`, Admin SDK bulk |
| **Starter Templates** | [templates.md](templates.md) | CRUD, user-owned resources, many-to-many, SDK init |
______________________________________________________________________
## Deployment & CLI
> **Read [reference/config.md](reference/config.md)** for deep dive on
> configuration.
Follow these patterns based on your current task:
### How to initialize SQL Connect in a Firebase project
1. Understand the app idea. Ask clarification questions if unclear.
1. Run `npx -y firebase-tools@latest init dataconnect`.
1. Validate that the app template and generated SDK are setup.
### How to build apps using SQL Connect locally
1. Start the emulator:
`npx -y firebase-tools@latest emulators:start --only dataconnect`.
1. Write schema and operations.
1. Seed local test data into `seed_data.gql`. Read
[reference/data_seeding.md](reference/data_seeding.md#local-prototyping-data-seeding).
1. Run `npx -y firebase-tools@latest dataconnect:compile` or
`npx -y firebase-tools@latest dataconnect:sdk:generate` to validate them.
1. Use the operations in your app and build it.
### How to deploy SQL Connect to Cloud SQL
1. Run `npx -y firebase-tools@latest deploy --only dataconnect`.
## Examples
For complete, working code examples of schemas and operations, see
**[examples.md](examples.md)**.
For ready-to-use starter templates (CRUD, user-owned resources, many-to-many,
YAML configs, SDK init), see **[templates.md](templates.md)**.
templates.md›
# Templates
Ready-to-use templates for common Firebase SQL Connect patterns.
______________________________________________________________________
## Basic CRUD Schema
```graphql
# schema.gql
type Item @table {
id: UUID! @default(expr: "uuidV4()")
name: String!
description: String
createdAt: Timestamp! @default(expr: "request.time")
updatedAt: Timestamp! @default(expr: "request.time")
}
```
```graphql
# queries.gql
query ListItems @auth(level: PUBLIC) {
items(orderBy: [{ createdAt: DESC }]) {
id name description createdAt
}
}
query GetItem($id: UUID!) @auth(level: PUBLIC) {
item(id: $id) { id name description createdAt updatedAt }
}
```
```graphql
# mutations.gql
mutation CreateItem($name: String!, $description: String) @auth(level: USER) {
item_insert(data: { name: $name, description: $description })
}
mutation UpdateItem($id: UUID!, $name: String, $description: String) @auth(level: USER) {
item_update(id: $id, data: {
name: $name,
description: $description,
updatedAt_expr: "request.time"
})
}
mutation DeleteItem($id: UUID!) @auth(level: USER) {
item_delete(id: $id)
}
```
______________________________________________________________________
## User-Owned Resources
```graphql
# schema.gql
type User @table(key: "uid") {
uid: String! @default(expr: "auth.uid")
email: String! @unique
displayName: String
}
type Note @table {
id: UUID! @default(expr: "uuidV4()")
owner: User!
title: String!
content: String
createdAt: Timestamp! @default(expr: "request.time")
}
```
```graphql
# queries.gql
query MyNotes @auth(level: USER) {
notes(
where: { owner: { uid: { eq_expr: "auth.uid" }}},
orderBy: [{ createdAt: DESC }]
) { id title content createdAt }
}
query GetMyNote($id: UUID!) @auth(level: USER) {
note(
first: { where: {
id: { eq: $id },
owner: { uid: { eq_expr: "auth.uid" }}
}}
) { id title content }
}
```
```graphql
# mutations.gql
mutation CreateNote($title: String!, $content: String) @auth(level: USER) {
note_insert(data: {
owner: { uid_expr: "auth.uid" },
title: $title,
content: $content
})
}
mutation UpdateNote($id: UUID!, $title: String, $content: String) @auth(level: USER) {
note_update(
first: { where: { id: { eq: $id }, owner: { uid: { eq_expr: "auth.uid" }}}},
data: { title: $title, content: $content }
)
}
mutation DeleteNote($id: UUID!) @auth(level: USER) {
note_delete(
first: { where: { id: { eq: $id }, owner: { uid: { eq_expr: "auth.uid" }}}}
)
}
```
______________________________________________________________________
## Many-to-Many Relationship
```graphql
# schema.gql
type Tag @table {
id: UUID! @default(expr: "uuidV4()")
name: String! @unique
}
type Article @table {
id: UUID! @default(expr: "uuidV4()")
title: String!
content: String!
}
type ArticleTag @table(key: ["article", "tag"]) {
article: Article!
tag: Tag!
}
```
```graphql
# queries.gql
query ArticlesByTag($tagName: String!) @auth(level: PUBLIC) {
articles(where: {
articleTags_on_article: { tag: { name: { eq: $tagName }}}
}) {
id title
tags: tags_via_ArticleTag { name }
}
}
query ArticleWithTags($id: UUID!) @auth(level: PUBLIC) {
article(id: $id) {
id title content
tags: tags_via_ArticleTag { id name }
}
}
```
```graphql
# mutations.gql
mutation AddTagToArticle($articleId: UUID!, $tagId: UUID!) @auth(level: USER) {
articleTag_insert(data: {
article: { id: $articleId },
tag: { id: $tagId }
})
}
mutation RemoveTagFromArticle($articleId: UUID!, $tagId: UUID!) @auth(level: USER) {
articleTag_delete(key: { articleId: $articleId, tagId: $tagId })
}
```
______________________________________________________________________
## dataconnect.yaml Template
```yaml
specVersion: "v1"
serviceId: "my-service"
location: "us-central1"
schema:
source: "./schema"
datasource:
postgresql:
database: "fdcdb"
cloudSql:
instanceId: "my-instance"
connectorDirs: ["./connector"]
```
______________________________________________________________________
## connector.yaml Template
```yaml
connectorId: "default"
generate:
javascriptSdk:
outputDir: "../web/src/lib/dataconnect"
package: "@myapp/dataconnect"
kotlinSdk:
outputDir: "../android/app/src/main/kotlin/com/myapp/dataconnect"
package: "com.myapp.dataconnect"
swiftSdk:
outputDir: "../ios/MyApp/DataConnect"
dartSdk:
outputDir: "../flutter/lib/dataconnect"
package: myapp_dataconnect
```
______________________________________________________________________
## Firebase Init Commands
```bash
# Initialize SQL Connect in project
npx -y firebase-tools@latest init dataconnect
# Initialize with specific project
npx -y firebase-tools@latest use <project-id>
npx -y firebase-tools@latest init dataconnect
# Start emulator for development
npx -y firebase-tools@latest emulators:start --only dataconnect
# Generate SDKs
npx -y firebase-tools@latest dataconnect:sdk:generate
# Deploy to production
npx -y firebase-tools@latest deploy --only dataconnect
```
______________________________________________________________________
## SDK Initialization (Web)
```typescript
// lib/firebase.ts
import { initializeApp } from 'firebase/app';
import { getAuth } from 'firebase/auth';
import { getDataConnect, connectDataConnectEmulator } from 'firebase/data-connect';
import { connectorConfig } from '@myapp/dataconnect';
const firebaseConfig = {
apiKey: "...",
authDomain: "...",
projectId: "...",
};
export const app = initializeApp(firebaseConfig);
export const auth = getAuth(app);
export const dataConnect = getDataConnect(app, connectorConfig);
// Connect to emulator in development
if (import.meta.env.DEV) {
connectDataConnectEmulator(dataConnect, 'localhost', 9399);
}
```
```typescript
// Example usage
import { listItems, createItem } from '@myapp/dataconnect';
// List items
const { data } = await listItems();
console.log(data.items);
// Create item (requires auth)
await createItem({ name: 'New Item', description: 'Description' });
```
______________________________________________________________________
## Realtime Query Templates
### Time-Based Polling
```graphql
query LiveDashboard
@auth(level: PUBLIC)
@refresh(every: { seconds: 30 }) {
items(orderBy: [{ updatedAt: DESC }], limit: 20) {
id name updatedAt
}
}
```
### Event-Driven Refresh
```graphql
query ItemList($categoryId: UUID!)
@auth(level: PUBLIC)
@refresh(onMutationExecuted: {
operation: "CreateItem",
condition: "request.variables.categoryId == mutation.variables.categoryId"
}) {
items(where: { category: { id: { eq: $categoryId }}}) {
id name createdAt
}
}
```
### Client Subscribe (Web)
```typescript
import { liveDashboardRef } from '@myapp/dataconnect';
import { subscribe } from 'firebase/data-connect';
const unsubscribe = subscribe(liveDashboardRef(), {
onNext: (result) => {
// Called immediately with current data, then on each refresh
renderDashboard(result.data.items);
},
onError: (error) => console.error('Subscription error:', error)
});
// Cleanup when done
// unsubscribe();
```