database-design

Design and optimize production-grade database schemas for SQL and NoSQL databases. Use when creating new databases, designing tables/collections, defining relationships, indexing strategies, migrations, multi-tenancy, security hardening, or performance optimization. Handles PostgreSQL, MySQL, MongoDB with native SQL — no ORM.

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "database-design" with this command: npx skills add rahmat1929/skill-database-design/rahmat1929-skill-database-design-database-design

Database Design

Philosophy: Design databases that are correct first, performant second, and maintainable always.

Filosofi: Rancang database yang benar terlebih dahulu, performan kedua, dan selalu mudah dipelihara.


When to Use This Skill

Trigger this skill in the following situations:

Gunakan skill ini dalam situasi berikut:

  • New Project — Designing a database schema for a new application from scratch
  • Schema Refactoring — Redesigning existing schemas for performance or scalability
  • Relationship Design — Implementing 1:1, 1:N, N:M relationships between tables
  • Migrations — Safely applying schema changes to production databases
  • Performance Issues — Optimizing slow queries via indexes and schema restructuring
  • Multi-Tenancy — Designing shared or isolated tenant data architectures
  • Security Hardening — Implementing RLS, column encryption, role-based DB access
  • Audit & Compliance — Adding change tracking, history tables, GDPR/PII handling
  • Data Seeding — Generating realistic test/seed data for development

Input Format

Required Information (Informasi Wajib)

FieldDescriptionExample
Database TypePostgreSQL, MySQL, MongoDB, SQLitePostgreSQL
Domain DescriptionWhat data will be storedE-commerce, Loyalty System, SaaS
Core EntitiesMain data objectsUser, Product, Order, Points

Optional Information (Informasi Opsional)

FieldDescriptionDefault
Expected Data VolumeSmall (<10K), Medium (10K-1M), Large (>1M rows)Medium
Read/Write RatioRead-heavy, Write-heavy, BalancedBalanced
Transaction RequirementsACID compliance neededtrue
Multi-TenancyTenant isolation strategy neededfalse
ComplianceGDPR, HIPAA, PCI-DSS requirementsnone
Deployment TargetSelf-hosted, Supabase, PlanetScale, Neon, RDSSelf-hosted
Sharding/PartitioningLarge-scale data distribution neededfalse

Input Example

Design a loyalty system database for a retail platform:
- DB: PostgreSQL
- Entities: Member, Tier, Transaction, PointBalance, Reward, Redemption
- Relationships:
  - Member belongs to a Tier
  - Member earns Points via Transactions
  - Member redeems Points for Rewards
- Expected data: 500K members, 10M transactions/year
- Read-heavy (balance checks frequent)
- GDPR compliance required
- Deploy on Supabase

Instructions

Follow these steps in order. Each step builds on the previous.

Ikuti langkah-langkah berikut secara berurutan. Setiap langkah dibangun dari langkah sebelumnya.

Step 0: Requirements Gathering (Pengumpulan Kebutuhan)

Before designing anything, ask clarifying questions.

Sebelum merancang apapun, ajukan pertanyaan klarifikasi.

Questions to ask the user:

  1. Domain & Scale: What is the application domain? How many users/records do you expect in Year 1 and Year 3?
  2. Read/Write Pattern: Is the workload read-heavy (dashboards, reports) or write-heavy (logging, events)?
  3. Multi-Tenancy: Do multiple organizations/tenants share the database?
  4. Compliance: Are there regulatory requirements (GDPR, HIPAA, PCI-DSS)?
  5. Existing Stack: What framework/language is the backend? (Node.js, Python, Go, etc.)
  6. Deployment: Where will the database be hosted? (Self-hosted, Supabase, PlanetScale, Neon, AWS RDS)
  7. Caching: Is there a caching layer? (Redis, Memcached)
  8. Search: Do you need full-text search? (PostgreSQL FTS, Elasticsearch)
  9. Audit Trail: Do you need to track who changed what and when?
  10. Soft Delete: Should records be logically deleted (kept but hidden) or physically removed?

If the user provides incomplete information, use these defaults:

  • Database: PostgreSQL
  • Scale: Medium (10K-1M rows)
  • Read/Write: Balanced
  • Multi-Tenancy: false
  • Compliance: none
  • Soft Delete: true (recommended)
  • Audit: true (recommended)

Step 1: Entity & Attribute Definition (Definisi Entitas & Atribut)

Identify core data objects and their attributes.

Identifikasi objek data inti dan atributnya.

Tasks:

  • Extract nouns from business requirements → Entities
  • List attributes (columns) for each entity
  • Determine data types (VARCHAR, INTEGER, TIMESTAMP, JSONB, etc.)
  • Choose Primary Key strategy (UUID vs BIGSERIAL)
  • Add standard columns: created_at, updated_at, deleted_at (soft delete)

Primary Key Decision Matrix:

CriteriaUUID (v4/v7)BIGSERIAL
Distributed systems✅ Best choice❌ Conflicts
URL-safe IDs✅ Non-guessable❌ Sequential = guessable
Insert performance⚠️ Random (v4) / ✅ Ordered (v7)✅ Sequential
Storage size16 bytes8 bytes
Human readability❌ Long✅ Short

Recommendation: Use UUID v7 (time-ordered) for new projects. Use BIGSERIAL for high-insert-rate tables or when simplicity matters.

Rekomendasi: Gunakan UUID v7 untuk proyek baru. Gunakan BIGSERIAL untuk tabel dengan insert rate tinggi.

Example:

-- Standard table template
-- Template tabel standar
CREATE TABLE members (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    email VARCHAR(255) UNIQUE NOT NULL,
    full_name VARCHAR(100) NOT NULL,
    phone VARCHAR(20),
    tier_id UUID REFERENCES tiers(id),
    is_active BOOLEAN DEFAULT true,
    created_at TIMESTAMPTZ DEFAULT NOW(),
    updated_at TIMESTAMPTZ DEFAULT NOW(),
    deleted_at TIMESTAMPTZ  -- NULL = active, NOT NULL = soft deleted
);

Step 2: Relationship Design & Normalization (Desain Relasi & Normalisasi)

Define relationships between tables and apply normalization.

Definisikan hubungan antar tabel dan terapkan normalisasi.

Relationship Types:

  • 1:1 → Foreign Key + UNIQUE constraint (e.g., User ↔ Profile)
  • 1:N → Foreign Key on the "many" side (e.g., Tier → Members)
  • N:M → Junction table (e.g., Order ↔ Products via OrderItems)
  • Self-referencing → FK to same table (e.g., comments → parent_comment)
  • Polymorphicentity_type + entity_id pattern (use sparingly)

Normalization Decision Guide:

Panduan keputusan normalisasi:

System TypeNormalization LevelReason
OLTP (transactional)3NF (full)Data integrity, no anomalies
OLAP (analytics)1NF-2NF (denormalized)Query performance, fewer JOINs
Read-heavySelective denormalizationCache frequently joined data
Write-heavyFull normalizationEliminate update anomalies

ERD Template (Must ALWAYS use Mermaid):

erDiagram
    Tiers ||--o{ Members : "belongs to"
    Members ||--o{ Transactions : "earns"
    Members ||--o{ Redemptions : "redeems"
    Rewards ||--o{ Redemptions : "fulfilled by"
    Members ||--|| PointBalances : "has"

    Members {
        uuid id PK
        string email UK
        string full_name
        uuid tier_id FK
        timestamptz created_at
    }

    Tiers {
        uuid id PK
        string name UK
        int min_points
        decimal multiplier
    }

    Transactions {
        uuid id PK
        uuid member_id FK
        decimal amount
        int points_earned
        timestamptz created_at
    }

    PointBalances {
        uuid id PK
        uuid member_id FK
        int current_balance
        int lifetime_earned
        timestamptz updated_at
    }

Step 3: Index Strategy (Strategi Indeks)

Design indexes for query performance.

Rancang indeks untuk performa query.

Index Decision Rules:

  1. ✅ Primary Keys → automatic index
  2. ✅ Foreign Keys → always add explicit index
  3. ✅ Columns in WHERE clauses → index
  4. ✅ Columns in ORDER BY → index (match sort direction)
  5. ✅ Columns in JOIN conditions → index
  6. ⚠️ Low-cardinality columns (boolean, status) → partial index instead
  7. ❌ Don't over-index → each index slows INSERT/UPDATE

Index Types (PostgreSQL):

TypeUse CaseExample
B-tree (default)Equality, range, sortingWHERE price > 100
HashEquality onlyWHERE id = ?
GINJSONB, arrays, full-textWHERE tags @> '["sale"]'
GiSTGeometry, range typesPostGIS, tsrange
BRINLarge sequential dataTime-series, logs

Advanced Index Examples:

-- Standard FK indexes (WAJIB / REQUIRED)
CREATE INDEX idx_transactions_member_id ON transactions(member_id);
CREATE INDEX idx_members_tier_id ON members(tier_id);

-- Composite index (frequently queried together)
-- Indeks komposit (sering di-query bersamaan)
CREATE INDEX idx_transactions_member_date
    ON transactions(member_id, created_at DESC);

-- Partial index (only index active records — saves space)
-- Indeks parsial (hanya indeks record aktif — hemat ruang)
CREATE INDEX idx_members_active_email
    ON members(email) WHERE deleted_at IS NULL;

-- Covering index (index-only scan — no table lookup needed)
CREATE INDEX idx_transactions_covering
    ON transactions(member_id, created_at DESC)
    INCLUDE (points_earned, amount);

-- GIN index for JSONB search
CREATE INDEX idx_members_metadata ON members USING GIN(metadata jsonb_path_ops);

-- Full-text search index
CREATE INDEX idx_rewards_name_fts
    ON rewards USING GIN(to_tsvector('english', name || ' ' || description));

-- Expression index
CREATE INDEX idx_members_email_lower ON members(LOWER(email));

EXPLAIN ANALYZE — How to Read Query Plans:

Cara membaca rencana query:

EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT m.full_name, pb.current_balance
FROM members m
JOIN point_balances pb ON pb.member_id = m.id
WHERE m.tier_id = 'uuid-here' AND m.deleted_at IS NULL;

Key things to look for:

  • Seq Scan → Missing index (bad for large tables)
  • Index Scan / Index Only Scan → Good, using index
  • Nested Loop → Fine for small result sets
  • Hash Join → Good for larger joins
  • actual time → Real execution time in ms
  • rows vs rows= → Estimated vs actual (big diff = stale statistics, run ANALYZE)

Step 4: Constraints, Triggers & Type Safety (Constraint, Trigger & Type Safety)

Add constraints for data integrity and type safety.

Tambahkan constraint untuk integritas data dan type safety.

Constraint Types:

-- NOT NULL: Required columns
-- UNIQUE: No duplicates
-- CHECK: Value range validation
-- FOREIGN KEY: Referential integrity
-- EXCLUDE: Prevent overlapping ranges (PostgreSQL)

PostgreSQL ENUM for Status Fields:

Gunakan ENUM untuk field status agar type-safe:

-- Create custom types (buat tipe kustom)
CREATE TYPE member_status AS ENUM ('active', 'suspended', 'banned', 'deleted');
CREATE TYPE transaction_type AS ENUM ('purchase', 'refund', 'bonus', 'adjustment', 'redemption');
CREATE TYPE tier_level AS ENUM ('bronze', 'silver', 'gold', 'platinum', 'diamond');

-- Use in tables
CREATE TABLE members (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    email VARCHAR(255) UNIQUE NOT NULL,
    status member_status DEFAULT 'active' NOT NULL,
    -- CHECK constraint for additional validation
    phone VARCHAR(20) CHECK (phone ~ '^\+?[0-9\-\s]{7,20}$'),
    created_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE transactions (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    member_id UUID NOT NULL REFERENCES members(id) ON DELETE RESTRICT,
    type transaction_type NOT NULL,
    amount DECIMAL(12, 2) NOT NULL CHECK (amount != 0),
    points_earned INTEGER NOT NULL DEFAULT 0,
    -- Positive for earn, negative for spend
    CHECK (
        (type IN ('purchase', 'bonus') AND points_earned >= 0) OR
        (type IN ('refund', 'redemption') AND points_earned <= 0) OR
        (type = 'adjustment')
    ),
    created_at TIMESTAMPTZ DEFAULT NOW()
);

Auto-update Trigger:

-- Generic updated_at trigger (reusable for all tables)
-- Trigger updated_at generik (dapat dipakai ulang untuk semua tabel)
CREATE OR REPLACE FUNCTION trigger_set_updated_at()
RETURNS TRIGGER AS $$
BEGIN
    NEW.updated_at = NOW();
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

-- Apply to any table (terapkan ke tabel manapun)
CREATE TRIGGER set_members_updated_at
    BEFORE UPDATE ON members
    FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at();

CREATE TRIGGER set_point_balances_updated_at
    BEFORE UPDATE ON point_balances
    FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at();

Point Balance Integrity Trigger:

-- Automatically update point balance on new transaction
-- Otomatis update saldo poin saat transaksi baru
CREATE OR REPLACE FUNCTION update_point_balance()
RETURNS TRIGGER AS $$
BEGIN
    INSERT INTO point_balances (member_id, current_balance, lifetime_earned)
    VALUES (NEW.member_id, GREATEST(0, NEW.points_earned), GREATEST(0, NEW.points_earned))
    ON CONFLICT (member_id) DO UPDATE SET
        current_balance = point_balances.current_balance + NEW.points_earned,
        lifetime_earned = CASE
            WHEN NEW.points_earned > 0
            THEN point_balances.lifetime_earned + NEW.points_earned
            ELSE point_balances.lifetime_earned
        END,
        updated_at = NOW();

    -- Prevent negative balance (cegah saldo negatif)
    IF (SELECT current_balance FROM point_balances WHERE member_id = NEW.member_id) < 0 THEN
        RAISE EXCEPTION 'Insufficient point balance for member %', NEW.member_id;
    END IF;

    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_update_points
    AFTER INSERT ON transactions
    FOR EACH ROW EXECUTE FUNCTION update_point_balance();

Step 5: Security Hardening (Penguatan Keamanan)

Implement database-level security measures.

Implementasikan langkah-langkah keamanan di level database.

Row-Level Security (RLS):

-- Enable RLS (aktifkan RLS)
ALTER TABLE members ENABLE ROW LEVEL SECURITY;

-- Policy: Users can only see their own data
-- Kebijakan: User hanya bisa melihat data miliknya
CREATE POLICY members_self_access ON members
    FOR SELECT
    USING (id = current_setting('app.current_user_id')::UUID);

-- Policy: Admins can see all
CREATE POLICY members_admin_access ON members
    FOR ALL
    USING (current_setting('app.current_role') = 'admin');

Role-Based Database Access:

-- Create roles (buat role)
CREATE ROLE app_readonly;
CREATE ROLE app_readwrite;
CREATE ROLE app_admin;

-- Read-only: SELECT only
GRANT USAGE ON SCHEMA public TO app_readonly;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO app_readonly;

-- Read-write: SELECT, INSERT, UPDATE (no DELETE, no DDL)
GRANT USAGE ON SCHEMA public TO app_readwrite;
GRANT SELECT, INSERT, UPDATE ON ALL TABLES IN SCHEMA public TO app_readwrite;
GRANT USAGE ON ALL SEQUENCES IN SCHEMA public TO app_readwrite;

-- Admin: Full access
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO app_admin;

-- Application user (least privilege)
CREATE USER app_user WITH PASSWORD 'strong_password_here';
GRANT app_readwrite TO app_user;

PII & Sensitive Data Handling:

-- Separate PII into dedicated table with stricter access
-- Pisahkan PII ke tabel khusus dengan akses lebih ketat
CREATE TABLE member_pii (
    member_id UUID PRIMARY KEY REFERENCES members(id) ON DELETE CASCADE,
    full_name_encrypted BYTEA NOT NULL,  -- pgcrypto encrypted
    phone_encrypted BYTEA,
    date_of_birth DATE,
    address_encrypted BYTEA,
    created_at TIMESTAMPTZ DEFAULT NOW()
);

-- Encrypt using pgcrypto
CREATE EXTENSION IF NOT EXISTS pgcrypto;

-- Insert with encryption
INSERT INTO member_pii (member_id, full_name_encrypted)
VALUES (
    'member-uuid',
    pgp_sym_encrypt('John Doe', current_setting('app.encryption_key'))
);

-- Read with decryption
SELECT pgp_sym_decrypt(full_name_encrypted, current_setting('app.encryption_key')) AS full_name
FROM member_pii WHERE member_id = 'member-uuid';

Step 6: Audit & History Tracking (Audit & Pelacakan Riwayat)

Track all changes for compliance and debugging.

Lacak semua perubahan untuk kepatuhan dan debugging.

Generic Audit Log Table:

CREATE TABLE audit_log (
    id BIGSERIAL PRIMARY KEY,
    table_name VARCHAR(100) NOT NULL,
    record_id UUID NOT NULL,
    action VARCHAR(10) NOT NULL CHECK (action IN ('INSERT', 'UPDATE', 'DELETE')),
    old_data JSONB,
    new_data JSONB,
    changed_fields TEXT[],
    performed_by UUID,  -- user who made the change
    performed_at TIMESTAMPTZ DEFAULT NOW(),
    ip_address INET,
    user_agent TEXT
);

CREATE INDEX idx_audit_log_table_record ON audit_log(table_name, record_id);
CREATE INDEX idx_audit_log_performed_at ON audit_log(performed_at);
CREATE INDEX idx_audit_log_performed_by ON audit_log(performed_by);

-- Generic audit trigger function
-- Fungsi trigger audit generik
CREATE OR REPLACE FUNCTION audit_trigger_func()
RETURNS TRIGGER AS $$
DECLARE
    changed TEXT[];
    col TEXT;
BEGIN
    IF TG_OP = 'UPDATE' THEN
        -- Detect which columns changed
        FOR col IN SELECT column_name FROM information_schema.columns
            WHERE table_name = TG_TABLE_NAME AND table_schema = TG_TABLE_SCHEMA
        LOOP
            IF to_jsonb(OLD) -> col IS DISTINCT FROM to_jsonb(NEW) -> col THEN
                changed := array_append(changed, col);
            END IF;
        END LOOP;
    END IF;

    INSERT INTO audit_log (table_name, record_id, action, old_data, new_data, changed_fields, performed_by)
    VALUES (
        TG_TABLE_NAME,
        CASE WHEN TG_OP = 'DELETE' THEN (OLD).id ELSE (NEW).id END,
        TG_OP,
        CASE WHEN TG_OP IN ('UPDATE', 'DELETE') THEN to_jsonb(OLD) END,
        CASE WHEN TG_OP IN ('INSERT', 'UPDATE') THEN to_jsonb(NEW) END,
        changed,
        NULLIF(current_setting('app.current_user_id', true), '')::UUID
    );

    RETURN COALESCE(NEW, OLD);
END;
$$ LANGUAGE plpgsql;

-- Apply to tables (terapkan ke tabel)
CREATE TRIGGER audit_members
    AFTER INSERT OR UPDATE OR DELETE ON members
    FOR EACH ROW EXECUTE FUNCTION audit_trigger_func();

CREATE TRIGGER audit_transactions
    AFTER INSERT OR UPDATE OR DELETE ON transactions
    FOR EACH ROW EXECUTE FUNCTION audit_trigger_func();

History Table (SCD Type 2) — for tracking tier changes:

CREATE TABLE member_tier_history (
    id BIGSERIAL PRIMARY KEY,
    member_id UUID NOT NULL REFERENCES members(id),
    tier_id UUID NOT NULL REFERENCES tiers(id),
    valid_from TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    valid_to TIMESTAMPTZ,  -- NULL = current
    changed_reason VARCHAR(50),  -- 'promotion', 'demotion', 'manual'
    CONSTRAINT no_overlap EXCLUDE USING gist (
        member_id WITH =,
        tstzrange(valid_from, COALESCE(valid_to, 'infinity')) WITH &&
    )
);

CREATE INDEX idx_tier_history_member ON member_tier_history(member_id, valid_from DESC);

Step 7: Migration Scripts (Skrip Migrasi)

Write safe, rollback-capable migration scripts.

Tulis skrip migrasi yang aman dan bisa di-rollback.

Migration Rules:

  1. ✅ Always wrap in transactions
  2. ✅ Always provide UP and DOWN migrations
  3. ✅ Use IF EXISTS / IF NOT EXISTS for idempotency
  4. ✅ Add columns as nullable first, then backfill, then set NOT NULL
  5. ❌ Never rename columns directly in production (add new → backfill → drop old)
  6. ❌ Never drop columns without a deprecation period

Zero-Downtime Migration Pattern:

-- migrations/001_initial_schema.up.sql
BEGIN;

CREATE EXTENSION IF NOT EXISTS "pgcrypto";

-- Tiers (create first — referenced by members)
CREATE TABLE IF NOT EXISTS tiers (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name VARCHAR(50) UNIQUE NOT NULL,
    level tier_level NOT NULL,
    min_points INTEGER NOT NULL DEFAULT 0,
    multiplier DECIMAL(3, 2) NOT NULL DEFAULT 1.00,
    benefits JSONB DEFAULT '{}',
    created_at TIMESTAMPTZ DEFAULT NOW()
);

-- Members
CREATE TABLE IF NOT EXISTS members (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    email VARCHAR(255) UNIQUE NOT NULL,
    full_name VARCHAR(100) NOT NULL,
    phone VARCHAR(20),
    tier_id UUID REFERENCES tiers(id) ON DELETE SET NULL,
    status member_status DEFAULT 'active' NOT NULL,
    metadata JSONB DEFAULT '{}',
    created_at TIMESTAMPTZ DEFAULT NOW(),
    updated_at TIMESTAMPTZ DEFAULT NOW(),
    deleted_at TIMESTAMPTZ
);

CREATE INDEX IF NOT EXISTS idx_members_email ON members(email) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_members_tier ON members(tier_id);
CREATE INDEX IF NOT EXISTS idx_members_status ON members(status) WHERE deleted_at IS NULL;

COMMIT;

-- migrations/001_initial_schema.down.sql
BEGIN;
DROP TABLE IF EXISTS members CASCADE;
DROP TABLE IF EXISTS tiers CASCADE;
COMMIT;

Adding a Column (Zero-Downtime):

-- migrations/005_add_referral_code.up.sql
-- Step 1: Add nullable column (no lock)
ALTER TABLE members ADD COLUMN IF NOT EXISTS referral_code VARCHAR(20);

-- Step 2: Backfill existing rows (in batches to avoid long locks)
-- Isi data existing (dalam batch untuk menghindari lock lama)
DO $$
DECLARE
    batch_size INT := 1000;
    affected INT := 1;
BEGIN
    WHILE affected > 0 LOOP
        UPDATE members
        SET referral_code = UPPER(SUBSTRING(id::TEXT, 1, 8))
        WHERE referral_code IS NULL
        AND id IN (SELECT id FROM members WHERE referral_code IS NULL LIMIT batch_size);
        GET DIAGNOSTICS affected = ROW_COUNT;
        PERFORM pg_sleep(0.1);  -- Brief pause between batches
    END LOOP;
END $$;

-- Step 3: Add constraint (after backfill complete)
ALTER TABLE members ALTER COLUMN referral_code SET NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_members_referral ON members(referral_code);

-- migrations/005_add_referral_code.down.sql
ALTER TABLE members DROP COLUMN IF EXISTS referral_code;

Step 8: Sample Data & Documentation (Data Sampel & Dokumentasi)

Generate realistic sample data and document how tables are used.

Buat data sampel realistis dan dokumentasikan penggunaan tabel.

Tasks:

  1. Sample Data (sample_data.sql): Write complete INSERT statements with realistic raw data.
  2. Data Usage (sample_data.md): Document the usage of the tables with the raw data and descriptions.
  3. ERD (ERD.md): ALWAYS use Mermaid syntax for the Entity-Relationship Diagram.
  4. Schema (SCHEMA.md): Document tables, columns, constraints, and relationships.

Sample Data Documentation Template (sample_data.md):

# Sample Data Usage

### Table: members
Stores the core user information and points to their active tier.

**Raw Data**:
```sql
INSERT INTO members (id, email, full_name, tier_id) VALUES
('b1c2...', 'member@example.com', 'Alice', 'tier-uuid');

Description:

  • Alice is an active member belonging to the Bronze tier. This record links to tiers via tier_id.

---

## Multi-Tenancy Patterns (Pola Multi-Tenancy)

Choose the right isolation strategy based on your requirements.
> Pilih strategi isolasi yang tepat berdasarkan kebutuhan Anda.

| Pattern | Isolation | Complexity | Cost | Best For |
|---|---|---|---|---|
| **Shared DB + `tenant_id`** | Low | Low | Low | Most SaaS apps |
| **Schema-per-tenant** | Medium | Medium | Medium | Regulated industries |
| **Database-per-tenant** | High | High | High | Enterprise, compliance-critical |

### Pattern 1: Shared DB with `tenant_id` (Recommended Default)

```sql
-- Add tenant_id to every table (tambahkan tenant_id ke setiap tabel)
CREATE TABLE members (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL REFERENCES tenants(id),
    email VARCHAR(255) NOT NULL,
    -- email unique PER tenant, not globally
    UNIQUE (tenant_id, email),
    created_at TIMESTAMPTZ DEFAULT NOW()
);

-- RLS policy for automatic tenant isolation
ALTER TABLE members ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON members
    FOR ALL
    USING (tenant_id = current_setting('app.current_tenant_id')::UUID)
    WITH CHECK (tenant_id = current_setting('app.current_tenant_id')::UUID);

-- Always index tenant_id (selalu indeks tenant_id)
CREATE INDEX idx_members_tenant ON members(tenant_id);

Pattern 2: Schema-per-Tenant

-- Create tenant schema (buat schema per tenant)
CREATE SCHEMA tenant_acme;

-- Create tables in tenant schema
CREATE TABLE tenant_acme.members (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    email VARCHAR(255) UNIQUE NOT NULL,
    created_at TIMESTAMPTZ DEFAULT NOW()
);

-- Switch schema at connection time
SET search_path TO tenant_acme, public;

Output Format (Format Output)

Project Structure

project/
├── database/                    
│   ├── migrations/
│   │   ├── 001_create_users.up.sql
│   │   ├── 001_create_users.down.sql
│   │   ├── 002_create_products.up.sql
│   │   └── 002_create_products.down.sql
│   ├── seeds/
│   │   ├── sample_data.md             # Usage of tables with raw data and description
│   │   └── sample_data.sql            # Raw data SQL inserts
│   ├── schema.sql
│   ├── ERD.md                     # Mermaid ERD (ALWAYS use Mermaid)
│   └── SCHEMA.md                  # Table documentation
└── README.md

Table Documentation Template

### table_name

| Column | Type | Nullable | Default | Description |
|--------|------|----------|---------|-------------|
| id | UUID | NO | gen_random_uuid() | Primary key |
| name | VARCHAR(100) | NO | — | Display name |
| created_at | TIMESTAMPTZ | NO | NOW() | Record creation time |
| deleted_at | TIMESTAMPTZ | YES | NULL | Soft delete timestamp |

**Indexes**: idx_table_column1, idx_table_column2
**Triggers**: set_updated_at
**RLS**: Enabled (tenant_isolation policy)
**Estimated rows**: 100,000

Constraints & Rules (Aturan & Batasan)

MUST (Wajib)

  1. Primary Key on every table — No exceptions
  2. Foreign Key explicit — Always specify ON DELETE behavior (CASCADE / SET NULL / RESTRICT)
  3. Index all Foreign Keys — PostgreSQL does NOT auto-index FKs
  4. NOT NULL on required fields — Don't allow NULL where data is required
  5. created_at on every table — Always track when records were created
  6. updated_at on mutable tables — With auto-update trigger
  7. Soft delete by default — Use deleted_at TIMESTAMPTZ for important entities
  8. Use TIMESTAMPTZ — Always timezone-aware, never bare TIMESTAMP
  9. Snake_case naming — Tables plural (members), columns singular (email)
  10. Transactions for migrations — Always wrap DDL in BEGIN/COMMIT

MUST NOT (Dilarang)

  1. ❌ Store passwords in plaintext — Use bcrypt/scrypt hashing at application level
  2. ❌ Store credit card numbers — Use a payment processor (Stripe, etc.)
  3. ❌ Over-use EAV pattern — Entity-Attribute-Value makes queries impossibly complex
  4. ❌ Use SELECT * — Always specify columns
  5. ❌ Skip Foreign Keys for "performance" — Integrity > micro-optimization
  6. ❌ Store monetary values as FLOAT — Use DECIMAL(12, 2) or INTEGER (cents)
  7. ❌ Use bare TIMESTAMP — Always use TIMESTAMPTZ for timezone safety
  8. ❌ Rename columns directly in production — Add new, backfill, drop old
  9. ❌ Store files/images as BLOBs — Store URLs, use object storage (S3, GCS)

Schema Validation Checklist (Daftar Periksa Validasi Skema)

Before finalizing any schema, verify every item:

Sebelum menyelesaikan skema apapun, verifikasi setiap item:

[ ] Every table has a PRIMARY KEY
[ ] Every FK column has an explicit INDEX
[ ] Every FK has ON DELETE behavior specified
[ ] Every required column is NOT NULL
[ ] Every table has created_at TIMESTAMPTZ
[ ] Mutable tables have updated_at with trigger
[ ] Important entities have deleted_at (soft delete)
[ ] All TIMESTAMP columns use TIMESTAMPTZ (with timezone)
[ ] Monetary values use DECIMAL, not FLOAT
[ ] Status fields use ENUM or CHECK constraints
[ ] Table names are plural snake_case
[ ] Column names are singular snake_case
[ ] No reserved words used as identifiers
[ ] Sensitive data is encrypted or in separate PII table
[ ] Indexes exist for common WHERE/JOIN/ORDER BY columns
[ ] Composite indexes have correct column order (high selectivity first)
[ ] Migration has both UP and DOWN scripts
[ ] Migration is wrapped in transaction
[ ] Migration generates UP and DOWN scripts inside `database/migrations/`
[ ] sample_data.sql and sample_data.md are provided with raw data and usage descriptions
[ ] ERD diagram ALWAYS uses Mermaid syntax and is saved in `database/ERD.md`

Examples

Example 1: Loyalty System (Sistem Loyalitas)

Scenario: Retail loyalty program with tier-based point earning and reward redemption.

Skenario: Program loyalitas retail dengan earning poin berbasis tier dan penukaran reward.

Requirements:

  • Members earn points from purchases
  • Points multiplier based on tier level
  • Members can redeem points for rewards
  • Tier auto-promotion based on lifetime points
  • Full audit trail required

Full Schema:

-- Custom Types
CREATE TYPE member_status AS ENUM ('active', 'suspended', 'banned');
CREATE TYPE transaction_type AS ENUM ('purchase', 'refund', 'bonus', 'adjustment', 'redemption');
CREATE TYPE tier_level AS ENUM ('bronze', 'silver', 'gold', 'platinum', 'diamond');
CREATE TYPE redemption_status AS ENUM ('pending', 'approved', 'fulfilled', 'cancelled');

-- Tiers
CREATE TABLE tiers (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name VARCHAR(50) UNIQUE NOT NULL,
    level tier_level UNIQUE NOT NULL,
    min_points INTEGER NOT NULL DEFAULT 0,
    points_multiplier DECIMAL(3, 2) NOT NULL DEFAULT 1.00,
    benefits JSONB DEFAULT '[]',
    is_active BOOLEAN DEFAULT true,
    created_at TIMESTAMPTZ DEFAULT NOW()
);

-- Members
CREATE TABLE members (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    member_code VARCHAR(20) UNIQUE NOT NULL,
    email VARCHAR(255) UNIQUE NOT NULL,
    full_name VARCHAR(100) NOT NULL,
    phone VARCHAR(20),
    tier_id UUID NOT NULL REFERENCES tiers(id),
    status member_status DEFAULT 'active' NOT NULL,
    joined_at DATE NOT NULL DEFAULT CURRENT_DATE,
    metadata JSONB DEFAULT '{}',
    created_at TIMESTAMPTZ DEFAULT NOW(),
    updated_at TIMESTAMPTZ DEFAULT NOW(),
    deleted_at TIMESTAMPTZ
);

CREATE INDEX idx_members_tier ON members(tier_id);
CREATE INDEX idx_members_status ON members(status) WHERE deleted_at IS NULL;
CREATE INDEX idx_members_email ON members(LOWER(email)) WHERE deleted_at IS NULL;
CREATE INDEX idx_members_code ON members(member_code);

-- Point Balances (1:1 with Members)
CREATE TABLE point_balances (
    member_id UUID PRIMARY KEY REFERENCES members(id) ON DELETE CASCADE,
    current_balance INTEGER NOT NULL DEFAULT 0 CHECK (current_balance >= 0),
    lifetime_earned INTEGER NOT NULL DEFAULT 0 CHECK (lifetime_earned >= 0),
    lifetime_redeemed INTEGER NOT NULL DEFAULT 0 CHECK (lifetime_redeemed >= 0),
    updated_at TIMESTAMPTZ DEFAULT NOW()
);

-- Transactions (point earning events)
CREATE TABLE transactions (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    member_id UUID NOT NULL REFERENCES members(id) ON DELETE RESTRICT,
    type transaction_type NOT NULL,
    reference_id VARCHAR(100),  -- external order/invoice ID
    amount DECIMAL(12, 2) NOT NULL DEFAULT 0,
    points_earned INTEGER NOT NULL DEFAULT 0,
    multiplier_applied DECIMAL(3, 2) NOT NULL DEFAULT 1.00,
    notes TEXT,
    created_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_txn_member ON transactions(member_id, created_at DESC);
CREATE INDEX idx_txn_type ON transactions(type);
CREATE INDEX idx_txn_reference ON transactions(reference_id) WHERE reference_id IS NOT NULL;
CREATE INDEX idx_txn_created ON transactions(created_at);

-- Rewards (catalog of redeemable items)
CREATE TABLE rewards (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name VARCHAR(255) NOT NULL,
    description TEXT,
    points_cost INTEGER NOT NULL CHECK (points_cost > 0),
    stock INTEGER DEFAULT 0 CHECK (stock >= 0),
    min_tier tier_level DEFAULT 'bronze',
    is_active BOOLEAN DEFAULT true,
    image_url VARCHAR(500),
    valid_from TIMESTAMPTZ,
    valid_until TIMESTAMPTZ,
    created_at TIMESTAMPTZ DEFAULT NOW(),
    updated_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_rewards_active ON rewards(is_active, points_cost) WHERE is_active = true;
CREATE INDEX idx_rewards_tier ON rewards(min_tier);

-- Redemptions
CREATE TABLE redemptions (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    member_id UUID NOT NULL REFERENCES members(id) ON DELETE RESTRICT,
    reward_id UUID NOT NULL REFERENCES rewards(id) ON DELETE RESTRICT,
    points_spent INTEGER NOT NULL CHECK (points_spent > 0),
    status redemption_status DEFAULT 'pending' NOT NULL,
    fulfilled_at TIMESTAMPTZ,
    cancelled_at TIMESTAMPTZ,
    cancel_reason TEXT,
    created_at TIMESTAMPTZ DEFAULT NOW(),
    updated_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_redemptions_member ON redemptions(member_id, created_at DESC);
CREATE INDEX idx_redemptions_status ON redemptions(status);

-- Member Tier History (SCD Type 2)
CREATE TABLE member_tier_history (
    id BIGSERIAL PRIMARY KEY,
    member_id UUID NOT NULL REFERENCES members(id) ON DELETE CASCADE,
    from_tier_id UUID REFERENCES tiers(id),
    to_tier_id UUID NOT NULL REFERENCES tiers(id),
    reason VARCHAR(50) NOT NULL,  -- 'auto_promotion', 'auto_demotion', 'manual'
    changed_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_tier_history_member ON member_tier_history(member_id, changed_at DESC);

ERD:

erDiagram
    Tiers ||--o{ Members : "belongs to"
    Members ||--|| PointBalances : "has"
    Members ||--o{ Transactions : "earns from"
    Members ||--o{ Redemptions : "redeems"
    Rewards ||--o{ Redemptions : "fulfilled by"
    Members ||--o{ MemberTierHistory : "tracks"

    Tiers {
        uuid id PK
        string name UK
        tier_level level UK
        int min_points
        decimal points_multiplier
        jsonb benefits
    }

    Members {
        uuid id PK
        string member_code UK
        string email UK
        uuid tier_id FK
        member_status status
        timestamptz deleted_at
    }

    PointBalances {
        uuid member_id PK_FK
        int current_balance
        int lifetime_earned
        int lifetime_redeemed
    }

    Transactions {
        uuid id PK
        uuid member_id FK
        transaction_type type
        decimal amount
        int points_earned
    }

    Rewards {
        uuid id PK
        string name
        int points_cost
        int stock
        tier_level min_tier
    }

    Redemptions {
        uuid id PK
        uuid member_id FK
        uuid reward_id FK
        int points_spent
        redemption_status status
    }

Example 2: E-Commerce Platform (Platform E-Commerce)

Scenario: Online marketplace with products, orders, reviews, and categories.

CREATE TABLE categories (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name VARCHAR(100) UNIQUE NOT NULL,
    slug VARCHAR(100) UNIQUE NOT NULL,
    parent_id UUID REFERENCES categories(id) ON DELETE SET NULL,
    sort_order INTEGER DEFAULT 0,
    created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_categories_parent ON categories(parent_id);

CREATE TABLE products (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name VARCHAR(255) NOT NULL,
    slug VARCHAR(255) UNIQUE NOT NULL,
    description TEXT,
    price DECIMAL(12, 2) NOT NULL CHECK (price >= 0),
    compare_at_price DECIMAL(12, 2) CHECK (compare_at_price IS NULL OR compare_at_price >= price),
    stock INTEGER DEFAULT 0 CHECK (stock >= 0),
    sku VARCHAR(50) UNIQUE,
    category_id UUID REFERENCES categories(id) ON DELETE SET NULL,
    tags TEXT[] DEFAULT '{}',
    images JSONB DEFAULT '[]',
    is_published BOOLEAN DEFAULT false,
    published_at TIMESTAMPTZ,
    created_at TIMESTAMPTZ DEFAULT NOW(),
    updated_at TIMESTAMPTZ DEFAULT NOW(),
    deleted_at TIMESTAMPTZ
);

CREATE INDEX idx_products_category ON products(category_id);
CREATE INDEX idx_products_price ON products(price) WHERE deleted_at IS NULL AND is_published = true;
CREATE INDEX idx_products_sku ON products(sku) WHERE sku IS NOT NULL;
CREATE INDEX idx_products_tags ON products USING GIN(tags);
CREATE INDEX idx_products_fts ON products USING GIN(
    to_tsvector('english', COALESCE(name, '') || ' ' || COALESCE(description, ''))
);

CREATE TABLE orders (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
    order_number VARCHAR(20) UNIQUE NOT NULL,
    subtotal DECIMAL(12, 2) NOT NULL CHECK (subtotal >= 0),
    tax DECIMAL(12, 2) NOT NULL DEFAULT 0 CHECK (tax >= 0),
    shipping_cost DECIMAL(12, 2) NOT NULL DEFAULT 0,
    total DECIMAL(12, 2) NOT NULL CHECK (total >= 0),
    status VARCHAR(20) DEFAULT 'pending' CHECK (
        status IN ('pending', 'confirmed', 'processing', 'shipped', 'delivered', 'cancelled', 'refunded')
    ),
    shipping_address JSONB NOT NULL,
    notes TEXT,
    created_at TIMESTAMPTZ DEFAULT NOW(),
    updated_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_orders_user ON orders(user_id, created_at DESC);
CREATE INDEX idx_orders_status ON orders(status);
CREATE INDEX idx_orders_number ON orders(order_number);

CREATE TABLE order_items (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    order_id UUID NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
    product_id UUID NOT NULL REFERENCES products(id) ON DELETE RESTRICT,
    quantity INTEGER NOT NULL CHECK (quantity > 0),
    unit_price DECIMAL(12, 2) NOT NULL CHECK (unit_price >= 0),
    total_price DECIMAL(12, 2) GENERATED ALWAYS AS (quantity * unit_price) STORED,
    created_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_order_items_order ON order_items(order_id);
CREATE INDEX idx_order_items_product ON order_items(product_id);

CREATE TABLE reviews (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE,
    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    rating SMALLINT NOT NULL CHECK (rating BETWEEN 1 AND 5),
    title VARCHAR(200),
    body TEXT,
    is_verified_purchase BOOLEAN DEFAULT false,
    created_at TIMESTAMPTZ DEFAULT NOW(),
    updated_at TIMESTAMPTZ DEFAULT NOW(),
    UNIQUE (product_id, user_id)  -- one review per user per product
);

CREATE INDEX idx_reviews_product ON reviews(product_id, rating);
CREATE INDEX idx_reviews_user ON reviews(user_id);

Example 3: SaaS Multi-Tenant Platform

Scenario: B2B project management tool with workspace-level isolation.

-- Tenant table
CREATE TABLE tenants (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name VARCHAR(100) NOT NULL,
    slug VARCHAR(50) UNIQUE NOT NULL,
    plan VARCHAR(20) DEFAULT 'free' CHECK (plan IN ('free', 'starter', 'pro', 'enterprise')),
    settings JSONB DEFAULT '{}',
    is_active BOOLEAN DEFAULT true,
    created_at TIMESTAMPTZ DEFAULT NOW(),
    updated_at TIMESTAMPTZ DEFAULT NOW()
);

-- Users with tenant association
CREATE TABLE users (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    email VARCHAR(255) NOT NULL,
    full_name VARCHAR(100) NOT NULL,
    role VARCHAR(20) DEFAULT 'member' CHECK (role IN ('owner', 'admin', 'member', 'viewer')),
    created_at TIMESTAMPTZ DEFAULT NOW(),
    updated_at TIMESTAMPTZ DEFAULT NOW(),
    deleted_at TIMESTAMPTZ,
    UNIQUE (tenant_id, email)
);

CREATE INDEX idx_users_tenant ON users(tenant_id);

-- Projects (tenant-scoped)
CREATE TABLE projects (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    name VARCHAR(200) NOT NULL,
    description TEXT,
    status VARCHAR(20) DEFAULT 'active' CHECK (status IN ('active', 'archived', 'deleted')),
    created_by UUID REFERENCES users(id),
    created_at TIMESTAMPTZ DEFAULT NOW(),
    updated_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_projects_tenant ON projects(tenant_id);

-- Enable RLS on all tenant-scoped tables
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_users ON users
    FOR ALL USING (tenant_id = current_setting('app.current_tenant_id')::UUID);

CREATE POLICY tenant_projects ON projects
    FOR ALL USING (tenant_id = current_setting('app.current_tenant_id')::UUID);

Edge Cases & Anti-Patterns (Kasus Tepi & Anti-Pola)

❌ Anti-Pattern 1: Polymorphic Associations (Without Type Safety)

-- BAD: No FK integrity possible
-- BURUK: Tidak bisa FK integrity
CREATE TABLE comments (
    id UUID PRIMARY KEY,
    entity_type VARCHAR(50),  -- 'post', 'product', 'order'
    entity_id UUID,           -- could point to anything
    body TEXT
);

-- GOOD: Separate FK per entity, or use table inheritance
-- BAIK: FK terpisah per entitas
CREATE TABLE comments (
    id UUID PRIMARY KEY,
    post_id UUID REFERENCES posts(id),
    product_id UUID REFERENCES products(id),
    body TEXT NOT NULL,
    CHECK (
        (post_id IS NOT NULL)::INT + (product_id IS NOT NULL)::INT = 1
    )
);

❌ Anti-Pattern 2: Storing Money as FLOAT

-- BAD: Floating point errors (0.1 + 0.2 ≠ 0.3)
price FLOAT NOT NULL

-- GOOD: Use DECIMAL or store as cents (INTEGER)
price DECIMAL(12, 2) NOT NULL
-- OR
price_cents INTEGER NOT NULL  -- 1999 = $19.99

❌ Anti-Pattern 3: Over-Indexing

-- BAD: Index on every column (slows writes significantly)
CREATE INDEX idx1 ON members(email);
CREATE INDEX idx2 ON members(full_name);
CREATE INDEX idx3 ON members(phone);
CREATE INDEX idx4 ON members(created_at);
CREATE INDEX idx5 ON members(updated_at);  -- rarely queried
CREATE INDEX idx6 ON members(deleted_at);  -- rarely queried

-- GOOD: Index only what you query
CREATE INDEX idx_members_email ON members(email) WHERE deleted_at IS NULL;
CREATE INDEX idx_members_created ON members(created_at);
-- Use covering index instead of multiple single indexes
CREATE INDEX idx_members_search ON members(email, full_name) WHERE deleted_at IS NULL;

❌ Anti-Pattern 4: NULL Handling Pitfalls

-- GOTCHA: NULL comparisons
-- NULL = NULL returns NULL (not TRUE!)
-- NULL != 'value' returns NULL (not TRUE!)

-- BAD: This misses soft-deleted rows AND active rows with NULL
SELECT * FROM members WHERE status != 'banned';

-- GOOD: Be explicit about NULL
SELECT * FROM members WHERE status != 'banned' OR status IS NULL;
-- Or use: WHERE status IS DISTINCT FROM 'banned'

❌ Anti-Pattern 5: VARCHAR(255) Everywhere

-- BAD: Lazy sizing
email VARCHAR(255),
phone VARCHAR(255),
country_code VARCHAR(255)

-- GOOD: Appropriate sizing (validates data, documents intent)
email VARCHAR(255),       -- RFC 5321 allows up to 254
phone VARCHAR(20),        -- international format
country_code CHAR(2),     -- ISO 3166-1 alpha-2
currency_code CHAR(3),    -- ISO 4217
postal_code VARCHAR(10)

Database-Specific Notes (Catatan Spesifik Database)

PostgreSQL-Specific Features

-- JSONB: Flexible schema within structured database
ALTER TABLE members ADD COLUMN preferences JSONB DEFAULT '{"notifications": true}';
SELECT * FROM members WHERE preferences @> '{"notifications": true}';

-- Arrays: Store simple lists without junction tables
ALTER TABLE products ADD COLUMN tags TEXT[] DEFAULT '{}';
SELECT * FROM products WHERE 'electronics' = ANY(tags);

-- CTEs for complex queries (WITH clause)
WITH monthly_points AS (
    SELECT member_id, SUM(points_earned) as total
    FROM transactions
    WHERE created_at >= DATE_TRUNC('month', NOW())
    GROUP BY member_id
)
SELECT m.full_name, mp.total
FROM members m
JOIN monthly_points mp ON mp.member_id = m.id
ORDER BY mp.total DESC;

-- Window Functions for rankings
SELECT full_name, current_balance,
    RANK() OVER (ORDER BY current_balance DESC) as rank
FROM members m
JOIN point_balances pb ON pb.member_id = m.id
WHERE m.deleted_at IS NULL;

-- Generated Columns
ALTER TABLE order_items ADD COLUMN total_price DECIMAL(12,2)
    GENERATED ALWAYS AS (quantity * unit_price) STORED;

MySQL Differences

-- UUID: No native UUID type, use BINARY(16) or CHAR(36)
CREATE TABLE members (
    id CHAR(36) PRIMARY KEY DEFAULT (UUID()),
    -- MySQL 8.0+ supports DEFAULT (expression)
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- No ENUM type issue: MySQL has native ENUM but it's string-based
status ENUM('active', 'suspended', 'banned') DEFAULT 'active'

-- No partial indexes: Use generated columns + index instead
ALTER TABLE members ADD COLUMN is_not_deleted TINYINT
    GENERATED ALWAYS AS (CASE WHEN deleted_at IS NULL THEN 1 END) STORED;
CREATE INDEX idx_active_members ON members(is_not_deleted, email);

-- InnoDB: Always specify ENGINE
CREATE TABLE members (...) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

MongoDB Schema Validation

// MongoDB 3.6+ schema validation (validasi skema MongoDB)
db.createCollection("members", {
  validator: {
    $jsonSchema: {
      bsonType: "object",
      required: ["email", "fullName", "tier", "status"],
      properties: {
        email: { bsonType: "string", pattern: "^.+@.+\\..+$" },
        fullName: { bsonType: "string", maxLength: 100 },
        tier: { enum: ["bronze", "silver", "gold", "platinum", "diamond"] },
        status: { enum: ["active", "suspended", "banned"] },
        pointBalance: {
          bsonType: "object",
          properties: {
            current: { bsonType: "int", minimum: 0 },
            lifetimeEarned: { bsonType: "int", minimum: 0 }
          }
        },
        createdAt: { bsonType: "date" }
      }
    }
  }
});

// Indexes
db.members.createIndex({ email: 1 }, { unique: true });
db.members.createIndex({ tier: 1, status: 1 });
db.members.createIndex({ "pointBalance.current": -1 });

Best Practices (Praktik Terbaik)

Naming Conventions (Konvensi Penamaan)

ElementConventionExample
Tablesplural, snake_caseorder_items, member_tier_history
Columnssingular, snake_casecreated_at, email, tier_id
Primary Keyidid UUID PRIMARY KEY
Foreign Key<singular_table>_idmember_id, tier_id
Indexesidx_<table>_<columns>idx_members_email
Triggerstrg_<action>_<table>trg_update_points
ENUMssingular, snake_casemember_status, tier_level
Constraintschk_<table>_<rule>chk_products_price_positive

Performance Tips (Tips Performa)

  1. Connection Pooling: Use PgBouncer or built-in pooler (Supabase has this)
  2. Partial Indexes: Index only relevant rows to save space and speed writes
  3. Materialized Views: Cache complex aggregations, refresh periodically
  4. BRIN Indexes: For large time-series or append-only tables
  5. Partitioning: Range-partition large tables by date
  6. VACUUM: Schedule regular VACUUM ANALYZE for PostgreSQL
  7. Read Replicas: Route read queries to replicas for horizontal scaling

Data Integrity Tips

  1. Use transactions for multi-table operations
  2. Use advisory locks for concurrent balance updates
  3. Use SERIALIZABLE isolation for financial transactions
  4. Validate at DB level — don't rely solely on application validation
  5. Test migrations on a copy of production data before deploying

Common Issues (Masalah Umum)

Issue 1: N+1 Query Problem

-- BAD: N+1 queries (loop in application)
SELECT * FROM members;
-- For each member:
SELECT * FROM point_balances WHERE member_id = ?;

-- GOOD: Single query with JOIN
SELECT m.*, pb.current_balance, pb.lifetime_earned
FROM members m
LEFT JOIN point_balances pb ON pb.member_id = m.id
WHERE m.deleted_at IS NULL;

Issue 2: Missing FK Indexes

-- PostgreSQL does NOT auto-create indexes on FK columns!
-- Always add explicit indexes:
CREATE INDEX idx_transactions_member_id ON transactions(member_id);
CREATE INDEX idx_order_items_order_id ON order_items(order_id);

Issue 3: Slow COUNT on Large Tables

-- BAD: Full table scan
SELECT COUNT(*) FROM transactions;  -- millions of rows = slow

-- GOOD: Approximate count (instant)
SELECT reltuples::BIGINT FROM pg_class WHERE relname = 'transactions';

-- GOOD: Exact count with conditions (use partial index)
SELECT COUNT(*) FROM transactions WHERE created_at >= NOW() - INTERVAL '24 hours';

Issue 4: Deadlocks on Point Balance Updates

-- Use advisory locks for concurrent balance updates
-- Gunakan advisory lock untuk update saldo bersamaan
SELECT pg_advisory_xact_lock(hashtext('point_balance_' || member_id::TEXT));
UPDATE point_balances SET current_balance = current_balance - 100
WHERE member_id = 'uuid' AND current_balance >= 100;

References

Official Documentation

Tools

Learning Resources


Metadata

Version

  • Current Version: 1.0.0
  • Last Updated: 2026-03-09
  • Compatible Platforms: Claude, ChatGPT, Gemini

Related Skills

Tags

#database #schema #PostgreSQL #MySQL #MongoDB #SQL #NoSQL #migration #ERD #security #RLS #multi-tenancy #audit #loyalty #performance #indexing

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

Security

ai-workflow-red-team-lite

对 AI 自动化流程做轻量红队演练,聚焦误用路径、边界失败和数据泄露风险。;use for red-team, ai, workflow workflows;do not use for 输出可直接滥用的攻击脚本, 帮助破坏系统.

Archived SourceRecently Updated
Security

vendor-risk-assessment

Assess third-party vendor risk for AI and SaaS products. Evaluates security posture, data handling, compliance, financial stability, and operational resilience. Use when onboarding new vendors, conducting annual reviews, or building a vendor management program. Generates a scored risk report with mitigation recommendations. Built by AfrexAI.

Archived SourceRecently Updated
Security

security

No summary provided by upstream source.

Repository SourceNeeds Review