Skip to content

Database Patterns

The tv_ Pattern: Projected Tables for GraphQL

Overview

The tv_ (projection table) pattern is FraiseQL's foundational architecture for efficient GraphQL queries. Despite the name, tv_ tables are actual PostgreSQL tables (not VIEWs), serving as denormalized projections of normalized write tables.

See Projection Tables in the Core Concepts Glossary for the canonical definition.

Key Principle: Write to normalized tables, read from denormalized tv_ projections.

Structure

Every tv_ table follows this exact structure:

CREATE TABLE tv_entity_name (
    -- Real columns for efficient filtering and indexing
    id UUID PRIMARY KEY,
    tenant_id UUID NOT NULL,

    -- Additional filter columns (indexed, fast queries)
    status TEXT,
    created_at TIMESTAMPTZ,
    user_id UUID,
    -- ... other frequently filtered fields

    -- Complete denormalized payload as JSONB
    data JSONB NOT NULL,

    -- Metadata
    updated_at TIMESTAMPTZ DEFAULT NOW()
);

-- Indexes on real columns (fast filtering)
CREATE INDEX idx_tv_entity_tenant ON tv_entity_name (tenant_id, created_at DESC);
CREATE INDEX idx_tv_entity_status ON tv_entity_name (status, tenant_id);

-- Optional: GIN index for JSONB queries
CREATE INDEX idx_tv_entity_data ON tv_entity_name USING GIN (data);

Why This Pattern?

Aspect tv_ Table (Actual Table) Traditional VIEW Materialized VIEW
Query speed Fastest (indexed) Slow (computes on read) Fast (pre-computed)
Filtering Real columns (indexed) Computed columns Pre-computed
Updates Trigger-based N/A Manual REFRESH
Consistency Event-driven Always fresh Scheduled refresh
GraphQL fit Perfect (JSONB data) Complex queries Static snapshots

Answer: tv_ tables are real tables with indexed columns for fast filtering and JSONB payloads for complete nested data.

Example: Orders

Normalized Write Tables (OLTP, referential integrity with trinity pattern):

CREATE TABLE tb_order (
    pk_order 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,                                       -- Optional human-readable
    tenant_id UUID NOT NULL,
    user_id UUID NOT NULL,
    status TEXT NOT NULL,
    total DECIMAL(10,2),
    created_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE tb_order_item (
    pk_order_item INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    id UUID UNIQUE NOT NULL DEFAULT gen_random_uuid(),
    order_id UUID NOT NULL,                -- References tb_order(id), not pk_order
    product_id UUID NOT NULL,
    quantity INT NOT NULL,
    price DECIMAL(10,2),
    FOREIGN KEY (order_id) REFERENCES tb_order(id)
);

Denormalized Read Table (OLAP, GraphQL-optimized):

CREATE TABLE tv_order (
    -- Filter columns (indexed for fast WHERE clauses)
    id UUID PRIMARY KEY,
    tenant_id UUID NOT NULL,
    status TEXT,
    user_id UUID,
    total DECIMAL(10,2),
    created_at TIMESTAMPTZ,

    -- Complete nested payload (GraphQL-ready)
    data JSONB NOT NULL,

    updated_at TIMESTAMPTZ DEFAULT NOW()
);

-- Essential indexes
CREATE INDEX idx_tv_order_tenant_created
    ON tv_order (tenant_id, created_at DESC);
CREATE INDEX idx_tv_order_status
    ON tv_order (status, tenant_id)
    WHERE status != 'cancelled';  -- Partial index for active orders

Example data JSONB:

{
  "__typename": "Order",
  "id": "d613dfba-3440-4c90-bb7b-877175621e08",
  "status": "shipped",
  "total": 299.99,
  "createdAt": "2025-10-09T10:30:00Z",
  "user": {
    "id": "a1b2c3d4-...",
    "email": "customer@example.com",
    "name": "John Doe"
  },
  "items": [
    {
      "id": "item-1",
      "productName": "Widget Pro",
      "quantity": 2,
      "price": 149.99
    }
  ],
  "shipping": {
    "address": "123 Main St",
    "trackingNumber": "1Z999AA10123456784"
  }
}

Synchronization Pattern

Trigger-Based Synchronization (not generated columns):

tv_ tables are maintained via explicit sync functions that rebuild the JSONB data when called. This provides predictable performance and full control over when synchronization occurs. See Explicit Sync Documentation for details.

Step 1: Create tv_ Table

-- tv_ table with JSONB data column (maintained via explicit sync)
CREATE TABLE tv_order (
    -- GraphQL identifier (matches tb_order.id)
    id UUID PRIMARY KEY,

    -- Filter columns (indexed for fast WHERE clauses)
    tenant_id UUID NOT NULL,
    status TEXT,
    user_id UUID,
    total DECIMAL(10,2),
    created_at TIMESTAMPTZ,

    -- Complete denormalized payload (maintained via explicit sync)
    data JSONB NOT NULL,

    updated_at TIMESTAMPTZ DEFAULT NOW()
);

-- Populate from existing tb_ data
INSERT INTO tv_order (id, tenant_id, status, user_id, total, created_at, data)
SELECT
    o.id,
    o.tenant_id,
    o.status,
    o.user_id,
    o.total,
    o.created_at,
    jsonb_build_object(
        '__typename', 'Order',
        'id', o.id,
        'status', o.status,
        'total', o.total,
        'createdAt', o.created_at,
        'user', (
            SELECT jsonb_build_object(
                'id', u.id,
                'email', u.email,
                'name', u.name
            )
            FROM tb_user u
            WHERE u.id = o.user_id
        ),
        'items', COALESCE(
            (
                SELECT jsonb_agg(jsonb_build_object(
                    'id', i.id,
                    'productName', i.product_name,
                    'quantity', i.quantity,
                    'price', i.price
                ) ORDER BY i.created_at)
                FROM tb_order_item i
                WHERE i.order_id = o.id
            ),
            '[]'::jsonb
        )
    )
FROM tb_order o;

Step 2: Explicit Synchronization (FraiseQL Approach)

Note: Traditional CQRS implementations use database triggers for automatic synchronization. FraiseQL uses explicit sync functions for better visibility and control. See Explicit Sync Documentation for details.

-- Explicit sync function (FraiseQL approach)
CREATE FUNCTION fn_sync_tv_order(p_order_id INT) RETURNS VOID AS $$
BEGIN
    INSERT INTO tv_order (id, data)
    SELECT id, data FROM v_order WHERE id = p_order_id
    ON CONFLICT (id) DO UPDATE SET
        data = EXCLUDED.data,
        updated_at = NOW();
END;
$$ LANGUAGE plpgsql;

-- Mutation functions call sync explicitly
CREATE FUNCTION fn_create_order(p_user_id INT, p_total DECIMAL) RETURNS JSONB AS $$
DECLARE v_order_id INT;
BEGIN
    INSERT INTO tb_order (user_id, total) VALUES (p_user_id, p_total)
    RETURNING id INTO v_order_id;

    PERFORM fn_sync_tv_order(v_order_id);  -- ← Explicit sync call
    RETURN (SELECT data FROM tv_order WHERE id = v_order_id);
END;
$$ LANGUAGE plpgsql;

-- Update function also syncs explicitly
CREATE FUNCTION fn_update_order_status(p_order_id INT, p_status TEXT) RETURNS JSONB AS $$
BEGIN
    UPDATE tb_order SET status = p_status WHERE id = p_order_id;
    PERFORM fn_sync_tv_order(p_order_id);  -- ← Explicit sync call
    RETURN (SELECT data FROM tv_order WHERE id = p_order_id);
END;
$$ LANGUAGE plpgsql;
RETURNS TRIGGER AS $$
DECLARE
    v_order_id UUID;
    v_updated_data JSONB;
BEGIN
    -- Determine affected order IDs
    IF TG_TABLE_NAME = 'tb_user' THEN
        -- When user changes, update all their orders
        FOR v_order_id IN
            SELECT id FROM tb_order WHERE user_id = COALESCE(NEW.id, OLD.id)
        LOOP
            -- Rebuild data for this order
            SELECT jsonb_build_object(
                '__typename', 'Order',
                'id', o.id,
                'status', o.status,
                'total', o.total,
                'createdAt', o.created_at,
                'user', jsonb_build_object(
                    'id', COALESCE(NEW.id, OLD.id),
                    'email', COALESCE(NEW.email, OLD.email),
                    'name', COALESCE(NEW.name, OLD.name)
                ),
                'items', COALESCE(
                    (
                        SELECT jsonb_agg(jsonb_build_object(
                            'id', i.id,
                            'productName', i.product_name,
                            'quantity', i.quantity,
                            'price', i.price
                        ) ORDER BY i.created_at)
                        FROM tb_order_item i
                        WHERE i.order_id = o.id
                    ),
                    '[]'::jsonb
                )
            ) INTO v_updated_data
            FROM tb_order o
            WHERE o.id = v_order_id;

            UPDATE tv_order SET data = v_updated_data, updated_at = NOW()
            WHERE id = v_order_id;
        END LOOP;

    ELSIF TG_TABLE_NAME = 'tb_order_item' THEN
        -- When order items change, update the order
        v_order_id := COALESCE(NEW.order_id, OLD.order_id);

        SELECT jsonb_build_object(
            '__typename', 'Order',
            'id', o.id,
            'status', o.status,
            'total', o.total,
            'createdAt', o.created_at,
            'user', (
                SELECT jsonb_build_object(
                    'id', u.id,
                    'email', u.email,
                    'name', u.name
                )
                FROM tb_user u
                WHERE u.id = o.user_id
            ),
            'items', COALESCE(
                (
                    SELECT jsonb_agg(jsonb_build_object(
                        'id', i.id,
                        'productName', i.product_name,
                        'quantity', i.quantity,
                        'price', i.price
                    ) ORDER BY i.created_at)
                    FROM tb_order_item i
                    WHERE i.order_id = o.id
                ),
                '[]'::jsonb
            )
        ) INTO v_updated_data
        FROM tb_order o
        WHERE o.id = v_order_id;

        UPDATE tv_order SET data = v_updated_data, updated_at = NOW()
        WHERE id = v_order_id;
    END IF;

    RETURN NULL;
END;
$$ LANGUAGE plpgsql;

-- Explicit sync: Call after user/order updates
SELECT refresh_tv_order(p_order_id => v_order_id);

-- Or sync multiple orders at once
SELECT refresh_tv_order_batch(p_order_ids => ARRAY[v_order_id1, v_order_id2]);

Benefits of Explicit Synchronization: - ✅ Predictable performance: No unexpected trigger overhead - ✅ Transactional control: Sync happens when you want it - ✅ Debugging friendly: Easy to trace sync operations - ✅ Resource management: Control when expensive operations run

GraphQL Query Pattern

GraphQL Query:

query GetOrders($status: String) {
  orders(
    filters: {status: $status}
    orderBy: {field: "createdAt", direction: DESC}
    limit: 50
  ) {
    id
    status
    total
    user {
      email
      name
    }
    items {
      productName
      quantity
      price
    }
  }
}

Generated SQL (single query, no N+1):

SELECT data
FROM tv_order
WHERE tenant_id = $1
  AND status = $2
ORDER BY created_at DESC
LIMIT 50;

Performance: - 50 orders with nested users + items: Single query, 2-5ms - Traditional approach (N+1): 1 + 50 + (50 × avg_items) queries, 100-500ms - Speedup: 20-100x faster

Design Rules for tv_ Tables

1. Real Columns for Filtering

Include as real columns (not just in JSONB): - Primary key (id) - Tenant isolation (tenant_id) - Common filters (status, user_id, created_at) - Sort keys (created_at, updated_at, priority)

Why: PostgreSQL can't efficiently index inside JSONB for complex queries.

-- ✅ GOOD: Real column with index
CREATE TABLE tv_order (
    id UUID PRIMARY KEY,       -- Required for GraphQL
    status TEXT,
    created_at TIMESTAMPTZ,
    data JSONB
);
CREATE INDEX idx_status_created ON tv_order (status, created_at DESC);

-- Query: Fast (uses index)
SELECT data FROM tv_order
WHERE status = 'shipped'
ORDER BY created_at DESC;

-- ❌ BAD: Status only in JSONB
CREATE TABLE tv_order_bad (
    data JSONB
);

-- Query: Slow (sequential scan)
SELECT data FROM tv_order_bad
WHERE data->>'status' = 'shipped'
ORDER BY (data->>'createdAt')::timestamptz DESC;

2. JSONB data Column Structure

Requirements: - Complete GraphQL response (all nested data) - Include __typename for GraphQL unions/interfaces - Use camelCase field names (GraphQL convention) - Pre-compute expensive aggregations

Example Structure:

{
  "__typename": "Order",          // ✅ Required for GraphQL
  "id": "...",                     // ✅ Always include
  "status": "shipped",             // ✅ Duplicate of real column (for consistency)
  "createdAt": "2025-10-09...",    // ✅ ISO 8601 format
  "user": { ... },                 // ✅ Complete nested object
  "items": [ ... ],                // ✅ Complete nested array
  "itemCount": 3,                  // ✅ Pre-computed aggregation
  "totalAmount": 299.99            // ✅ Pre-computed sum
}

3. Indexing Strategy

Standard Indexes (every tv_ table):

-- Tenant + primary sort key (most common query)
CREATE INDEX idx_tv_entity_tenant_created
    ON tv_entity (tenant_id, created_at DESC);

-- Status-based filtering
CREATE INDEX idx_tv_entity_status
    ON tv_entity (status, tenant_id);

-- Optional: Partial indexes for hot paths
CREATE INDEX idx_tv_entity_active
    ON tv_entity (tenant_id, created_at DESC)
    WHERE status IN ('pending', 'active', 'processing');

Advanced: GIN index for JSONB queries (use sparingly):

-- Only if you query JSONB fields directly
CREATE INDEX idx_tv_entity_data_gin
    ON tv_entity USING GIN (data jsonb_path_ops);

-- Allows queries like:
SELECT * FROM tv_entity
WHERE data @> '{"user": {"role": "admin"}}';

4. Naming Conventions

Pattern Example Purpose
tb_* tb_order Write tables (normalized, OLTP)
tv_* tv_order Read tables (denormalized, OLAP)
v_* v_order_summary Actual VIEWs (computed on read)
mv_* mv_daily_stats Materialized VIEWs (scheduled refresh)

Performance Characteristics

tv_ Table Query Performance:

-- Filtering on indexed real columns: 0.5-2ms
SELECT data FROM tv_order
WHERE tenant_id = $1
  AND status = 'shipped'
  AND created_at > NOW() - INTERVAL '7 days'
ORDER BY created_at DESC
LIMIT 50;

-- vs. Traditional JOIN approach: 50-200ms
SELECT o.*, u.email, array_agg(i.*)
FROM tb_order o
JOIN tb_user u ON u.id = o.user_id
LEFT JOIN tb_order_item i ON i.order_id = o.id
WHERE o.tenant_id = $1 AND o.status = 'shipped'
GROUP BY o.id, u.email;

Trade-offs:

Aspect Benefit Cost
Read speed 10-100x faster N/A
Write complexity N/A Trigger overhead (2-10ms per write)
Storage Duplicate data (2-3x) Disk space
Consistency Eventual (trigger-based) Not real-time

Recommendation: Use tv_ tables for all GraphQL queries. The read performance gain (10-100x) far outweighs the storage cost.

Mutation Structure Pattern

Overview

FraiseQL mutations follow a consistent 5-step pattern that ensures data integrity, audit trails, and synchronized tv_ tables.

Standard Mutation Flow: 1. Validation - Check business rules not enforced by types 2. Existence Check - Verify required records exist 3. Business Logic - Perform the mutation on tb_ tables 4. Refresh tv_ - Rebuild denormalized projections 5. Return Result - Structured response with change tracking

Complete Example: Update Order

SQL Function Structure:

CREATE OR REPLACE FUNCTION update_order(
    p_tenant_id UUID,
    p_user_id UUID,
    p_order_id UUID,
    p_status TEXT,
    p_notes TEXT DEFAULT NULL
)
RETURNS TABLE(
    id UUID,
    status TEXT,
    updated_fields TEXT[],
    message TEXT,
    object_data JSONB,
    extra_metadata JSONB
) AS $$
DECLARE
    v_old_order RECORD;
    v_updated_fields TEXT[] := '{}';
    v_change_status TEXT;
BEGIN
    -- =====================================================================
    -- STEP 1: VALIDATION
    -- =====================================================================

    -- Validate status transition
    IF p_status NOT IN ('pending', 'confirmed', 'shipped', 'delivered', 'cancelled') THEN
        RAISE EXCEPTION 'Invalid status: %. Must be one of: pending, confirmed, shipped, delivered, cancelled', p_status;
    END IF;

    -- Additional business rules
    IF p_status = 'shipped' AND p_notes IS NULL THEN
        RAISE EXCEPTION 'Tracking notes required when shipping order';
    END IF;

    -- =====================================================================
    -- STEP 2: EXISTENCE CHECK
    -- =====================================================================

    -- Check if order exists and belongs to tenant
    SELECT * INTO v_old_order
    FROM tb_order
    WHERE id = p_order_id
      AND tenant_id = p_tenant_id;

    IF NOT FOUND THEN
        RAISE EXCEPTION 'Order % not found for tenant %', p_order_id, p_tenant_id;
    END IF;

    -- Validate state transitions
    IF v_old_order.status = 'cancelled' THEN
        RAISE EXCEPTION 'Cannot modify cancelled order';
    END IF;

    -- =====================================================================
    -- STEP 3: BUSINESS LOGIC (Mutation on tb_ tables)
    -- =====================================================================

    -- Track which fields changed
    IF v_old_order.status != p_status THEN
        v_updated_fields := array_append(v_updated_fields, 'status');
    END IF;

    IF COALESCE(v_old_order.notes, '') != COALESCE(p_notes, '') THEN
        v_updated_fields := array_append(v_updated_fields, 'notes');
    END IF;

    -- Determine change status
    IF array_length(v_updated_fields, 1) = 0 THEN
        v_change_status := 'noop:no_changes';
    ELSE
        v_change_status := 'updated';
    END IF;

    -- Perform the update
    UPDATE tb_order
    SET
        status = p_status,
        notes = p_notes,
        updated_at = NOW(),
        updated_by = p_user_id
    WHERE id = p_order_id;

    -- =====================================================================
    -- STEP 4: REFRESH tv_ TABLE
    -- =====================================================================

    -- Explicitly refresh the denormalized projection
    PERFORM refresh_tv_order(p_order_id);

    -- =====================================================================
    -- STEP 5: RETURN RESULT (with audit logging)
    -- =====================================================================

    -- Log to entity_change_log
    INSERT INTO core.tb_entity_change_log
        (tenant_id, user_id, object_type, object_id,
         modification_type, change_status, object_data, extra_metadata)
    VALUES
        (p_tenant_id, p_user_id, 'order', p_order_id,
         'UPDATE', v_change_status,
         jsonb_build_object(
             'before', row_to_json(v_old_order),
             'after', (SELECT row_to_json(tb_order) FROM tb_order WHERE id = p_order_id),
             'op', 'u'
         ),
         jsonb_build_object(
             'updated_fields', v_updated_fields,
             'input_params', jsonb_build_object(
                 'status', p_status,
                 'notes', p_notes
             )
         ));

    -- Return structured result
    RETURN QUERY
    SELECT
        p_order_id as id,
        v_change_status as status,
        v_updated_fields as updated_fields,
        format('Order updated: %s', array_to_string(v_updated_fields, ', ')) as message,
        (SELECT data FROM tv_order WHERE id = p_order_id) as object_data,
        jsonb_build_object('updated_fields', v_updated_fields) as extra_metadata;

END;
$$ LANGUAGE plpgsql;

GraphQL Resolver Integration

Python Resolver:

from fraiseql.types import ID
import fraiseql
from fraiseql.db import execute_mutation

@fraiseql.mutation
async def update_order(
    info,
    id: ID,
    status: str,
    notes: str | None = None
) -> MutationLogResult:
    """Update order status."""
    db = info.context["db"]
    tenant_id = info.context["tenant_id"]
    user_id = info.context["user_id"]

    # Call SQL function (5-step pattern executed)
    result = await db.execute_mutation(
        """
        SELECT * FROM update_order(
            p_tenant_id := $1,
            p_user_id := $2,
            p_order_id := $3,
            p_status := $4,
            p_notes := $5
        )
        """,
        tenant_id,
        user_id,
        id,
        status,
        notes
    )

    return MutationLogResult(
        status=result["status"],
        message=result["message"],
        op="update",
        entity="order",
        payload_before=result["object_data"].get("before"),
        payload_after=result["object_data"].get("after"),
        extra_metadata=result["extra_metadata"]
    )

Create Pattern

Create follows same 5-step pattern:

CREATE OR REPLACE FUNCTION create_order(
    p_tenant_id UUID,
    p_user_id UUID,
    p_customer_id UUID,
    p_items JSONB  -- Array of {product_id, quantity, price}
)
RETURNS TABLE(
    id UUID,
    status TEXT,
    message TEXT,
    object_data JSONB
) AS $$
DECLARE
    v_order_id UUID;
    v_item JSONB;
BEGIN
    -- STEP 1: VALIDATION
    IF jsonb_array_length(p_items) = 0 THEN
        RAISE EXCEPTION 'Order must contain at least one item';
    END IF;

    -- Validate all products exist
    FOR v_item IN SELECT * FROM jsonb_array_elements(p_items)
    LOOP
        IF NOT EXISTS (SELECT 1 FROM tb_product WHERE id = (v_item->>'product_id')::UUID) THEN
            RAISE EXCEPTION 'Product % not found', v_item->>'product_id';
        END IF;
    END LOOP;

    -- STEP 2: EXISTENCE CHECK
    IF NOT EXISTS (SELECT 1 FROM tb_user WHERE id = p_customer_id AND tenant_id = p_tenant_id) THEN
        RAISE EXCEPTION 'Customer % not found', p_customer_id;
    END IF;

    -- STEP 3: BUSINESS LOGIC
    v_order_id := gen_random_uuid();

    -- Insert into tb_order
    INSERT INTO tb_order (id, tenant_id, user_id, status, created_by)
    VALUES (v_order_id, p_tenant_id, p_customer_id, 'pending', p_user_id);

    -- Insert items
    FOR v_item IN SELECT * FROM jsonb_array_elements(p_items)
    LOOP
        INSERT INTO tb_order_item (id, order_id, product_id, quantity, price)
        VALUES (
            gen_random_uuid(),
            v_order_id,
            (v_item->>'product_id')::UUID,
            (v_item->>'quantity')::INT,
            (v_item->>'price')::DECIMAL
        );
    END LOOP;

    -- Update total
    UPDATE tb_order
    SET total = (
        SELECT SUM(quantity * price)
        FROM tb_order_item
        WHERE order_id = v_order_id
    )
    WHERE id = v_order_id;

    -- STEP 4: REFRESH tv_
    PERFORM refresh_tv_order(v_order_id);

    -- STEP 5: RETURN RESULT
    INSERT INTO core.tb_entity_change_log
        (tenant_id, user_id, object_type, object_id,
         modification_type, change_status, object_data)
    VALUES
        (p_tenant_id, p_user_id, 'order', v_order_id,
         'INSERT', 'new',
         jsonb_build_object(
             'after', (SELECT row_to_json(tb_order) FROM tb_order WHERE id = v_order_id),
             'op', 'c'
         ));

    RETURN QUERY
    SELECT
        v_order_id as id,
        'new'::TEXT as status,
        'Order created successfully' as message,
        (SELECT data FROM tv_order WHERE id = v_order_id) as object_data;

END;
$$ LANGUAGE plpgsql;

Delete Pattern

Delete with soft-delete support:

CREATE OR REPLACE FUNCTION delete_order(
    p_tenant_id UUID,
    p_user_id UUID,
    p_order_id UUID
)
RETURNS TABLE(
    id UUID,
    status TEXT,
    message TEXT
) AS $$
DECLARE
    v_old_order RECORD;
BEGIN
    -- STEP 1: VALIDATION
    -- (No specific validation for delete)

    -- STEP 2: EXISTENCE CHECK
    SELECT * INTO v_old_order
    FROM tb_order
    WHERE id = p_order_id
      AND tenant_id = p_tenant_id;

    IF NOT FOUND THEN
        RAISE EXCEPTION 'Order % not found', p_order_id;
    END IF;

    -- Check if already deleted
    IF v_old_order.deleted_at IS NOT NULL THEN
        RETURN QUERY
        SELECT
            p_order_id as id,
            'noop:already_deleted'::TEXT as status,
            'Order already deleted' as message;
        RETURN;
    END IF;

    -- STEP 3: BUSINESS LOGIC (soft delete)
    UPDATE tb_order
    SET
        deleted_at = NOW(),
        deleted_by = p_user_id
    WHERE id = p_order_id;

    -- STEP 4: REFRESH tv_ (or remove from tv_)
    DELETE FROM tv_order WHERE id = p_order_id;

    -- STEP 5: RETURN RESULT
    INSERT INTO core.tb_entity_change_log
        (tenant_id, user_id, object_type, object_id,
         modification_type, change_status, object_data)
    VALUES
        (p_tenant_id, p_user_id, 'order', p_order_id,
         'DELETE', 'deleted',
         jsonb_build_object(
             'before', row_to_json(v_old_order),
             'op', 'd'
         ));

    RETURN QUERY
    SELECT
        p_order_id as id,
        'deleted'::TEXT as status,
        'Order deleted successfully' as message;

END;
$$ LANGUAGE plpgsql;

Batch Refresh Pattern

When mutations affect multiple tv_ rows:

-- Refresh function accepting multiple IDs
CREATE OR REPLACE FUNCTION refresh_tv_order_batch(p_order_ids UUID[])
RETURNS void AS $$
BEGIN
    INSERT INTO tv_order (id, tenant_id, status, user_id, total, created_at, data)
    SELECT
        o.id,
        o.tenant_id,
        o.status,
        o.user_id,
        o.total,
        o.created_at,
        jsonb_build_object(
            '__typename', 'Order',
            'id', o.id,
            -- ... complete JSONB construction
        ) as data
    FROM tb_order o
    WHERE o.id = ANY(p_order_ids)
    ON CONFLICT (id) DO UPDATE SET
        status = EXCLUDED.status,
        data = EXCLUDED.data,
        updated_at = NOW();
END;
$$ LANGUAGE plpgsql;

-- Use in mutations affecting multiple orders
CREATE OR REPLACE FUNCTION bulk_ship_orders(
    p_tenant_id UUID,
    p_order_ids UUID[]
)
RETURNS TABLE(processed_count INT) AS $$
BEGIN
    -- STEP 3: Update all orders
    UPDATE tb_order
    SET status = 'shipped', updated_at = NOW()
    WHERE id = ANY(p_order_ids)
      AND tenant_id = p_tenant_id
      AND status = 'confirmed';

    -- STEP 4: Batch refresh
    PERFORM refresh_tv_order_batch(p_order_ids);

    -- STEP 5: Return count
    RETURN QUERY SELECT array_length(p_order_ids, 1) as processed_count;
END;
$$ LANGUAGE plpgsql;

Best Practices

Validation: - Validate business rules not enforced by database constraints - Check state transitions (e.g., can't ship a cancelled order) - Validate related entity existence - Return clear error messages

Existence Checks: - Always verify record exists before mutation - Check tenant ownership (multi-tenancy security) - Detect NOOP cases early (no changes to apply)

Business Logic: - Track changed fields for audit trail - Use atomic operations (single transaction) - Handle cascading updates (e.g., recalculate totals)

tv_ Refresh: - Always call refresh after tb_ mutations - Use batch refresh for bulk operations - Consider: DELETE from tv_ for soft-deleted records

Return Results: - Always log to entity_change_log - Return structured mutation result - Include before/after snapshots - Track no-op operations (important for debugging)

Error Handling

Structured Exceptions:

-- Custom exception types
CREATE OR REPLACE FUNCTION update_order(...)
RETURNS TABLE(...) AS $$
BEGIN
    -- Validation errors
    IF p_status NOT IN (...) THEN
        RAISE EXCEPTION 'validation:invalid_status'
            USING DETAIL = format('Invalid status: %s', p_status);
    END IF;

    -- Not found errors
    IF NOT FOUND THEN
        RAISE EXCEPTION 'not_found:order'
            USING DETAIL = format('Order %s not found', p_order_id);
    END IF;

    -- Business rule violations
    IF v_old_order.status = 'shipped' THEN
        RAISE EXCEPTION 'conflict:already_shipped'
            USING DETAIL = 'Cannot modify shipped orders';
    END IF;

EXCEPTION
    WHEN OTHERS THEN
        -- Log error
        INSERT INTO core.tb_entity_change_log
            (tenant_id, object_type, object_id,
             modification_type, change_status, object_data)
        VALUES
            (p_tenant_id, 'order', p_order_id,
             'UPDATE', format('failed:%s', SQLERRM),
             jsonb_build_object('error', SQLERRM));
        RAISE;
END;
$$ LANGUAGE plpgsql;

Benefits of 5-Step Pattern: - ✅ Consistent mutation structure across codebase - ✅ Automatic audit trail for compliance - ✅ tv_ tables always synchronized - ✅ Clear error messages with context - ✅ Explicit validation and existence checks - ✅ No silent failures (NOOP operations tracked)

JSONB Composition for N+1 Prevention

Problem: Nested GraphQL queries result in N+1 database queries.

Traditional Approach (N+1 problem):

query {
  users {
    id
    name
    posts {  # Triggers 1 query per user
      id
      title
    }
  }
}

Solution: JSONB aggregation in database views.

View Design:

CREATE VIEW v_users_with_posts AS
SELECT
  u.id,
  u.email,
  u.name,
  u.created_at,
  jsonb_build_object(
    'id', u.id,
    'email', u.email,
    'name', u.name,
    'createdAt', u.created_at,
    'posts', (
      SELECT jsonb_agg(jsonb_build_object(
        'id', p.id,
        'title', p.title,
        'createdAt', p.created_at
      ) ORDER BY p.created_at DESC)
      FROM posts p
      WHERE p.user_id = u.id
    )
  ) as data
FROM users u;

GraphQL Query (single SQL query):

query {
  users {
    id
    name
    posts {
      id
      title
    }
  }
}

Performance: Single database query regardless of nesting depth. No DataLoader setup required.

View Composition Patterns

Basic View

Simple entity view with JSONB output:

CREATE VIEW v_product AS
SELECT
  p.id,
  p.sku,
  p.name,
  p.price,
  jsonb_build_object(
    '__typename', 'Product',
    'id', p.id,
    'sku', p.sku,
    'name', p.name,
    'price', p.price,
    'categoryId', p.category_id
  ) as data
FROM products p
WHERE p.deleted_at IS NULL;

Nested Aggregations

Multi-level nested data in single view:

CREATE VIEW tv_order_complete AS
SELECT
  o.id,
  o.customer_id,
  o.status,
  jsonb_build_object(
    '__typename', 'Order',
    'id', o.id,
    'status', o.status,
    'total', o.total,
    'customer', (
      SELECT jsonb_build_object(
        'id', c.id,
        'name', c.name,
        'email', c.email
      )
      FROM tb_customer c
      WHERE c.id = o.customer_id
    ),
    'items', (
      SELECT jsonb_agg(jsonb_build_object(
        'id', i.id,
        'productName', i.product_name,
        'quantity', i.quantity,
        'price', i.price
      ) ORDER BY i.created_at)
      FROM tb_order_item i
      WHERE i.order_id = o.id
    ),
    'shipping', (
      SELECT jsonb_build_object(
        'address', s.address,
        'city', s.city,
        'status', s.status,
        'trackingNumber', s.tracking_number
      )
      FROM tb_shipment s
      WHERE s.order_id = o.id
      LIMIT 1
    )
  ) as data
FROM tb_order o;

Conditional Aggregations

Include data based on WHERE clauses in subqueries:

CREATE VIEW v_post_with_approved_comments AS
SELECT
  p.id,
  p.title,
  jsonb_build_object(
    '__typename', 'Post',
    'id', p.id,
    'title', p.title,
    'content', p.content,
    'approvedComments', (
      SELECT jsonb_agg(jsonb_build_object(
        'id', c.id,
        'text', c.text,
        'author', c.author_name
      ) ORDER BY c.created_at DESC)
      FROM comments c
      WHERE c.post_id = p.id
        AND c.status = 'approved'  -- Conditional filter
    ),
    'pendingCommentCount', (
      SELECT COUNT(*)
      FROM comments c
      WHERE c.post_id = p.id
        AND c.status = 'pending'
    )
  ) as data
FROM posts p;

Materialized Views

Purpose: Pre-compute expensive aggregations.

Creation:

CREATE MATERIALIZED VIEW mv_user_stats AS
SELECT
  u.id,
  u.name,
  COUNT(DISTINCT p.id) as post_count,
  COUNT(DISTINCT c.id) as comment_count,
  MAX(p.created_at) as last_post_at,
  SUM(p.view_count) as total_views
FROM users u
LEFT JOIN posts p ON p.author_id = u.id
LEFT JOIN comments c ON c.user_id = u.id
GROUP BY u.id, u.name;

CREATE UNIQUE INDEX ON mv_user_stats (id);

Refresh Strategy:

-- Manual refresh
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_user_stats;

-- Scheduled refresh (using pg_cron)
SELECT cron.schedule(
  'refresh-stats',
  '0 * * * *',  -- Every hour
  'REFRESH MATERIALIZED VIEW CONCURRENTLY mv_user_stats'
);

Trade-offs:

Approach Freshness Query Speed Complexity
Regular View Real-time Slower Low
Materialized View Scheduled Fast Medium
Incremental Update Near real-time Fast High

Table-View Sync Pattern

Purpose: Maintain separate write tables and read views.

Pattern:

-- Write-optimized table (normalized)
CREATE TABLE tb_order (
  id UUID PRIMARY KEY,
  tenant_id UUID NOT NULL,
  user_id UUID NOT NULL,
  status VARCHAR(50),
  total DECIMAL(10,2),
  created_at TIMESTAMP DEFAULT NOW()
);

-- Read-optimized view (denormalized)
CREATE VIEW tv_order AS
SELECT
  o.id,
  o.tenant_id,
  o.status,
  o.total,
  jsonb_build_object(
    'id', o.id,
    'status', o.status,
    'total', o.total,
    'user', jsonb_build_object(
      'id', u.id,
      'email', u.email,
      'name', u.name
    ),
    'items', (
      SELECT jsonb_agg(jsonb_build_object(
        'id', i.id,
        'name', i.name,
        'quantity', i.quantity,
        'price', i.price
      ))
      FROM tb_order_item i
      WHERE i.order_id = o.id
    )
  ) as data
FROM tb_order o
JOIN tb_user u ON u.id = o.user_id;

Benefits:

  • Write operations use normalized tables (data integrity)
  • Read operations use denormalized views (performance)
  • Schema changes don't break API (view acts as abstraction)

Multi-Tenancy Patterns

Row-Level Security

Tenant isolation at the database level:

-- Multi-tenant table with RLS
CREATE TABLE projects (
  id UUID PRIMARY KEY,
  tenant_id UUID NOT NULL,
  name VARCHAR(200) NOT NULL,
  description TEXT,
  created_at TIMESTAMP DEFAULT NOW()
);

-- Enable Row Level Security
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;

-- Create policy for tenant isolation
CREATE POLICY tenant_isolation ON projects
  FOR ALL
  USING (tenant_id = current_setting('app.current_tenant_id')::UUID);

-- Tenant-aware view
CREATE VIEW v_projects AS
SELECT
  p.id,
  p.name,
  jsonb_build_object(
    '__typename', 'Project',
    'id', p.id,
    'name', p.name,
    'description', p.description,
    'createdAt', p.created_at
  ) as data
FROM projects p;

-- Set tenant context before queries
SELECT set_config('app.current_tenant_id', '123e4567-...', true);

View-Level Tenant Filtering

Filter tenants in view definition:

CREATE VIEW tv_tenant_orders AS
SELECT
  o.id,
  jsonb_build_object(
    '__typename', 'Order',
    'id', o.id,
    'status', o.status,
    'total', o.total
  ) as data
FROM tb_order o
WHERE o.tenant_id = current_setting('app.tenant_id')::UUID;

Application-Level Filtering

Use QueryOptions for tenant filtering:

import fraiseql

@fraiseql.query
async def get_orders(info, status: str | None = None) -> list[Order]:
    db = info.context["db"]
    tenant_id = info.context["tenant_id"]

    where = {"tenant_id": tenant_id}
    if status:
        where["status"] = status

    return await db.find("v_orders", where=where)

Indexing Strategy

JSONB Indexes

-- GIN index for JSONB containment queries
CREATE INDEX idx_orders_json_data ON orders USING GIN (data);

-- Expression index for specific JSONB fields
CREATE INDEX idx_orders_status ON orders ((data->>'status'));

-- Functional index for nested JSONB
CREATE INDEX idx_orders_user_email ON orders ((data->'user'->>'email'));

Multi-Column Indexes

-- Tenant + timestamp for common queries
CREATE INDEX idx_orders_tenant_created
ON orders (tenant_id, created_at DESC);

-- Status + tenant for filtered queries
CREATE INDEX idx_orders_status_tenant
ON orders (status, tenant_id)
WHERE status != 'cancelled';

Partial Indexes

-- Index only active records
CREATE INDEX idx_orders_active
ON orders (tenant_id, created_at DESC)
WHERE status IN ('pending', 'processing', 'shipped');

-- Index only recent records
CREATE INDEX idx_orders_recent
ON orders (tenant_id, status)
WHERE created_at > NOW() - INTERVAL '30 days';

Query Optimization

Analyze Query Plans

EXPLAIN (ANALYZE, BUFFERS)
SELECT data FROM v_orders WHERE tenant_id = '123e4567-...';

-- Look for:
-- - Sequential scans (bad) vs Index scans (good)
-- - High buffer usage
-- - Nested loop joins vs hash joins

Common Optimization Patterns

Use LATERAL joins for correlated subqueries:

CREATE VIEW v_users_with_latest_post AS
SELECT
  u.id,
  jsonb_build_object(
    'id', u.id,
    'name', u.name,
    'latestPost', p.data
  ) as data
FROM users u
LEFT JOIN LATERAL (
  SELECT jsonb_build_object(
    'id', p.id,
    'title', p.title
  ) as data
  FROM posts p
  WHERE p.author_id = u.id
  ORDER BY p.created_at DESC
  LIMIT 1
) p ON true;

Use COALESCE for null handling:

SELECT
  jsonb_build_object(
    'items', COALESCE(
      (SELECT jsonb_agg(...) FROM items),
      '[]'::jsonb  -- Default to empty array
    )
  ) as data
FROM tb_order;

Use DISTINCT ON for latest records:

CREATE VIEW tv_latest_order_per_user AS
SELECT DISTINCT ON (user_id)
  user_id,
  jsonb_build_object(
    'orderId', id,
    'total', total,
    'createdAt', created_at
  ) as data
FROM tb_order
ORDER BY user_id, created_at DESC;

Hierarchical Data Patterns

Recursive CTE for Tree Structures

-- Category hierarchy
CREATE TABLE tb_category (
  id UUID PRIMARY KEY,
  parent_id UUID REFERENCES tb_category(id),
  name VARCHAR(100) NOT NULL,
  slug VARCHAR(100) NOT NULL
);

-- Recursive view for full tree
CREATE VIEW v_category_tree AS
WITH RECURSIVE category_tree AS (
  -- Root categories
  SELECT
    id,
    parent_id,
    name,
    slug,
    0 AS depth,
    ARRAY[id] AS path,
    ARRAY[name] AS breadcrumb
  FROM tb_category
  WHERE parent_id IS NULL

  UNION ALL

  -- Child categories
  SELECT
    c.id,
    c.parent_id,
    c.name,
    c.slug,
    ct.depth + 1,
    ct.path || c.id,
    ct.breadcrumb || c.name
  FROM tb_category c
  JOIN category_tree ct ON c.parent_id = ct.id
  WHERE ct.depth < 10  -- Prevent infinite recursion
)
SELECT
  id,
  jsonb_build_object(
    '__typename', 'Category',
    'id', id,
    'name', name,
    'slug', slug,
    'depth', depth,
    'path', path,
    'breadcrumb', breadcrumb,
    'children', (
      SELECT jsonb_agg(jsonb_build_object(
        'id', c.id,
        'name', c.name,
        'slug', c.slug
      ) ORDER BY c.name)
      FROM tb_category c
      WHERE c.parent_id = category_tree.id
    )
  ) as data
FROM category_tree
ORDER BY path;

Materialized Path Pattern

Using ltree extension for efficient tree queries:

-- Using ltree extension
CREATE EXTENSION IF NOT EXISTS ltree;

CREATE TABLE tv_category_tree (
  id UUID PRIMARY KEY,
  name VARCHAR(100) NOT NULL,
  path ltree NOT NULL,
  UNIQUE(path)
);

-- Index for path operations
CREATE INDEX idx_category_path ON tv_category_tree USING gist(path);

-- Insert with path
INSERT INTO tv_category_tree (name, path) VALUES
  ('Electronics', 'electronics'),
  ('Computers', 'electronics.computers'),
  ('Laptops', 'electronics.computers.laptops'),
  ('Gaming Laptops', 'electronics.computers.laptops.gaming');

-- Find all descendants
SELECT
  c.id,
  c.name,
  c.path,
  jsonb_build_object(
    'id', c.id,
    'name', c.name,
    'path', c.path::text,
    'depth', nlevel(c.path)
  ) as data
FROM tv_category_tree c
WHERE c.path <@ 'electronics.computers'::ltree;  -- All under computers

Polymorphic Associations

Single Table Inheritance Pattern

Store different entity types in one table:

-- Polymorphic notifications
CREATE TABLE notifications (
  id UUID PRIMARY KEY,
  user_id UUID NOT NULL,
  type VARCHAR(50) NOT NULL,
  -- Polymorphic reference
  entity_type VARCHAR(50),
  entity_id UUID,
  -- Type-specific data
  data JSONB NOT NULL,
  read_at TIMESTAMP,
  created_at TIMESTAMP DEFAULT NOW()
);

CREATE INDEX idx_user_notifications
ON notifications(user_id, read_at, created_at DESC);

-- Type-specific view with entity resolution
CREATE VIEW v_notifications AS
SELECT
  n.id,
  n.user_id,
  n.read_at,
  jsonb_build_object(
    '__typename', 'Notification',
    'id', n.id,
    'type', n.type,
    'read', n.read_at IS NOT NULL,
    'createdAt', n.created_at,
    -- Polymorphic entity resolution
    'entity', CASE n.entity_type
      WHEN 'Post' THEN (
        SELECT jsonb_build_object(
          '__typename', 'Post',
          'id', p.id,
          'title', p.title
        )
        FROM posts p
        WHERE p.id = n.entity_id
      )
      WHEN 'Comment' THEN (
        SELECT jsonb_build_object(
          '__typename', 'Comment',
          'id', c.id,
          'content', LEFT(c.content, 100)
        )
        FROM comments c
        WHERE c.id = n.entity_id
      )
      ELSE NULL
    END,
    'message', n.data->>'message'
  ) as data
FROM notifications n
ORDER BY n.created_at DESC;

Table Per Type with Union Pattern

Separate tables unified through views:

-- Different activity types
CREATE TABLE page_views (
  id UUID PRIMARY KEY,
  user_id UUID,
  page_url TEXT NOT NULL,
  referrer TEXT,
  duration_seconds INT,
  created_at TIMESTAMP DEFAULT NOW()
);

CREATE TABLE button_clicks (
  id UUID PRIMARY KEY,
  user_id UUID,
  button_id VARCHAR(100) NOT NULL,
  page_url TEXT NOT NULL,
  created_at TIMESTAMP DEFAULT NOW()
);

CREATE TABLE form_submissions (
  id UUID PRIMARY KEY,
  user_id UUID,
  form_id VARCHAR(100) NOT NULL,
  form_data JSONB NOT NULL,
  created_at TIMESTAMP DEFAULT NOW()
);

-- Unified activity view
CREATE VIEW v_user_activities AS
SELECT
  id,
  user_id,
  activity_type,
  created_at,
  jsonb_build_object(
    '__typename', 'UserActivity',
    'id', id,
    'type', activity_type,
    'details', details,
    'createdAt', created_at
  ) as data
FROM (
  SELECT
    id,
    user_id,
    'page_view' AS activity_type,
    jsonb_build_object(
      'pageUrl', page_url,
      'referrer', referrer,
      'duration', duration_seconds
    ) AS details,
    created_at
  FROM page_views

  UNION ALL

  SELECT
    id,
    user_id,
    'button_click' AS activity_type,
    jsonb_build_object(
      'buttonId', button_id,
      'pageUrl', page_url
    ) AS details,
    created_at
  FROM button_clicks

  UNION ALL

  SELECT
    id,
    user_id,
    'form_submission' AS activity_type,
    jsonb_build_object(
      'formId', form_id,
      'fields', form_data
    ) AS details,
    created_at
  FROM form_submissions
) activities
ORDER BY created_at DESC;

Production Patterns from Real Systems

Entity Change Log (Audit Trail)

Purpose: Centralized audit log for tracking all object-level changes across the system.

When to Use Entity Change Log

✅ Always Use For: - Financial transactions - Regulatory compliance (SOX, PCI-DSS) - User account changes - Security audit trail (login, permissions, roles) - Permission/role modifications - Access control audit - Critical business data - Orders, contracts, invoices, payments

✅ Consider Using For: - Content management - Track edits, revisions, who changed what - Configuration changes - System settings, feature flags - Multi-user collaboration - Attribution and conflict resolution - Compliance requirements - GDPR, HIPAA, industry-specific regulations

❌ Consider Skipping For: - High-frequency analytics events - Use dedicated analytics table instead - Temporary/ephemeral data - Session state, cache, temporary calculations - Read-only operations - Queries don't need audit trail - Performance-critical hot paths - Consider async audit logging

Rule of Thumb: If you'd need to answer "Who changed X and when?" during an investigation, use change_log.

Table Structure

CREATE TABLE core.tb_entity_change_log (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    pk_entity_change_log UUID NOT NULL DEFAULT gen_random_uuid(),

    tenant_id UUID NOT NULL,
    user_id UUID,  -- User who triggered the change

    object_type TEXT NOT NULL,  -- e.g., 'allocation', 'machine', 'location'
    object_id UUID NOT NULL,

    modification_type TEXT NOT NULL CHECK (
        modification_type IN ('INSERT', 'UPDATE', 'DELETE', 'NOOP')
    ),

    change_status TEXT NOT NULL CHECK (
        change_status ~ '^(new|existing|updated|deleted|synced|completed|ok|done|success|failed:[a-z_]+|noop:[a-z_]+|conflict:[a-z_]+|duplicate:[a-z_]+|validation:[a-z_]+|not_found|forbidden|unauthorized|blocked:[a-z_]+)$'
    ),

    object_data JSONB NOT NULL,      -- Before/after snapshots
    extra_metadata JSONB DEFAULT '{}'::jsonb,

    created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_entity_log_object ON core.tb_entity_change_log (object_type, object_id);
CREATE INDEX idx_entity_log_tenant ON core.tb_entity_change_log (tenant_id, created_at);
CREATE INDEX idx_entity_log_status ON core.tb_entity_change_log (change_status);

Debezium-Style Object Data Format:

{
  "before": {
    "id": "123e4567-...",
    "name": "Old Name",
    "status": "pending"
  },
  "after": {
    "id": "123e4567-...",
    "name": "New Name",
    "status": "active"
  },
  "op": "u",
  "source": {
    "connector": "postgresql",
    "table": "tb_orders"
  }
}

Usage in Mutations:

import fraiseql
from fraiseql.types import ID

@fraiseql.mutation
async def update_order(info, id: ID, name: str) -> MutationResult:
    db = info.context["db"]

    # Log the mutation
    result = await db.execute(
        """
        INSERT INTO core.tb_entity_change_log
            (tenant_id, user_id, object_type, object_id,
             modification_type, change_status, object_data)
        VALUES
            ($1, $2, 'order', $3, 'UPDATE', 'updated', $4::jsonb)
        RETURNING id
        """,
        info.context["tenant_id"],
        info.context["user_id"],
        id,
        json.dumps({
            "before": {"name": old_name},
            "after": {"name": name}
        })
    )

    return MutationResult(status="updated", id=id)

Benefits: - Complete audit trail for compliance - Debugging production issues (see what changed when) - Rollback support (reconstruct previous state) - Analytics on mutation patterns

Performance & Retention Considerations

Write Cost: Each mutation performs 2 writes (data table + change_log) - Latency Impact: ~10-20% increase per mutation - Throughput Impact: ~15-25% reduction in max mutations/second - Mitigation: Use async audit logging for non-critical operations

Storage Growth: Unbounded growth without cleanup strategy - Estimate: ~500-800 bytes per change entry - Example: 10,000 mutations/day = ~5MB/day = ~1.8GB/year - Production Scale: 100,000 mutations/day = ~50MB/day = ~18GB/year

Retention Strategy - Partitioning by Month:

-- Create partitioned change_log table
CREATE TABLE core.tb_entity_change_log (
    id BIGINT GENERATED ALWAYS AS IDENTITY,
    pk_entity_change_log UUID NOT NULL DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL,
    user_id UUID,
    object_type TEXT NOT NULL,
    object_id UUID NOT NULL,
    modification_type TEXT NOT NULL,
    change_status TEXT NOT NULL,
    object_data JSONB NOT NULL,
    extra_metadata JSONB DEFAULT '{}'::jsonb,
    created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (id, created_at)  -- Include created_at in PK for partitioning
) PARTITION BY RANGE (created_at);

-- Create monthly partitions
CREATE TABLE core.tb_entity_change_log_2025_01
    PARTITION OF core.tb_entity_change_log
    FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');

CREATE TABLE core.tb_entity_change_log_2025_02
    PARTITION OF core.tb_entity_change_log
    FOR VALUES FROM ('2025-02-01') TO ('2025-03-01');

-- Indexes on each partition (created automatically)
CREATE INDEX idx_entity_log_object_2025_01
    ON core.tb_entity_change_log_2025_01 (object_type, object_id);
CREATE INDEX idx_entity_log_tenant_2025_01
    ON core.tb_entity_change_log_2025_01 (tenant_id, created_at);

Automated Partition Management:

-- Function to create next month's partition
CREATE OR REPLACE FUNCTION core.create_next_change_log_partition()
RETURNS void AS $$
DECLARE
    next_month DATE := date_trunc('month', CURRENT_DATE + interval '1 month');
    partition_name TEXT := 'tb_entity_change_log_' || to_char(next_month, 'YYYY_MM');
BEGIN
    EXECUTE format(
        'CREATE TABLE IF NOT EXISTS core.%I PARTITION OF core.tb_entity_change_log
         FOR VALUES FROM (%L) TO (%L)',
        partition_name,
        next_month,
        next_month + interval '1 month'
    );
END;
$$ LANGUAGE plpgsql;

-- Schedule monthly (via pg_cron or external scheduler)
-- Run on 1st of each month: SELECT core.create_next_change_log_partition();

Retention Policy - Archive Old Partitions:

-- Archive partitions older than 1 year to separate table
CREATE TABLE core.tb_entity_change_log_archive (
    LIKE core.tb_entity_change_log INCLUDING ALL
);

-- Archive and drop old partition (manual or scheduled)
DO $$
DECLARE
    old_partition TEXT := 'tb_entity_change_log_2024_01';
BEGIN
    -- Move data to archive
    EXECUTE format(
        'INSERT INTO core.tb_entity_change_log_archive
         SELECT * FROM core.%I',
        old_partition
    );

    -- Drop old partition
    EXECUTE format('DROP TABLE core.%I', old_partition);
END $$;

Best Practices: - ✅ Keep 12-18 months in partitioned table (fast queries) - ✅ Archive older data to separate table or cold storage - ✅ Compress archived partitions with pg_repack or similar - ✅ Monitor partition sizes weekly - ✅ Set up alerts for partition creation failures

Query Performance on Partitioned Table:

-- Efficient query (uses partition pruning)
SELECT * FROM core.tb_entity_change_log
WHERE created_at >= '2025-01-01'
  AND object_type = 'order'
  AND object_id = '123e4567-...';
-- Only scans 2025_01 partition

-- Inefficient query (scans all partitions)
SELECT * FROM core.tb_entity_change_log
WHERE object_type = 'order';
-- Always include created_at filter for partition pruning

tv_ Tables as Performance Layer

Purpose: tv_ tables serve as the primary high-performance data access layer.

Since Rust provides excellent JSON concatenation performance, tv_ tables eliminate the need for additional caching layers. The denormalized JSONB data in tv_ tables is optimized for direct GraphQL query serving.

Performance Characteristics: - Direct Access: GraphQL resolvers read directly from tv_ tables - Pre-computed: Complex joins and aggregations are materialized - Fast Serialization: Rust handles JSONB to GraphQL conversion efficiently - Explicit Updates: tv_ tables updated via explicit sync functions

No Additional Caching Needed: - tv_ tables provide sub-millisecond query performance - Rust's speed eliminates need for query result caching - Explicit sync ensures data consistency - Multi-tenant isolation built into table structure

Standardized Mutation Response Shape

Purpose: Consistent mutation results with before/after snapshots.

GraphQL Type:

from fraiseql.types import ID

@fraise_type
class MutationResultBase:
    """Standardized result for all mutations."""
    status: str
    id: ID | None = None
    updated_fields: list[str] | None = None
    message: str | None = None
    errors: list[dict[str, Any]] | None = None

@fraise_type
class MutationLogResult:
    """Detailed mutation result with change tracking."""
    status: str
    message: str | None = None
    reason: str | None = None
    op: str | None = None  # insert, update, delete
    entity: str | None = None
    extra_metadata: dict[str, Any] | None = None
    payload_before: dict[str, Any] | None = None
    payload_after: dict[str, Any] | None = None

Usage in Resolver:

import fraiseql
from fraiseql.types import ID

@fraiseql.mutation
async def update_product(
    info,
    id: ID,
    name: str,
    price: float
) -> MutationLogResult:
    db = info.context["db"]

    # Get current state
    old_product = await db.find_one("v_product", {"id": id})

    # Update
    await db.execute(
        "UPDATE tb_product SET name = $1, price = $2 WHERE id = $3",
        name, price, id
    )

    # Get new state
    new_product = await db.find_one("v_product", {"id": id})

    return MutationLogResult(
        status="updated",
        message=f"Product {name} updated successfully",
        op="update",
        entity="product",
        payload_before=old_product,
        payload_after=new_product,
        extra_metadata={"updated_fields": ["name", "price"]}
    )

Monitoring & Metrics

Purpose: Track tv_ table performance and sync effectiveness.

Sync Performance Monitoring:

-- Monitor sync function performance
SELECT
    schemaname,
    funcname,
    calls,
    ROUND(total_time::numeric, 2) as total_ms,
    ROUND(mean_time::numeric, 2) as avg_ms,
    ROUND((total_time / calls)::numeric, 2) as ms_per_call
FROM pg_stat_user_functions
WHERE funcname LIKE 'refresh_tv_%'
ORDER BY total_time DESC;

tv_ Table Freshness Check:

-- Check how fresh tv_ data is
SELECT
    schemaname || '.' || tablename as table_name,
    n_tup_ins + n_tup_upd + n_tup_del as total_changes,
    last_autoanalyze,
    last_analyze
FROM pg_stat_user_tables
WHERE tablename LIKE 'tv_%'
ORDER BY n_tup_ins + n_tup_upd + n_tup_del DESC;

Best Practices

View Design: - Use JSONB aggregation to prevent N+1 queries - Return structured data in data column - Include filter columns (id, tenant_id, status) at root level - Use COALESCE for null handling in aggregations

Performance: - Index foreign keys used in joins - Create composite indexes for common filter combinations - Use partial indexes for subset queries - Analyze query plans regularly

Multi-Tenancy: - Apply tenant filtering at view or application level - Use Row-Level Security for automatic isolation - Include tenant_id in all composite indexes

tv_ Table Maintenance: - Use explicit sync functions for data consistency - Monitor sync performance regularly - Keep tv_ tables fresh for optimal query performance - Monitor tv_ table sync performance - Ensure explicit sync calls are timely

Audit Trail: - Log all mutations to entity_change_log - Store before/after snapshots - Include user context for compliance - Use for debugging production issues

Maintenance: - Document view dependencies - Version views for backward compatibility - Monitor materialized view freshness - Keep views focused and composable

Summary: - Use JSONB aggregation to prevent N+1 queries - Separate write tables from read views - Apply tenant filtering at view or application level - Index JSONB fields accessed in WHERE clauses - Use explicit sync for tv_ table updates - Log all mutations for audit trail - Monitor query plans and sync performance regularly