#!/bin/bash # ============================================ # Chat Switchboard - Schema Validation (v0.16) # ============================================ # Verifies the database schema is correct after # migration. Checks expected tables, key columns, # indexes, triggers, and confirms dropped artifacts. # Exits non-zero on any failure. # # Required env vars: # PGHOST, PGPORT, PGUSER, PGPASSWORD, PGDATABASE # # Usage: # scripts/db-validate.sh # ============================================ set -euo pipefail echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "Schema validation: ${PGDATABASE}" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" ERRORS=0 CHECKS=0 # ── Helper: check table exists ─────────────── check_table() { local table="$1" CHECKS=$((CHECKS + 1)) local exists exists=$(psql -tAc "SELECT 1 FROM information_schema.tables WHERE table_schema='public' AND table_name='${table}';") if [[ "${exists}" == "1" ]]; then echo " ✓ table: ${table}" else echo " ✗ MISSING table: ${table}" ERRORS=$((ERRORS + 1)) fi } # ── Helper: check table does NOT exist ─────── check_table_absent() { local table="$1" CHECKS=$((CHECKS + 1)) local exists exists=$(psql -tAc "SELECT 1 FROM information_schema.tables WHERE table_schema='public' AND table_name='${table}';") if [[ "${exists}" == "1" ]]; then echo " ✗ UNEXPECTED table: ${table} (should have been dropped)" ERRORS=$((ERRORS + 1)) else echo " ✓ absent: ${table}" fi } # ── Helper: check column exists ────────────── check_column() { local table="$1" local column="$2" CHECKS=$((CHECKS + 1)) local exists exists=$(psql -tAc "SELECT 1 FROM information_schema.columns WHERE table_schema='public' AND table_name='${table}' AND column_name='${column}';") if [[ "${exists}" == "1" ]]; then echo " ✓ column: ${table}.${column}" else echo " ✗ MISSING column: ${table}.${column}" ERRORS=$((ERRORS + 1)) fi } # ── Helper: check column does NOT exist ────── check_column_absent() { local table="$1" local column="$2" CHECKS=$((CHECKS + 1)) local exists exists=$(psql -tAc "SELECT 1 FROM information_schema.columns WHERE table_schema='public' AND table_name='${table}' AND column_name='${column}';") if [[ "${exists}" == "1" ]]; then echo " ✗ UNEXPECTED column: ${table}.${column} (should have been dropped)" ERRORS=$((ERRORS + 1)) else echo " ✓ absent: ${table}.${column}" fi } # ── Helper: check column type ──────────────── check_column_type() { local table="$1" local column="$2" local expected_type="$3" CHECKS=$((CHECKS + 1)) local actual_type actual_type=$(psql -tAc "SELECT data_type FROM information_schema.columns WHERE table_schema='public' AND table_name='${table}' AND column_name='${column}';") if [[ "${actual_type}" == "${expected_type}" ]]; then echo " ✓ type: ${table}.${column} = ${expected_type}" else echo " ✗ WRONG type: ${table}.${column} = ${actual_type} (expected ${expected_type})" ERRORS=$((ERRORS + 1)) fi } # ── Helper: check index exists ─────────────── check_index() { local index="$1" CHECKS=$((CHECKS + 1)) local exists exists=$(psql -tAc "SELECT 1 FROM pg_indexes WHERE schemaname='public' AND indexname='${index}';") if [[ "${exists}" == "1" ]]; then echo " ✓ index: ${index}" else echo " ✗ MISSING index: ${index}" ERRORS=$((ERRORS + 1)) fi } # ── Helper: check trigger exists ───────────── check_trigger() { local trigger="$1" local table="$2" CHECKS=$((CHECKS + 1)) local exists exists=$(psql -tAc "SELECT 1 FROM pg_trigger t JOIN pg_class c ON t.tgrelid = c.oid WHERE t.tgname='${trigger}' AND c.relname='${table}' AND NOT t.tgisinternal;") if [[ "${exists}" == "1" ]]; then echo " ✓ trigger: ${trigger} on ${table}" else echo " ✗ MISSING trigger: ${trigger} on ${table}" ERRORS=$((ERRORS + 1)) fi } # ── Helper: check extension ────────────────── check_extension() { local ext="$1" CHECKS=$((CHECKS + 1)) local exists exists=$(psql -tAc "SELECT 1 FROM pg_extension WHERE extname='${ext}';") if [[ "${exists}" == "1" ]]; then echo " ✓ extension: ${ext}" else echo " ✗ MISSING extension: ${ext}" ERRORS=$((ERRORS + 1)) fi } # ═══════════════════════════════════════════ # CHECKS # ═══════════════════════════════════════════ # ── Extensions ──────────────────────────── echo "" echo "PostgreSQL Extensions:" check_extension "pgcrypto" check_extension "vector" # ── Migration tracking ─────────────────── echo "" echo "Migration tracking:" check_table "schema_migrations" MIGRATION_COUNT=$(psql -tAc "SELECT COUNT(*) FROM schema_migrations;" 2>/dev/null || echo "0") echo " ✓ ${MIGRATION_COUNT} migrations applied" LATEST=$(psql -tAc "SELECT version FROM schema_migrations ORDER BY version DESC LIMIT 1;" 2>/dev/null || echo "none") echo " ✓ latest: ${LATEST}" # ── Core tables ────────────────────────── echo "" echo "Core tables:" check_table "users" check_table "refresh_tokens" check_table "teams" check_table "team_members" check_table "groups" check_table "group_members" check_table "provider_configs" check_table "model_catalog" check_table "personas" check_table "persona_grants" check_table "resource_grants" check_table "platform_policies" check_table "global_settings" check_table "user_model_settings" check_table "channels" check_table "messages" check_table "channel_members" check_table "channel_models" check_table "channel_cursors" check_table "folders" check_table "notes" check_table "audit_log" check_table "usage_log" check_table "model_pricing" check_table "extensions" check_table "extension_user_settings" check_table "attachments" check_table "knowledge_bases" check_table "kb_documents" check_table "kb_chunks" check_table "channel_knowledge_bases" # ── Dropped tables (v0.16.0 cleanup) ──── echo "" echo "Dropped tables (v0.16.0 cleanup):" check_table_absent "projects" check_table_absent "project_channels" # ── Users (vault) ──────────────────────── echo "" echo "Users (vault):" check_column "users" "encrypted_uek" check_column "users" "uek_salt" check_column "users" "uek_nonce" check_column "users" "vault_set" # ── Provider Configs (encryption) ──────── echo "" echo "Provider Configs (encryption):" check_column "provider_configs" "api_key_enc" check_column_type "provider_configs" "api_key_enc" "bytea" check_column "provider_configs" "key_nonce" check_column_type "provider_configs" "key_nonce" "bytea" check_column "provider_configs" "key_scope" check_column_absent "provider_configs" "api_key_plain" # ── Model Catalog ──────────────────────── echo "" echo "Model Catalog:" check_column "model_catalog" "model_type" check_column "model_catalog" "visibility" check_column "model_catalog" "capabilities" # ── Groups (v0.16.0) ──────────────────── echo "" echo "Groups (v0.16.0):" check_column "groups" "name" check_column "groups" "scope" check_column "groups" "team_id" check_column "groups" "created_by" check_column "group_members" "group_id" check_column "group_members" "user_id" check_column "group_members" "added_by" check_index "idx_group_members_user" check_index "idx_group_members_group" check_index "idx_groups_name_scope" # ── Resource Grants (v0.16.0) ─────────── echo "" echo "Resource Grants (v0.16.0):" check_column "resource_grants" "resource_type" check_column "resource_grants" "resource_id" check_column "resource_grants" "grant_scope" check_column "resource_grants" "granted_groups" check_column "resource_grants" "created_by" check_index "idx_resource_grants_resource" check_index "idx_resource_grants_groups" # ── Personas ───────────────────────────── echo "" echo "Personas:" check_column "personas" "scope" check_column "personas" "owner_id" check_column "personas" "base_model_id" check_column "personas" "thinking_budget" check_column "personas" "is_shared" # ── Channels & Messages ────────────────── echo "" echo "Channels & Messages:" check_column "channels" "settings" check_column "channels" "folder_id" check_column "channels" "team_id" check_column "messages" "parent_id" check_column "messages" "sibling_index" check_column "messages" "tool_calls" check_column "messages" "metadata" # ── Notes ──────────────────────────────── echo "" echo "Notes:" check_column "notes" "search_vector" check_column "notes" "embedding" check_column "notes" "team_id" check_index "idx_notes_search" # ── Extensions ─────────────────────────── echo "" echo "Extensions:" check_column "extensions" "ext_id" check_column "extensions" "tier" check_column "extensions" "manifest" check_column "extensions" "is_system" check_column "extensions" "scope" # ── Attachments ────────────────────────── echo "" echo "Attachments:" check_column "attachments" "channel_id" check_column "attachments" "storage_key" check_column "attachments" "extracted_text" check_index "idx_attachments_channel" check_index "idx_attachments_orphan" # ── Knowledge Bases ────────────────────── echo "" echo "Knowledge Bases:" check_column "knowledge_bases" "scope" check_column "knowledge_bases" "embedding_config" check_column "knowledge_bases" "document_count" check_table "kb_documents" check_table "kb_chunks" check_column "kb_chunks" "embedding" check_table "channel_knowledge_bases" # ── Usage & Pricing ────────────────────── echo "" echo "Usage & Pricing:" check_column "usage_log" "provider_scope" check_column "usage_log" "role" check_column "usage_log" "cache_creation_tokens" check_column "model_pricing" "cache_create_per_m" # ── Key triggers ───────────────────────── echo "" echo "Key triggers:" check_trigger "users_updated_at" "users" check_trigger "channels_updated_at" "channels" check_trigger "personas_updated_at" "personas" check_trigger "groups_updated_at" "groups" check_trigger "resource_grants_updated_at" "resource_grants" check_trigger "notes_search_update" "notes" # ── CI Username Indexes ────────────────── echo "" echo "CI Username Indexes:" check_index "users_username_ci" check_index "users_email_ci" # ═══════════════════════════════════════════ # ADD NEW MIGRATION CHECKS ABOVE THIS LINE # ═══════════════════════════════════════════ # ── Summary ────────────────────────────────── echo "" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" if [[ ${ERRORS} -eq 0 ]]; then echo "✅ Schema validation passed: ${CHECKS}/${CHECKS} checks OK" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" exit 0 else echo "❌ Schema validation FAILED: ${ERRORS}/${CHECKS} checks failed" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" exit 1 fi