Initial commit

This commit is contained in:
St. Nebula
2026-04-23 23:58:59 -05:00
commit 47b9e3c159
257 changed files with 18913 additions and 0 deletions
@@ -0,0 +1,95 @@
---
name: firebase-data-connect
description: Build and deploy Firebase Data Connect backends with PostgreSQL. Use for schema design, GraphQL queries/mutations, authorization, and SDK generation for web, Android, iOS, and Flutter apps.
---
# Firebase Data Connect
Firebase Data Connect is a relational database service using Cloud SQL for PostgreSQL with GraphQL schema, auto-generated queries/mutations, and type-safe SDKs.
## Project Structure
```
dataconnect/
├── dataconnect.yaml # Service configuration
├── schema/
│ └── schema.gql # Data model (types with @table)
└── connector/
├── connector.yaml # Connector config + SDK generation
├── queries.gql # Queries
└── mutations.gql # Mutations
```
## 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.
> **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 Operations (`connector/queries.gql`, `connector/mutations.gql`)
Write the queries and mutations your client will use. Data Connect generates the underlying SQL.
> **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.
### 3. Secure Your App (`connector/` files)
Add authorization logic closely with your operations.
> **Read [reference/security.md](reference/security.md)** for:
> * `@auth(level: ...)` for PUBLIC, USER, or NO_ACCESS.
> * `@check` and `@redact` for row-level security and validation.
### 4. Generate & Use SDKs
Generate type-safe code for your client platform.
> **Read [reference/sdks.md](reference/sdks.md)** for:
> * Android (Kotlin), iOS (Swift), Web (TypeScript), Flutter (Dart).
> * How to initialize and call your queries/mutations.
> * **Nested Data**: See how to access related fields (e.g., `movie.reviews`).
---
## 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/advanced.md](reference/advanced.md) | `Vector`, `@col(dataType: "vector")` |
| **Full-Text Search** | [reference/advanced.md](reference/advanced.md) | `@searchable` |
| **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` |
---
## Deployment & CLI
> **Read [reference/config.md](reference/config.md)** for deep dive on configuration.
Common commands (run from project root):
```bash
# Initialize Data Connect
npx -y firebase-tools@latest init dataconnect
# Start local emulator
npx -y firebase-tools@latest emulators:start --only dataconnect
# Generate SDK code
npx -y firebase-tools@latest dataconnect:sdk:generate
# Deploy to production
npx -y firebase-tools@latest deploy --only dataconnect
```
## Examples
For complete, working code examples of schemas and operations, see **[examples.md](examples.md)**.
@@ -0,0 +1,377 @@
# Examples
Complete, working examples for common Data 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" }}
}}
)
}
```
---
## 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
})
}
```
@@ -0,0 +1,303 @@
# Advanced Features Reference
## Contents
- [Vector Similarity Search](#vector-similarity-search)
- [Full-Text Search](#full-text-search)
- [Cloud Functions Integration](#cloud-functions-integration)
- [Data Seeding & Bulk Operations](#data-seeding--bulk-operations)
---
## Vector Similarity Search
Semantic search using Vertex AI embeddings and PostgreSQL's `pgvector`.
### Schema Setup
```graphql
type Movie @table {
id: UUID! @default(expr: "uuidV4()")
title: String!
description: String
# Vector field for embeddings - size must match model output (768 for gecko)
descriptionEmbedding: Vector! @col(size: 768)
}
```
### Generate Embeddings in Mutations
Use `_embed` server value to auto-generate embeddings via Vertex AI:
```graphql
mutation CreateMovieWithEmbedding($title: String!, $description: String!)
@auth(level: USER) {
movie_insert(data: {
title: $title,
description: $description,
descriptionEmbedding_embed: {
model: "textembedding-gecko@003",
text: $description
}
})
}
```
### Similarity Search Query
Data Connect generates `_similarity` fields for Vector columns:
```graphql
query SearchMovies($query: String!) @auth(level: PUBLIC) {
movies_descriptionEmbedding_similarity(
compare_embed: { model: "textembedding-gecko@003", text: $query },
method: L2, # L2, COSINE, or INNER_PRODUCT
within: 2.0, # Max distance threshold
limit: 5
) {
id
title
description
_metadata { distance } # See how close each result is
}
}
```
### Similarity Parameters
| Parameter | Description |
|-----------|-------------|
| `compare` | Raw Vector to compare against |
| `compare_embed` | Generate embedding from text via Vertex AI |
| `method` | Distance function: `L2`, `COSINE`, `INNER_PRODUCT` |
| `within` | Max distance (results further are excluded) |
| `where` | Additional filters |
| `limit` | Max results to return |
### Custom Embeddings
Pass pre-computed vectors directly:
```graphql
mutation StoreCustomEmbedding($id: UUID!, $embedding: Vector!) @auth(level: USER) {
movie_update(id: $id, data: { descriptionEmbedding: $embedding })
}
query SearchWithCustomVector($vector: Vector!) @auth(level: PUBLIC) {
movies_descriptionEmbedding_similarity(
compare: $vector,
method: COSINE,
limit: 10
) { id title }
}
```
---
## Full-Text Search
Fast keyword/phrase search using PostgreSQL's full-text capabilities.
### Enable with @searchable
```graphql
type Movie @table {
title: String! @searchable
description: String @searchable(language: "english")
genre: String @searchable
}
```
### Search Query
Data Connect generates `_search` fields:
```graphql
query SearchMovies($query: String!) @auth(level: PUBLIC) {
movies_search(
query: $query,
queryFormat: QUERY, # QUERY, PLAIN, PHRASE, or ADVANCED
limit: 20
) {
id title description
_metadata { relevance } # Relevance score
}
}
```
### Query Formats
| Format | Description |
|--------|-------------|
| `QUERY` | Web-style (default): quotes, AND, OR supported |
| `PLAIN` | Match all words, any order |
| `PHRASE` | Match exact phrase |
| `ADVANCED` | Full tsquery syntax |
### Tuning Results
```graphql
query SearchWithThreshold($query: String!) @auth(level: PUBLIC) {
movies_search(
query: $query,
relevanceThreshold: 0.05, # Min relevance score
where: { genre: { eq: "Action" }},
orderBy: [{ releaseYear: DESC }]
) { id title }
}
```
### Supported Languages
`english` (default), `french`, `german`, `spanish`, `italian`, `portuguese`, `dutch`, `danish`, `finnish`, `norwegian`, `swedish`, `russian`, `arabic`, `hindi`, `simple`
---
## Cloud Functions Integration
Trigger Cloud Functions when mutations execute.
### Basic Trigger (Node.js)
```typescript
import { onMutationExecuted } from "firebase-functions/dataconnect";
import { logger } from "firebase-functions";
export const onUserCreate = onMutationExecuted(
{
service: "myService",
connector: "default",
operation: "CreateUser",
region: "us-central1" // Must match Data Connect location
},
(event) => {
const variables = event.data.payload.variables;
const returnedData = event.data.payload.data;
logger.info("User created:", returnedData);
// Send welcome email, sync to analytics, etc.
}
);
```
### Basic Trigger (Python)
```python
from firebase_functions import dataconnect_fn, logger
@dataconnect_fn.on_mutation_executed(
service="myService",
connector="default",
operation="CreateUser"
)
def on_user_create(event: dataconnect_fn.Event):
variables = event.data.payload.variables
returned_data = event.data.payload.data
logger.info("User created:", returned_data)
```
### Event Data
```typescript
// event.authType: "app_user" | "unauthenticated" | "admin"
// event.authId: Firebase Auth UID (for app_user)
// event.data.payload.variables: mutation input variables
// event.data.payload.data: mutation response data
// event.data.payload.errors: any errors that occurred
```
### Filtering with Wildcards
```typescript
// Trigger on all User* mutations
export const onUserMutation = onMutationExecuted(
{ operation: "User*" },
(event) => { /* ... */ }
);
// Capture operation name
export const onAnyMutation = onMutationExecuted(
{ service: "myService", operation: "{operationName}" },
(event) => {
console.log("Operation:", event.params.operationName);
}
);
```
### Use Cases
- **Data sync**: Replicate to Firestore, BigQuery, external APIs
- **Notifications**: Send emails, push notifications on events
- **Async workflows**: Image processing, data aggregation
- **Audit logging**: Track all data changes
> ⚠️ **Avoid infinite loops**: Don't trigger mutations that would fire the same trigger. Use filters to exclude self-triggered events.
---
## Data Seeding & Bulk Operations
### Local Prototyping with _insertMany
```graphql
mutation SeedMovies @transaction {
movie_insertMany(data: [
{ id: "uuid-1", title: "Movie 1", genre: "Action" },
{ id: "uuid-2", title: "Movie 2", genre: "Drama" },
{ id: "uuid-3", title: "Movie 3", genre: "Comedy" }
])
}
```
### Reset Data with _upsertMany
```graphql
mutation ResetData {
movie_upsertMany(data: [
{ id: "uuid-1", title: "Movie 1", genre: "Action" },
{ id: "uuid-2", title: "Movie 2", genre: "Drama" }
])
}
```
### Clear All Data
```graphql
mutation ClearMovies {
movie_deleteMany(all: true)
}
```
### Production: Admin SDK Bulk Operations
```typescript
import { initializeApp } from 'firebase-admin/app';
import { getDataConnect } from 'firebase-admin/data-connect';
const app = initializeApp();
const dc = getDataConnect({ location: "us-central1", serviceId: "my-service" });
const movies = [
{ id: "uuid-1", title: "Movie 1", genre: "Action" },
{ id: "uuid-2", title: "Movie 2", genre: "Drama" }
];
// Bulk insert
await dc.insertMany("movie", movies);
// Bulk upsert
await dc.upsertMany("movie", movies);
// Single operations
await dc.insert("movie", movies[0]);
await dc.upsert("movie", movies[0]);
```
### Emulator Data Persistence
```bash
# Export emulator data
npx -y firebase-tools@latest emulators:export ./seed-data
# Start with saved data
npx -y firebase-tools@latest emulators:start --only dataconnect --import=./seed-data
```
@@ -0,0 +1,267 @@
# 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 Data 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 Data 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 Data 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:
- Data 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
2. **Generate SQL diff**: `npx -y firebase-tools@latest dataconnect:sql:diff`
3. **Review migration**: Check breaking changes
4. **Deploy**: `npx -y firebase-tools@latest deploy --only dataconnect`
### Schema Migrations
Data 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 Data Connect
run: |
npx -y firebase-tools@latest deploy --only dataconnect --token ${{ secrets.FIREBASE_TOKEN }} --force
```
---
## VS Code Extension
Install "Firebase Data 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
}
```
@@ -0,0 +1,357 @@
# Operations Reference
## Contents
- [Generated Fields](#generated-fields)
- [Queries](#queries)
- [Mutations](#mutations)
- [Key Scalars](#key-scalars)
- [Multi-Step Operations](#multi-step-operations)
---
## Generated Fields
Data 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
---
## 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 }
}
```
### 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 })
}
```
@@ -0,0 +1,278 @@
# 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. Data 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
}
}
}
```
@@ -0,0 +1,287 @@
# SDK Reference
## Contents
- [SDK Generation](#sdk-generation)
- [Web SDK](#web-sdk)
- [Android SDK](#android-sdk)
- [iOS SDK](#ios-sdk)
- [Admin SDK](#admin-sdk)
---
## SDK Generation
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
```
---
## Web SDK
### Installation
```bash
npm install firebase
```
### Initialization
```typescript
import { initializeApp } from 'firebase/app';
import { getDataConnect, connectDataConnectEmulator } from 'firebase/data-connect';
import { connectorConfig } from '@movie-app/dataconnect';
const app = initializeApp(firebaseConfig);
const dc = getDataConnect(app, connectorConfig);
// For local development
if (import.meta.env.DEV) {
connectDataConnectEmulator(dc, 'localhost', 9399);
}
```
### Calling Operations
```typescript
// Generated SDK provides typed functions
import { listMovies, createMovie, getMovie } from '@movie-app/dataconnect';
// Accessing Nested Fields
const movie = await getMovie({ id: '...' });
// Relations are just properties on the object
const director = movie.data.movie.metadata.director;
const firstActor = movie.data.movie.actors[0].name;
// Query
const result = await listMovies();
console.log(result.data.movies);
// Query with variables
const movie = await getMovie({ id: 'uuid-here' });
// Mutation
const newMovie = await createMovie({
title: 'New Movie',
genre: 'Action'
});
console.log(newMovie.data.movie_insert); // Returns key
```
### Subscriptions
```typescript
import { listMoviesRef, subscribe } from '@movie-app/dataconnect';
const unsubscribe = subscribe(listMoviesRef(), {
onNext: (result) => {
console.log('Movies updated:', result.data.movies);
},
onError: (error) => {
console.error('Subscription error:', error);
}
});
// Later: unsubscribe();
```
### With Authentication
```typescript
import { getAuth, signInWithEmailAndPassword } from 'firebase/auth';
const auth = getAuth(app);
await signInWithEmailAndPassword(auth, email, password);
// SDK automatically includes auth token in requests
const myReviews = await myReviews(); // @auth(level: USER) query from examples.md
```
---
## Android SDK
### Dependencies (build.gradle.kts)
```kotlin
dependencies {
implementation(platform("com.google.firebase:firebase-bom:33.0.0"))
implementation("com.google.firebase:firebase-dataconnect")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:1.6.0")
}
```
### Initialization
```kotlin
import com.google.firebase.Firebase
import com.google.firebase.dataconnect.dataConnect
import com.example.dataconnect.MyConnector
val connector = MyConnector.instance
// For emulator
connector.dataConnect.useEmulator("10.0.2.2", 9399)
```
### Calling Operations
```kotlin
// Query
val result = connector.listMovies.execute()
result.data.movies.forEach { movie ->
println(movie.title)
// Access nested fields directly
println(movie.metadata?.director)
println(movie.actors.firstOrNull()?.name)
}
// Query with variables
val movie = connector.getMovie.execute(id = "uuid-here")
// Mutation
val newMovie = connector.createMovie.execute(
title = "New Movie",
genre = "Action"
)
```
### Flow Subscription
```kotlin
connector.listMovies.flow().collect { result ->
when (result) {
is DataConnectResult.Success -> updateUI(result.data.movies)
is DataConnectResult.Error -> showError(result.exception)
}
}
```
---
## iOS SDK
### Dependencies (Package.swift or SPM)
```swift
dependencies: [
.package(url: "https://github.com/firebase/firebase-ios-sdk.git", from: "11.0.0")
]
// Add FirebaseDataConnect to target dependencies
```
### Initialization
```swift
import FirebaseCore
import FirebaseDataConnect
FirebaseApp.configure()
let connector = MyConnector.shared
// For emulator
connector.useEmulator(host: "localhost", port: 9399)
```
### Calling Operations
```swift
// Query
let result = try await connector.listMovies.execute()
for movie in result.data.movies {
print(movie.title)
// Access nested fields directly
print(movie.metadata?.director ?? "Unknown")
print(movie.actors.first?.name ?? "No actors")
}
// Query with variables
let movie = try await connector.getMovie.execute(id: "uuid-here")
// Mutation
let newMovie = try await connector.createMovie.execute(
title: "New Movie",
genre: "Action"
)
```
### Combine Publisher
```swift
connector.listMovies.publisher
.sink(
receiveCompletion: { completion in
if case .failure(let error) = completion {
print("Error: \(error)")
}
},
receiveValue: { result in
self.movies = result.data.movies
}
)
.store(in: &cancellables)
```
---
## Admin SDK
Server-side operations with elevated privileges (bypasses @auth):
### Node.js
```typescript
import { initializeApp, cert } from 'firebase-admin/app';
import { getDataConnect } from 'firebase-admin/data-connect';
initializeApp({
credential: cert(serviceAccount)
});
const dc = getDataConnect();
// Execute operations (bypasses @auth)
const result = await dc.executeGraphql({
query: `query { users { id email } }`,
operationName: 'ListAllUsers'
});
// Or use generated Admin SDK
import { listAllUsers } from './admin-connector';
const users = await listAllUsers();
```
### Generate Admin SDK
In `connector.yaml`:
```yaml
generate:
nodeAdminSdk:
outputDir: "./admin-sdk"
package: "@app/admin-dataconnect"
```
Generate:
```bash
npx -y firebase-tools@latest dataconnect:sdk:generate
```
@@ -0,0 +1,289 @@
# 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.
@@ -0,0 +1,269 @@
# Templates
Ready-to-use templates for common Firebase Data 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 Data 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' });
```