PortfolioBlog’u Supabase’e taşırken değiştirdiğim şey, uygulamanın veri sorumluluğu olmadı. Tarayıcı hâlâ benim API’me istek gönderiyor; kurallar Business katmanında, sorgular Data katmanında duruyor. Değişen parça PostgreSQL’in çalıştığı yer. Bu ayrımı korumak, bağlantı sorununu düzeltirken bütün uygulamayı başka bir SDK’nın etrafında yeniden yazma ihtiyacını ortadan kaldırdı.
Burada anlattığım akış .NET 10, EF Core 10 ve Npgsql 10 ile bu sitede kullandığım entegrasyon. Supabase Auth veya tarayıcıdan Data API kullanımı bu akışa dahil değil. Görsellerin taşınmasını ayrı Storage yazısında ele alıyorum; bir veritabanı bağlantısına dosya depolama görevini de yüklemiyorum.
Bağlantı yöntemini uygulamanın ömrüne göre seçmek
Render’daki API uzun süre çalışan bir süreç. IPv4 erişimi için Supabase panelindeki Connect bölümünden Session pooler bağlantısını aldım. Bu uçta port 5432; kullanıcı adı da doğrudan bağlantıdaki postgres yerine postgres.PROJECT_REF biçiminde. Host’u bölge adından tahmin etmek yerine paneldeki değeri aynen almak gerekiyor. Yanlış kullanıcı adıyla doğru sunucuya ulaşmak, bağlantının çalıştığı anlamına gelmiyor.
Transaction pooler genellikle kısa ömürlü/serverless işler için değerlendirilir ve 6543 portunu kullanır. Uygulamam şema/search_path ayarları ve oturum davranışları üzerine kurulu olduğu için bu entegrasyonda Session seçtim; kod 6543’ü açıkça reddediyor. Bu, diğer yöntemin her uygulamada yanlış olduğu iddiası değil, bu uygulamanın bağlantı sözleşmesi.
Paketler ve sunucu ayarları
dotnet add package Npgsql --version 10.0.3
dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL --version 10.0.3
dotnet tool restore
Paket komutları yeni bir backend için örnek. Bu depoda sürümler Directory.Packages.props ile merkezi yönetiliyor; Data projesine ikinci bir bağımsız sürüm listesi eklemiyorum. Microsoft.EntityFrameworkCore.Design ve depodaki dotnet-ef aracı scaffold işlemini destekliyor. Supabase PostgreSQL kullanmak için ayrıca Supabase istemci SDK’sı şart değil; standart PostgreSQL sürücüsü yeterli.
ConnectionStrings__Portfolio="Host=YOUR_SESSION_POOLER_HOST;Port=5432;Database=postgres;Username=postgres.YOUR_PROJECT_REF;Password=YOUR_PASSWORD"
Database__Schema=public
# Optional: override the bundled provider CA certificate.
# Database__RootCertificate=/app/config/provider-ca.crt
ASP.NET Core çift alt çizgiyi bölüm ayırıcısı olarak okur: ConnectionStrings__Portfolio, GetConnectionString("Portfolio") ile erişilen değerdir. Veritabanı parolası, Storage sunucu anahtarından ve yönetici giriş parolasından ayrıdır. Örnekteki değerler yer tutucudur. Gerçek değerler sunucu ayarlarından okunur; Angular environment dosyasına veya tarayıcıya giden JSON’a eklenmez.
Şifreli bağlantı ile doğrulanmış bağlantıyı ayırmak
Üretimde SslMode.VerifyFull kullanıyorum. Bu hem bağlantıyı şifreler hem sertifika zincirini ve bağlanılan sunucunun adını doğrular. Sadece sertifika hatası kaybolsun diye kontrolü kapatmak yerine, sağlayıcının public kök CA dosyasını config/supabase-ca.crt olarak dağıtıyorum. Public sertifika gizli anahtar değildir; API publish çıktısına da kopyalanır. Sağlayıcının CA’sı değişirse yeni dosya ve ayar birlikte güncellenir.
Aşağıdaki bağımsız konsol örneği Npgsql biçiminde PORTFOLIO_PG_CONNECTION ve sertifikanın tam yolu olan PORTFOLIO_PG_CA değişkenlerini ister. Asıl API hem PostgreSQL URI’sini hem Npgsql biçimini kabul ediyor; küçük örnek o ayrıştırıcıyı tekrar üretmiyor. Var olan post tablosundan en fazla beş yayındaki kayıt okur, tablo veya veri oluşturmaz.
using Npgsql;
var raw = Environment.GetEnvironmentVariable("PORTFOLIO_PG_CONNECTION")
?? throw new InvalidOperationException("Set PORTFOLIO_PG_CONNECTION to an Npgsql connection string.");
var certificate = Environment.GetEnvironmentVariable("PORTFOLIO_PG_CA")
?? throw new InvalidOperationException("Set PORTFOLIO_PG_CA to the Supabase root certificate path.");
var options = new NpgsqlConnectionStringBuilder(raw)
{
SslMode = SslMode.VerifyFull,
RootCertificate = Path.GetFullPath(certificate),
SearchPath = "public",
MaxPoolSize = 10,
Timeout = 15,
CommandTimeout = 30
};
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(45));
await using var dataSource = NpgsqlDataSource.Create(options.ConnectionString);
await using var command = dataSource.CreateCommand("""
SELECT slug, title_tr
FROM post
WHERE published AND NOT deleted
ORDER BY source_created_at DESC, id
LIMIT @limit
""");
command.Parameters.AddWithValue("limit", 5);
await using var reader = await command.ExecuteReaderAsync(timeout.Token);
while (await reader.ReadAsync(timeout.Token))
Console.WriteLine($"{reader.GetString(0)} | {reader.GetString(1)}");
DataSource bir bağlantı havuzunu temsil eder. Konsol burada tek iş yaptığı için sonunda dispose ediliyor; uzun yaşayan API’de ortak DataSource uygulama ömrü boyunca kullanılır. Her HTTP isteğinde yeni havuz oluşturmak yerine bağlantı/kapsam ömrünü yönetmek gerekir. MaxPoolSize=10, aynı havuzun sınırıdır; uygulama kopyası ve bağımsız havuz sayısı arttığında toplam bağlantı bütçesi de artar.
DB-first: tablonun modeli ayrı, HTTP’nin modeli ayrı
Şemayı SQL betikleri belirliyor. Data/Models ve Data/Context bu şemadan üretiliyor; elle eklediğim sorgu filtresi ve fonksiyon eşlemeleri partial dosyada. Core/DTOs ise HTTP sözleşmesi. Örneğin veritabanında title_tr/title_en, category_id ve source_created_at kolonları var; API, başlığı { tr, en } yapısında ve kategoriyi çevrilmiş etiketleriyle döndürüyor. DTO’nun şekli tablo tasarımını mecburen belirlemiyor.
Veri modeli altı tablodan oluşuyor: profile, experience, education, project, category ve post. Profile tek profil kaydını, bütün grafiğin revizyonunu ve güncelleme zamanını taşır. Yazı ve projeler profil GUID’sine, yazılar ayrıca kategori GUID’sine foreign key ile bağlıdır. API entity ve projection kullanır; yedekleme araçları doğrudan aynı tabloları okur. Ek JSON tablosu veya export view tutulmaz.
node scripts/scaffold-models.mjs --check komutu, modelin gerçek şemayla uyuştuğunu doğruluyor. Supabase’in auth/storage şemalarını uygulama entity’lerine karıştırmamak için tablolar açıkça seçili. Scaffold çıktısına elle iş kuralı yazarsam bir sonraki üretimde kaybolur; bu yüzden repository, mapping ve partial ayrımı pratik bir ihtiyaç.
Yayımlanan kayıtları sunucuda seçmek
var page = await context.Posts.AsNoTracking()
.Where(post => post.Published && !post.Deleted)
.OrderByDescending(post => post.SourceCreatedAt)
.ThenBy(post => post.Id)
.Skip((pageNumber - 1) * 5)
.Take(5)
.Select(post => new { post.Slug, Title = post.TitleTr })
.ToArrayAsync(cancellationToken);
Örnekteki pageNumber önceden doğrulanmış pozitif bir sayıdır. Filtre, sıralama, beşli sayfalama ve projection SQL’e çevrilir; tüm yazıları belleğe alıp sonradan ayıklamıyorum. Aynı kaynak tarihindeki kayıtları id ile sıralamak sayfalar arasında tutarlı sıra sağlar. Adminin taslak görmesi ile genel kullanıcının yayımlanan kayıt görmesi ayrı sorgu/policy kararlarıdır.
Konsol örneğindeki @limit bir SQL parametresi. Kullanıcıdan gelen metni sorguya ekleyerek SQL oluşturmuyorum. Türkçe aramada ise ayrıca portfolio_contains fonksiyonu var: deterministik Türkçe collation ile küçük harfe çevirip literal alt metin arıyor. Yüzde ve alt çizgi arayan kullanıcı istemeden LIKE jokeri göndermiş olmuyor. Bu çözümü ölçülmüş bir full-text arama motoru diye sunmuyorum.
RLS, veritabanı rolü ve API auth aynı kontrol değil
-- Excerpt from the application's SQL; not a new migration to run blindly.
ALTER TABLE post ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON TABLE post FROM PUBLIC, anon, authenticated;
ALTER VIEW site_document SET (security_invoker = true);
-- Inspect the actual grants:
SELECT has_table_privilege('anon', 'public.post', 'SELECT') AS anon_can_read,
has_table_privilege('authenticated', 'public.post', 'SELECT') AS user_can_read;
İçerik, SQL geçmişi ve Data Protection tablolarında RLS açık; anon/authenticated rolleri tablo veya export view okuyamıyor. API ise tablo sahibi rolüyle bağlanıyor; tablo sahibinin normalde RLS’yi aşabildiğini hesaba katıyorum. Yani public endpoint’in Published filtresini ve admin yetkilendirmesini veritabanında RLS var diye kaldırmıyorum. Daha dar bir uygulama rolüne geçiş ayrı bir yetki tasarımı ve doğrulama işi.
Kaydetme, yarışan istekler ve tekrar deneme
Admin belgesi okuduğu revizyonla kaydedilir. SiteService eski revizyonu 409 Conflict ile reddeder; AtomicWrite değişiklikleri Serializable transaction içinde toplar. Repository revizyonu koşullu artırır, sonra değişen ilişkileri yazar. 40001/40P01 gibi yeniden denenebilir durumlarda işlem stratejisi bütün veritabanı işini tekrar çalıştırır. Bu bloğun içine e-posta veya Storage yüklemesi koymuyorum; aynı dış etki ikinci kez oluşabilir.
Şema kurulumu uygulama açılışından ayrıdır: npm run db:setup, schema.sql dosyasını transaction ve advisory lock içinde uygular. SQL tekrar çalıştırılabilir; mevcut profil bulunduğunda seed atlanır. Eski düzen upgrade.sql ile dönüştürülür; ayrı migration geçmişi tablosu tutulmaz. API yalnız kurulu şemayı okur. Aktarımda kimlikleri, yayın seçimlerini, iki dildeki içeriği ve kaynak tarihlerini ayrıca karşılaştırırım.
Çalıştığını nereden anlıyorum?
npm run db:audit
node scripts/scaffold-models.mjs --check
node scripts/verify-production.mjs
npm run db:backup
- Bağlantı reddediliyorsa önce Session host/5432, postgres.PROJECT_REF kullanıcı adı ve gerçek veritabanı parolası kontrol edilir; Storage anahtarı SQL parolası değildir.
- Sertifika hatasında CA dosyasının publish çıktısındaki yolu ve sunucu adı incelenir. VerifyFull kapatılmaz.
- Kaydetme 409 dönüyorsa güncel revizyon okunur; kullanıcıya başarılıymış gibi eski belge gösterilmez.
- Testler ayrı geçici şema kullanır. Üretim içeriğini test verisiyle doldurmak veya sıfırlamak bu doğrulamanın parçası değildir.
İçerik yedeği görsellerin tamamı değildir; db:backup içerik, sunucu güvenlik ayarları ve Storage resimlerini ayrı dosyalara alır. Cookie/CSRF anahtar halkası Security__DataProtectionKeys ayarından yüklenir; veritabanı tablosu veya kalıcı disk gerekmez. Aynı anahtarlar yeniden başlatmada korunur. Yenilerken generate-keyring.mjs yeni anahtar ekler ve mevcut oturumlar çözülebilsin diye eskilerini tutar. Anahtar değeri tarayıcıya gönderilmez.