# FraiseQL Table Naming Conventions: tb_, v_, tv_ Pattern

Understanding and optimizing the table/view naming pattern for Rust-first architecture

---

## 🎯 The Naming Convention

FraiseQL uses a **prefix-based naming pattern** to indicate the type and purpose of database objects:

```
tb_*  → Base Tables (normalized, write-optimized)
v_*   → Views (standard SQL views, read-optimized)
tv_*  → Table Views (denormalized tables matching GraphQL types)
mv_*  → Materialized Views (pre-computed aggregations)
```

**Key Insight**: `tv_*` (table views) are **TABLES** that store denormalized, pre-composed data matching the GraphQL types exposed by the API.

---

## 📊 Detailed Analysis of Each Pattern

### Pattern 1: `tb_*` - Base Tables (Source of Truth)

**Purpose**: Normalized, write-optimized tables

**Example**:
```sql
-- Base table: normalized schema with trinity pattern
CREATE TABLE tb_user (
    pk_user INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,  -- Internal fast joins
    id UUID UNIQUE NOT NULL DEFAULT gen_random_uuid(),         -- Public API
    identifier TEXT UNIQUE,                                     -- Human-readable (optional)
    first_name TEXT NOT NULL,
    last_name TEXT NOT NULL,
    email TEXT NOT NULL UNIQUE,
    created_at TIMESTAMPTZ DEFAULT NOW(),
    updated_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE tb_post (
    pk_post INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    id UUID UNIQUE NOT NULL DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL,  -- References tb_user(id), not pk_user
    title TEXT NOT NULL,
    content TEXT NOT NULL,
    created_at TIMESTAMPTZ DEFAULT NOW(),
    FOREIGN KEY (user_id) REFERENCES tb_user(id)
);

CREATE TABLE tb_comment (
    pk_comment INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    id UUID UNIQUE NOT NULL DEFAULT gen_random_uuid(),
    post_id UUID NOT NULL,
    user_id UUID NOT NULL,
    content TEXT NOT NULL,
    created_at TIMESTAMPTZ DEFAULT NOW(),
    FOREIGN KEY (post_id) REFERENCES tb_post(id),
    FOREIGN KEY (user_id) REFERENCES tb_user(id)
);
```

**Characteristics**:
- ✅ Normalized (3NF)
- ✅ Write-optimized (no duplication)
- ✅ Foreign keys enforced
- ✅ Source of truth
- ❌ Requires JOINs for queries
- ❌ Slower for read-heavy workloads

**When to Use**:
- Write operations (INSERT, UPDATE, DELETE)
- Data integrity enforcement
- As the source for `tv_*` and `v_*` objects

**GraphQL Mapping** (not recommended directly):
```python
import fraiseql

# Don't query tb_* directly in GraphQL
# Use tv_* or v_* instead

@type(sql_source="tb_user")  # ❌ Slow - requires JOINs
class User:
    ...
```

---

### Pattern 2: `v_*` - Standard Views (SQL Views)

**Purpose**: Pre-defined queries for common access patterns

**Example**:
```sql
-- View: Standard SQL view (query on read)
CREATE VIEW v_user AS
SELECT
    u.id,
    u.first_name,
    u.last_name,
    u.email,
    u.created_at,
    COALESCE(
        (
            SELECT json_agg(
                json_build_object(
                    'id', p.id,
                    'title', p.title,
                    'created_at', p.created_at
                )
                ORDER BY p.created_at DESC
            )
            FROM tb_post p
            WHERE p.user_id = u.id
            LIMIT 10
        ),
        '[]'::json
    ) as posts_json
FROM tb_user u;
```

**Characteristics**:
- ✅ No storage overhead (just a query)
- ✅ Always up-to-date (queries live data)
- ✅ Can have indexes on underlying tables
- ❌ Executes JOIN on every query (slow)
- ❌ Cannot index the view itself

**Performance**:
```sql
SELECT * FROM v_user WHERE id = 1;
-- Execution: 5-10ms (JOIN + subquery on every read)
```

**When to Use**:
- ✅ Simple queries on small datasets (< 10k rows)
- ✅ When storage is constrained (no extra space for tv_* tables)
- ✅ When absolute freshness required (no staleness acceptable)
- ✅ Prototypes and development (quick to set up)
- ✅ Admin interfaces (performance less critical)

**When NOT to Use**:
- ❌ Large datasets (> 100k rows) - too slow (5-10ms per query)
- ❌ High-traffic GraphQL APIs - JOIN overhead kills performance
- ❌ Complex aggregations - better with mv_* materialized views

**GraphQL Mapping**:
```python
import fraiseql

@type(sql_source="v_user")  # ⚠️ OK for small datasets, not for production APIs
class User:
    id: int
    first_name: str
    posts_json: list[dict]  # JSON, not transformed
```

**Trade-offs**:
- Still slow (5-10ms per query due to JOINs)
- Returns JSON (snake_case), needs transformation
- No storage overhead but runtime performance cost
- Good for development, bad for production scale

---

### Pattern 3: `tv_*` - Table Views (Denormalized Tables Matching GraphQL Types)

**Purpose**: Pre-composed JSONB data for instant GraphQL responses

**Example**:
```sql
-- Table view (regular table, NOT generated column)
CREATE TABLE tv_user (
    id UUID PRIMARY KEY,  -- GraphQL uses UUID, not internal pk_user
    data JSONB NOT NULL,  -- Regular column, manually maintained
    updated_at TIMESTAMPTZ DEFAULT NOW()
);

-- Sync function (explicit - CRITICAL!)
CREATE FUNCTION fn_sync_tv_user(p_id UUID) RETURNS VOID AS $$
BEGIN
    INSERT INTO tv_user (id, data)
    SELECT
        u.id,
        jsonb_build_object(
            'id', u.id,
            'first_name', u.first_name,
            'last_name', u.last_name,
            'email', u.email,
            'created_at', u.created_at,
            'user_posts', COALESCE((
                SELECT jsonb_agg(
                    jsonb_build_object(
                        'id', p.id,
                        'title', p.title,
                        'content', p.content,
                        'created_at', p.created_at
                    )
                    ORDER BY p.created_at DESC
                )
                FROM tb_post p
                WHERE p.user_id = u.id
                LIMIT 10
            ), '[]'::jsonb)
        )
    FROM tb_user u
    WHERE u.id = p_id
    ON CONFLICT (id) DO UPDATE SET
        data = EXCLUDED.data,
        updated_at = NOW();
END;
$$ LANGUAGE plpgsql;

-- Populate from base table
INSERT INTO tv_user (id) SELECT id FROM tb_user;
UPDATE tv_user SET data = (SELECT data FROM v_user WHERE v_user.id = tv_user.id);

-- Triggers to keep in sync (call explicit sync function)
CREATE OR REPLACE FUNCTION trg_sync_tv_user()
RETURNS TRIGGER AS $$
BEGIN
    -- On tb_user changes
    IF TG_OP = 'INSERT' THEN
        INSERT INTO tv_user (id) VALUES (NEW.id);
        PERFORM fn_sync_tv_user(NEW.id);
    ELSIF TG_OP = 'UPDATE' THEN
        PERFORM fn_sync_tv_user(NEW.id);
    ELSIF TG_OP = 'DELETE' THEN
        DELETE FROM tv_user WHERE id = OLD.id;
    END IF;
    RETURN NULL;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_sync_tv_user
AFTER INSERT OR UPDATE OR DELETE ON tb_user
FOR EACH ROW EXECUTE FUNCTION trg_sync_tv_user();

-- Also sync when posts change
CREATE OR REPLACE FUNCTION trg_sync_tv_user_on_post()
RETURNS TRIGGER AS $$
BEGIN
    -- Update user's tv_user when their posts change
    PERFORM fn_sync_tv_user(COALESCE(NEW.user_id, OLD.user_id));
    RETURN NULL;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_sync_tv_user_on_post
AFTER INSERT OR UPDATE OR DELETE ON tb_post
FOR EACH ROW EXECUTE FUNCTION trg_sync_tv_user_on_post();
```

**Characteristics**:
- ✅ **It's a TABLE** (not a view!)
- ✅ Regular table with explicit sync (not generated column)
- ✅ Pre-composed JSONB matching GraphQL types (instant reads)
- ✅ JSONB format (ready for Rust transform)
- ✅ Embedded relations (no JOINs needed)
- ✅ Zero N+1 queries
- ✅ Rebuildable at any time from base tables
- ⚠️ Storage overhead (1.5-2x) — but storage is cheap, computation is expensive
- ⚠️ Write amplification (sync on every change) — acceptable trade-off for read-heavy workloads

**Performance**:
```sql
SELECT data FROM tv_user WHERE id = 1;
-- Execution: 0.05ms (simple indexed lookup!)

-- vs View (v_user):
SELECT * FROM v_user WHERE id = 1;
-- Execution: 5-10ms (JOIN + subquery)

-- Speedup: 100-200x!
```

**Note**: tv_* table views require explicit sync via `fn_sync_tv_*()` functions in mutations. This is not automatic - it's a deliberate design choice for performance and control.

**Why table views, not materialized views?**
PostgreSQL materialized views require `REFRESH MATERIALIZED VIEW` which recomputes the *entire* view—expensive and slow for frequently changing data. Table views are regular tables with row-level sync: mutations only recompute affected rows via `fn_sync_tv_*()`. This enables fast, incremental updates instead of full table refreshes.

**When to Use**:
- ✅ Read-heavy workloads (10:1+ read:write)
- ✅ GraphQL APIs (perfect fit!)
- ✅ Predictable query patterns
- ✅ Relations with limited cardinality (<100 items)
- ✅ When you need explicit control over sync timing

**GraphQL Mapping** (optimal):
```python
import fraiseql

@fraiseql.type(sql_source="tv_user", jsonb_column="data")
class User:
    id: int
    first_name: str  # Rust transforms to firstName
    last_name: str   # Rust transforms to lastName
    email: str
    user_posts: list[Post] | None = None  # Embedded!

@fraiseql.query
async def user(info, id: int) -> User:
    # 1. SELECT data FROM tv_user WHERE id = $1 (0.05ms)
    # 2. Rust transform (0.5ms)
    # Total: 0.55ms (vs 5-10ms with v_user!)
    repo = Repository(info.context["db"], info.context)
    return await repo.find_one("tv_user", id=id)

@fraiseql.mutation
async def update_user(info, id: int, input: UpdateUserInput) -> User:
    # Update base table
    repo = Repository(info.context["db"], info.context)
    await repo.update("tb_user", input, id=id)

    # CRITICAL: Explicitly sync tv_user
    await repo.call_function("fn_sync_tv_user", {"p_id": id})

    # Return updated data
    return await repo.find_one("tv_user", id=id)
```

---

### Pattern 4: `mv_*` - Materialized Views (Aggregations)

**Purpose**: Pre-computed aggregations with manual refresh

**Example**:
```sql
-- Materialized view: complex aggregation
CREATE MATERIALIZED VIEW mv_dashboard AS
SELECT
    COUNT(*) as total_users,
    COUNT(*) FILTER (WHERE created_at > NOW() - INTERVAL '7 days') as new_users,
    jsonb_build_object(
        'top_users', (
            SELECT jsonb_agg(
                jsonb_build_object(
                    'id', u.id,
                    'name', u.first_name || ' ' || u.last_name,
                    'post_count', COUNT(p.id)
                )
            )
            FROM tb_user u
            LEFT JOIN tb_post p ON p.user_id = u.id
            GROUP BY u.id
            ORDER BY COUNT(p.id) DESC
            LIMIT 10
        )
    ) as top_users
FROM tb_user;

-- Refresh manually (cron job)
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_dashboard;
```

**Characteristics**:
- ✅ Pre-computed aggregations
- ✅ Very fast reads (0.1-0.5ms)
- ✅ Handles complex queries (GROUP BY, multiple JOINs)
- ⚠️ Stale data (until refresh)
- ❌ Manual refresh needed
- ❌ Cannot use for transactional data

**Performance**:
```sql
-- Live query (no MV)
SELECT COUNT(*), ... complex aggregation ...
-- Execution: 150ms

-- Materialized view
SELECT * FROM mv_dashboard;
-- Execution: 0.1ms

-- Speedup: 1500x!
```

**When to Use**:
- ✅ Complex aggregations (GROUP BY, COUNT, SUM)
- ✅ Analytics dashboards
- ✅ Acceptable staleness (5-60 minutes)
- ❌ Not for real-time data
- ❌ Not for user-specific data

---

## 🏗️ Recommended Architecture Patterns

### Pattern A: Pure `tv_*` Architecture (Recommended for Most Cases)

**Concept**: Only use base tables (`tb_*`) and table views (`tv_*`) for reads

```
┌─────────────────────────────────────┐
│ tb_user, tb_post, tb_comment        │
│ (Normalized base tables)            │
└─────────────┬───────────────────────┘
              │
              │ Triggers sync
              ▼
┌─────────────────────────────────────┐
│ tv_user, tv_post                    │
│ (Table views with pre-composed JSONB)│
│ - Auto-updates on write             │
│ - Embedded relations                │
│ - Ready for Rust transform          │
└─────────────┬───────────────────────┘
              │
              │ GraphQL queries
              ▼
┌─────────────────────────────────────┐
│ Rust Transformer                    │
│ - Snake_case → camelCase            │
│ - Field selection                   │
│ - 0.5ms transformation              │
└─────────────────────────────────────┘
```

**Schema**:
```sql
-- Base tables (tb_*)
CREATE TABLE tb_user (...);
CREATE TABLE tb_post (...);

-- Table views (tv_*)
CREATE TABLE tv_user (
    id UUID PRIMARY KEY,  -- Exposed to GraphQL
    data JSONB NOT NULL   -- Pre-composed data matching GraphQL type
);

CREATE TABLE tv_post (
    id UUID PRIMARY KEY,  -- Exposed to GraphQL
    data JSONB NOT NULL   -- Pre-composed data matching GraphQL type
);

-- Sync functions (explicit)
CREATE FUNCTION fn_sync_tv_user(p_id UUID) RETURNS VOID AS ...;
CREATE FUNCTION fn_sync_tv_post(p_id UUID) RETURNS VOID AS ...;

-- Sync triggers
CREATE TRIGGER trg_sync_tv_user AFTER INSERT OR UPDATE OR DELETE ON tb_user
    FOR EACH ROW EXECUTE FUNCTION trg_sync_tv_user();
```

**Benefits**:
- ✅ Simple (only 2 layers)
- ✅ Always up-to-date (explicit sync in mutations)
- ✅ Fast reads (0.05-0.5ms)
- ✅ Works with Rust transformer
- ✅ Explicit control over sync timing

**Drawbacks**:
- ❌ Must call sync functions in mutations (not automatic)
- ❌ Storage overhead (1.5-2x)
- ❌ Write amplification (sync on every change)

**When to Use**: 90% of GraphQL APIs

---

### Pattern B: Hybrid `tv_*` + `mv_*` Architecture (Advanced)

**Concept**: Use `tv_*` table views for entity queries, `mv_*` for aggregations

```
┌─────────────────────────────────────┐
│ tb_user, tb_post, tb_comment        │
└─────────────┬───────────────────────┘
              │
              ├───────────────┬────────────────┐
              │               │                │
              ▼               ▼                ▼
┌───────────────────┐  ┌──────────┐  ┌──────────────┐
│ tv_user, tv_post  │  │ mv_*     │  │ Direct       │
│ (Real-time)       │  │ (Stale)  │  │ (Slow)       │
└───────────────────┘  └──────────┘  └──────────────┘
         │                   │              │
         ▼                   ▼              ▼
    GraphQL              Dashboard      Admin
    API                  Queries        Queries
```

**Schema**:
```sql
-- Base tables
CREATE TABLE tb_user (...);
CREATE TABLE tb_post (...);

-- Table views (real-time queries)
CREATE TABLE tv_user (id UUID PRIMARY KEY, data JSONB NOT NULL);
CREATE FUNCTION fn_sync_tv_user(p_id UUID) RETURNS VOID AS ...;

-- Materialized views (analytics)
CREATE MATERIALIZED VIEW mv_dashboard AS ...;
CREATE MATERIALIZED VIEW mv_user_stats AS ...;
```

**When to Use**:
- Public API (use `tv_*` for fast entity queries)
- Analytics dashboard (use `mv_*` for aggregations)
- Admin panel (query `tb_*` directly for flexibility)

---

### Pattern C: Minimal Architecture (Development/Small Apps)

**Concept**: Skip tv_* table views, use base tables + Rust transformer directly

```
┌─────────────────────────────────────┐
│ users, posts, comments              │
│ (Standard tables, no prefixes)      │
│ - JSONB column with generated data  │
└─────────────┬───────────────────────┘
              │
              ▼
┌─────────────────────────────────────┐
│ Rust Transformer                    │
└─────────────────────────────────────┘
```

**Schema**:
```sql
-- Simple: no tb_/tv_ split
CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    first_name TEXT,
    last_name TEXT,

    -- Generated JSONB column (embedded relations)
    data JSONB GENERATED ALWAYS AS (
        jsonb_build_object(
            'id', id,
            'first_name', first_name,
            'user_posts', (SELECT jsonb_agg(...) FROM posts WHERE user_id = users.id LIMIT 10)
        )
    ) STORED
);
```

**Benefits**:
- ✅ Simplest setup (no prefixes, no sync triggers)
- ✅ Still fast (0.5-1ms queries)
- ✅ Good for small apps

**When to Use**:
- MVPs and prototypes
- Small applications (<10k users)
- Development/testing

---

## 📊 Performance Comparison

### Query Performance by Pattern

| Pattern | Read Time | Write Time | Storage | Complexity |
|---------|-----------|------------|---------|------------|
| **tb_* only** (no optimization) | 5-10ms | 0.5ms | 1x | Low |
| **v_* views** | 5-10ms | 0.5ms | 1x | Low |
| **tv_* table views** | 0.05-0.5ms | 1-2ms | 1.5-2x | Medium |
| **mv_* views** | 0.1-0.5ms | 0.5ms | 1.2-1.5x | Medium |

### When to Use Each

```
Decision Tree:

Read:write ratio?
├─ 1:1 (balanced) → Use tb_* + direct queries (simple)
├─ 10:1 (read-heavy) → Use tb_* + tv_* table views (optimal for GraphQL)
└─ 100:1 (extremely read-heavy) → Use tb_* + tv_* table views + mv_* (full optimization)

Query type?
├─ Entity lookup (user, post) → tv_* table view (0.5ms)
├─ List with filters → tv_* table view (0.5-1ms)
├─ Complex aggregation → mv_* (0.1-0.5ms)
└─ Admin/flexibility → tb_* direct (5-10ms, acceptable)
```

---

## 🎯 Recommended Naming Convention

### For New Projects (Simplified)

**Don't use prefixes for small projects**:
```sql
-- Simple naming (no prefixes)
CREATE TABLE users (...);
CREATE TABLE posts (...);

-- Generated column for GraphQL
ALTER TABLE users ADD COLUMN data JSONB GENERATED ALWAYS AS (...) STORED;
```

**Use prefixes for large projects** (clarity at scale):
```sql
-- Base tables (write operations)
CREATE TABLE tb_user (...);
CREATE TABLE tb_post (...);

-- Transform tables (GraphQL reads)
CREATE TABLE tv_user (id UUID PRIMARY KEY, data JSONB NOT NULL);
CREATE TABLE tv_post (id UUID PRIMARY KEY, data JSONB NOT NULL);
CREATE FUNCTION fn_sync_tv_user(p_id UUID) RETURNS VOID AS ...;
CREATE FUNCTION fn_sync_tv_post(p_id UUID) RETURNS VOID AS ...;

-- Materialized views (analytics)
CREATE MATERIALIZED VIEW mv_dashboard AS ...;
```

---

## 💡 FraiseQL Type Registration

### With `tv_*` Tables

```python
import fraiseql

@fraiseql.type(sql_source="tv_user", jsonb_column="data")
class User:
    id: int
    first_name: str
    user_posts: list[Post] | None

@fraiseql.query
async def user(info, id: int) -> User:
    # Queries tv_user (0.05ms lookup + 0.5ms Rust transform = 0.55ms)
    repo = Repository(info.context["db"], info.context)
    return await repo.find_one("tv_user", id=id)
```

### Without Prefixes (Simpler)

```python
import fraiseql

@fraiseql.type(sql_source="users", jsonb_column="data")
class User:
    id: int
    first_name: str

@fraiseql.query
async def user(info, id: int) -> User:
    repo = Repository(info.context["db"], info.context)
    return await repo.find_one("users", id=id)
```

---

## 🚀 Migration Path

### Current Setup (Complex)

```
tb_* (base tables)
  ↓
v_* (views) ← Slow, not used much
  ↓
tv_* (table views) ← Optimal for GraphQL
  ↓
mv_* (materialized views) ← For aggregations
```

### Simplified Rust-First Architecture

```
tb_* (base tables)
  ↓
tv_* (table views) ← Main GraphQL data source
  ↓
mv_* (optional, for analytics)
```

**Remove**:
- ❌ `v_*` views (not needed with `tv_*`)
- ❌ Complex sync logic (use triggers)

**Keep**:
- ✅ `tb_*` (source of truth)
- ✅ `tv_*` (GraphQL optimization)
- ✅ `mv_*` (optional, for aggregations)

---

## 🎯 Key Takeaways

### 1. `tv_*` Are Tables with Explicit Sync!

`tv_*` (table views) are **regular TABLES** that store denormalized data matching GraphQL types and require explicit sync:
```sql
CREATE TABLE tv_user (  -- ← It's a TABLE!
    id UUID PRIMARY KEY,
    data JSONB NOT NULL  -- ← Regular column, NOT generated
);

-- CRITICAL: Must call sync function in mutations
CREATE FUNCTION fn_sync_tv_user(p_id UUID) RETURNS VOID AS ...;
```

### 2. `tv_*` Table View Pattern is Optimal for GraphQL

**Why**:
- ✅ Pre-composed JSONB matching GraphQL types (instant reads)
- ✅ Embedded relations (no JOINs)
- ✅ Perfect for Rust transformer
- ✅ Always up-to-date (explicit sync in mutations)

**Performance**: 0.05-0.5ms (100-200x faster than views/JOINs)

### 3. Choose `v_*` or `tv_*` Based on Scale

**`v_*` (SQL views)** are appropriate for:
- Small datasets (< 10k rows) where JOIN overhead is acceptable
- Development/prototypes where setup speed matters
- Cases where absolute freshness is required

**`tv_*` (table views)** are optimal for:
- Large datasets (> 100k rows) needing sub-millisecond queries
- Production GraphQL APIs with high traffic
- Complex relations with pre-composed JSONB matching GraphQL types

### 4. Use `mv_*` Selectively

**Materialized views** for aggregations only:
- Complex GROUP BY queries
- Analytics dashboards
- Acceptable staleness

### 5. Naming Convention is Optional

**Small projects**: Skip prefixes (users, posts)
**Large projects**: Use prefixes for clarity (tb_user, tv_user, mv_dashboard)

---

## 📋 Recommended Setup

### Production GraphQL API

```sql
-- Base tables (source of truth) with trinity pattern
CREATE TABLE tb_user (
    pk_user INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    id UUID UNIQUE NOT NULL DEFAULT gen_random_uuid(),
    identifier TEXT UNIQUE,
    first_name TEXT, ...
);
CREATE TABLE tb_post (
    pk_post INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    id UUID UNIQUE NOT NULL DEFAULT gen_random_uuid(),
    user_id UUID, ...
);

-- Table views (GraphQL queries)
CREATE TABLE tv_user (
    id UUID PRIMARY KEY,  -- Exposed to GraphQL
    data JSONB NOT NULL   -- Pre-composed data matching GraphQL type
);

-- Sync functions (CRITICAL - explicit sync)
CREATE FUNCTION fn_sync_tv_user(p_id UUID) RETURNS VOID AS ...;

-- Sync triggers (call explicit sync functions)
CREATE TRIGGER trg_sync_tv_user AFTER INSERT OR UPDATE OR DELETE ON tb_user
    FOR EACH ROW EXECUTE FUNCTION trg_sync_tv_user();
CREATE TRIGGER trg_sync_tv_user_on_post AFTER INSERT OR UPDATE OR DELETE ON tb_post
    FOR EACH ROW EXECUTE FUNCTION trg_sync_tv_user_on_post();

-- Optional: Materialized views for dashboards
CREATE MATERIALIZED VIEW mv_dashboard AS ...;
```

**Result**: 0.5-1ms entity queries, 0.1-0.5ms aggregations

---

## 🚀 Summary

**Pattern Recommendation**:

| Use Case | Pattern | Tables |
|----------|---------|--------|
| **MVP/Small app** | Simple or `v_*` | `users` (with JSONB) or `tb_user` + `v_user` |
| **Production API** | `tb_*` + `tv_*` table views | `tb_user` (writes) + `tv_user` (reads) |
| **With analytics** | `tb_*` + `tv_*` + `mv_*` | Add `mv_dashboard` for aggregations |

**Key Insight**: The `tv_*` table view pattern (tables with explicit sync) is **ideal for Rust-first FraiseQL**:
- 0.05-0.5ms reads
- Always up-to-date (via explicit sync)
- Perfect for Rust transformer
- 100-200x faster than JOINs

**Simplification**: Prefer `tv_*` table views for production GraphQL APIs, but `v_*` views work well for smaller applications where JOIN overhead is acceptable.

---

## 🔔 Observer Pattern for External Integrations

Don't call external APIs from database functions. Write events to a table; let workers process them.

This is the standard pattern for integrating PL/pgSQL mutations with SendGrid, Slack, Stripe, or any external service.

### Event Log Table

```sql
CREATE TABLE app.tb_event_log (
    id BIGSERIAL PRIMARY KEY,
    tenant_id UUID NOT NULL,
    event_type TEXT NOT NULL,           -- 'send_email', 'slack_notify', 'webhook'
    payload JSONB NOT NULL,             -- Event data
    created_at TIMESTAMPTZ DEFAULT NOW(),
    processed_at TIMESTAMPTZ,           -- NULL until processed
    retry_count INTEGER DEFAULT 0
);

CREATE INDEX idx_event_log_pending
    ON app.tb_event_log(created_at)
    WHERE processed_at IS NULL;
```

### In Your Mutation

```sql
CREATE FUNCTION fn_create_order(...) RETURNS JSONB AS $$
BEGIN
    -- Business logic
    INSERT INTO tb_order (...) VALUES (...) RETURNING id INTO v_order_id;

    -- Emit event (atomic with business logic)
    INSERT INTO app.tb_event_log (tenant_id, event_type, payload)
    VALUES (
        auth_tenant_id,
        'order_created',
        jsonb_build_object(
            'order_id', v_order_id,
            'customer_email', v_email,
            'amount', v_amount
        )
    );

    -- Sync table view
    PERFORM fn_sync_tv_order(v_order_id);

    RETURN jsonb_build_object('success', true, 'data', ...);
END;
$$ LANGUAGE plpgsql;
```

### External Worker (Python)

```python
async def process_events():
    while True:
        events = await db.fetch('''
            SELECT id, event_type, payload
            FROM app.tb_event_log
            WHERE processed_at IS NULL
            ORDER BY created_at LIMIT 100
        ''')

        for event in events:
            try:
                if event['event_type'] == 'order_created':
                    await send_confirmation_email(event['payload'])
                elif event['event_type'] == 'slack_notify':
                    await post_to_slack(event['payload'])

                await db.execute(
                    'UPDATE app.tb_event_log SET processed_at = NOW() WHERE id = $1',
                    event['id']
                )
            except Exception:
                await db.execute(
                    'UPDATE app.tb_event_log SET retry_count = retry_count + 1 WHERE id = $1',
                    event['id']
                )

        await asyncio.sleep(5)
```

### Why This Pattern?

| Approach | Problem |
|----------|---------|
| **Synchronous API calls in PL/pgSQL** | Requires extensions (pg_net), blocks transactions, no retry logic |
| **Application-level orchestration** | Distributed transactions, eventual consistency, lost events |
| **Observer Pattern** | ✅ ACID guarantees, ✅ Retry logic, ✅ Full audit trail, ✅ No lost events |

Events commit with your transaction—no lost messages. Workers poll at their own pace. Failed events retry automatically. The database is your queue.
