Compare commits

...

2 Commits

Author SHA1 Message Date
suphicakir 4103b90e4b docs: push akışı güncellendi (push kullanıcı tarafından yapılır)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 23:30:24 +03:00
suphicakir 5550c7c43d feat(backend): migration mekanizması, indeksler ve ortak model yardımcıları
- db/migrations.rs: schema_migrations ile sürümlü migration (v1: koleksiyonlar)
- db/indexes.rs: DATA_MODEL indeksleri + senkronizasyon indeksi, açılışta otomatik
- models/common.rs: UUID v7, ortak alanlar, hane kapsamlı filtre
- Birim testleri (4)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 23:25:55 +03:00
14 changed files with 369 additions and 37 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ Tam gereksinimler: `KAKEIBO_PROMPT.md` (yalnızca ilgili bölümü oku).
- Değişikliklerde dosyayı baştan yazma; yalnızca değişen kısmı düzenle.
- Yanıt formatı: ne yapıldı → nasıl test edilir → sıradaki adım.
- Her adım sonunda `docs/MEMORY.md`'ye 3–5 satır not ekle.
- Her faz sonunda commit + `git push origin main` (Claude yapar).
- Her adım sonunda Claude commit atar ve "push zamanı" diye haber verir; push'u kullanıcı kendi uygulamasıyla yapar (Claude `git push` çalıştırmaz).
- Bir hata 2 denemede çözülmezse dur, seçenek sun.
- Kullanıcı Rust'a yeni: kavramları ihtiyaç anında, kısa örnekle açıkla.
- Her yeni bağımlılık için kısa gerekçe ver.
+1
View File
@@ -959,6 +959,7 @@ dependencies = [
"tokio",
"tracing",
"tracing-subscriber",
"uuid",
]
[[package]]
+1
View File
@@ -13,3 +13,4 @@ thiserror = "2.0.21"
tokio = { version = "1.54.1", features = ["full"] }
tracing = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
uuid = { version = "1.29.2", features = ["v7"] }
-30
View File
@@ -1,30 +0,0 @@
//! MongoDB bağlantısı.
use std::time::Duration;
use mongodb::{Client, Database, bson::doc, options::ClientOptions};
/// URI'de veritabanı adı yoksa kullanılacak ad.
const DEFAULT_DB_NAME: &str = "kakeibo";
/// Bağlantıyı kurar ve bir `ping` ile sunucunun gerçekten erişilebilir olduğunu doğrular.
pub async fn connect(uri: &str) -> mongodb::error::Result<Database> {
let mut options = ClientOptions::parse(uri).await?;
options.app_name = Some("kakeibo".into());
// Sunucu yoksa 30 sn yerine 5 sn içinde hata ver.
options.server_selection_timeout = Some(Duration::from_secs(5));
let db_name = options
.default_database
.clone()
.unwrap_or_else(|| DEFAULT_DB_NAME.into());
let client = Client::with_options(options)?;
let db = client.database(&db_name);
ping(&db).await?;
Ok(db)
}
pub async fn ping(db: &Database) -> mongodb::error::Result<()> {
db.run_command(doc! { "ping": 1 }).await.map(|_| ())
}
+116
View File
@@ -0,0 +1,116 @@
//! İndeksler (DATA_MODEL.md §4). Açılışta her seferinde çağrılır;
//! MongoDB zaten var olan aynı indeksi yeniden oluşturmaz.
use mongodb::{
Database, IndexModel,
bson::{Document, doc},
error::Result,
options::IndexOptions,
};
use super::col;
pub async fn ensure(db: &Database) -> Result<()> {
for (collection, indexes) in definitions() {
db.collection::<Document>(collection)
.create_indexes(indexes)
.await?;
}
tracing::info!("İndeksler hazır");
Ok(())
}
fn idx(keys: Document) -> IndexModel {
IndexModel::builder().keys(keys).build()
}
fn idx_opts(keys: Document, options: IndexOptions) -> IndexModel {
IndexModel::builder().keys(keys).options(options).build()
}
fn unique() -> IndexOptions {
IndexOptions::builder().unique(true).build()
}
fn definitions() -> Vec<(&'static str, Vec<IndexModel>)> {
let hp = || idx(doc! { "household_id": 1, "period_id": 1 });
let mut defs = vec![
(col::USERS, vec![idx_opts(doc! { "email": 1 }, unique())]),
(col::INVITES, vec![idx_opts(doc! { "code": 1 }, unique())]),
(
col::PERIODS,
vec![
idx(doc! { "household_id": 1, "start_date": 1 }),
// Hanede en fazla bir açık dönem
idx_opts(
doc! { "household_id": 1 },
IndexOptions::builder()
.name("one_open_period_per_household".to_string())
.unique(true)
.partial_filter_expression(doc! { "status": "open" })
.build(),
),
],
),
(
col::CATEGORIES,
vec![idx(doc! { "household_id": 1, "sort_order": 1 })],
),
(
col::PAYMENT_METHODS,
vec![idx(doc! { "household_id": 1, "sort_order": 1 })],
),
(col::PLAN_ITEMS, vec![hp()]),
(col::BUDGETS, vec![hp()]),
(col::NOTES, vec![hp()]),
(
col::ENTRIES,
vec![
idx(doc! { "household_id": 1, "period_id": 1, "date": 1 }),
idx(doc! { "household_id": 1, "date": 1 }),
],
),
(
col::CARRYOVERS,
vec![idx(doc! { "household_id": 1, "to_period_id": 1 })],
),
(
col::ASSET_TRANSACTIONS,
vec![idx(doc! { "household_id": 1, "asset_id": 1, "date": 1 })],
),
(
col::EXCHANGE_RATES,
vec![idx(doc! { "code": 1, "household_id": 1, "rate_date": -1 })],
),
];
// Senkronizasyon (Faz 11): "şu tarihten sonra değişenler" sorgusu.
for &name in col::HOUSEHOLD_SCOPED {
let sync = idx(doc! { "household_id": 1, "updated_at": 1 });
match defs.iter_mut().find(|(c, _)| *c == name) {
Some((_, list)) => list.push(sync),
None => defs.push((name, vec![sync])),
}
}
defs
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_household_collection_has_sync_index() {
let defs = definitions();
for &name in col::HOUSEHOLD_SCOPED {
let (_, list) = defs.iter().find(|(c, _)| *c == name).unwrap();
assert!(
list.iter()
.any(|i| i.keys == doc! { "household_id": 1, "updated_at": 1 }),
"{name} koleksiyonunda senkronizasyon indeksi yok"
);
}
}
}
+83
View File
@@ -0,0 +1,83 @@
//! Basit şema migration mekanizması (DATA_MODEL.md §3.15).
//!
//! Her migration bir kez çalışır; `schema_migrations` koleksiyonuna
//! `{ _id: sürüm, name, applied_at }` olarak kaydedilir.
//! Yeni migration eklemek: `MIGRATIONS` listesine bir sonraki sürüm numarasıyla ekle.
use std::{future::Future, pin::Pin};
use mongodb::{
Database,
bson::{DateTime, Document, doc},
error::Result,
};
use super::col;
type MigrationFn =
for<'a> fn(&'a Database) -> Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>>;
struct Migration {
version: i32,
name: &'static str,
run: MigrationFn,
}
/// Sıralı migration listesi. Sürümler 1'den başlar ve atlamadan artar.
const MIGRATIONS: &[Migration] = &[Migration {
version: 1,
name: "initial",
run: |db| Box::pin(m001_initial(db)),
}];
/// Uygulanmamış migration'ları sırayla çalıştırır.
pub async fn run(db: &Database) -> Result<()> {
let coll = db.collection::<Document>(col::SCHEMA_MIGRATIONS);
for m in MIGRATIONS {
if coll.find_one(doc! { "_id": m.version }).await?.is_some() {
continue;
}
tracing::info!("Migration çalışıyor: v{} ({})", m.version, m.name);
(m.run)(db).await?;
coll.insert_one(doc! {
"_id": m.version,
"name": m.name,
"applied_at": DateTime::now(),
})
.await?;
}
Ok(())
}
/// v1: Tüm koleksiyonları açıkça oluşturur.
/// (Transaction içinde ilk kez yazılan koleksiyonlar sorun çıkarmasın diye önceden var olsunlar.)
async fn m001_initial(db: &Database) -> Result<()> {
let existing = db.list_collection_names().await?;
let all = col::HOUSEHOLD_SCOPED
.iter()
.chain(&[col::EXCHANGE_RATES, col::SCHEMA_MIGRATIONS]);
for name in all {
if !existing.iter().any(|e| e == name) {
db.create_collection(*name).await?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn versions_start_at_1_and_are_sequential() {
for (i, m) in MIGRATIONS.iter().enumerate() {
assert_eq!(
m.version,
i as i32 + 1,
"migration sürümleri ardışık olmalı"
);
}
}
}
+78
View File
@@ -0,0 +1,78 @@
//! MongoDB bağlantısı, koleksiyon adları, migration ve indeksler.
pub mod indexes;
pub mod migrations;
use std::time::Duration;
use mongodb::{Client, Database, bson::doc, options::ClientOptions};
/// URI'de veritabanı adı yoksa kullanılacak ad.
const DEFAULT_DB_NAME: &str = "kakeibo";
/// Bağlantıyı kurar ve bir `ping` ile sunucunun gerçekten erişilebilir olduğunu doğrular.
pub async fn connect(uri: &str) -> mongodb::error::Result<Database> {
let mut options = ClientOptions::parse(uri).await?;
options.app_name = Some("kakeibo".into());
// Sunucu yoksa 30 sn yerine 5 sn içinde hata ver.
options.server_selection_timeout = Some(Duration::from_secs(5));
let db_name = options
.default_database
.clone()
.unwrap_or_else(|| DEFAULT_DB_NAME.into());
let client = Client::with_options(options)?;
let db = client.database(&db_name);
ping(&db).await?;
Ok(db)
}
pub async fn ping(db: &Database) -> mongodb::error::Result<()> {
db.run_command(doc! { "ping": 1 }).await.map(|_| ())
}
/// Açılışta çalışır: bekleyen migration'lar, ardından indeksler.
/// İkisi de tekrar çalıştırılabilir (idempotent); zaten yapılmış olanı atlar.
pub async fn prepare(db: &Database) -> mongodb::error::Result<()> {
migrations::run(db).await?;
indexes::ensure(db).await?;
Ok(())
}
/// Koleksiyon adları (DATA_MODEL.md §3). Yazım hatasını derleyici yakalasın diye sabit.
#[allow(dead_code)] // Bazıları sonraki fazlarda kullanılacak
pub mod col {
pub const USERS: &str = "users";
pub const HOUSEHOLDS: &str = "households";
pub const INVITES: &str = "invites";
pub const PERIODS: &str = "periods";
pub const CATEGORIES: &str = "categories";
pub const PAYMENT_METHODS: &str = "payment_methods";
pub const PLAN_ITEMS: &str = "plan_items";
pub const BUDGETS: &str = "budgets";
pub const ENTRIES: &str = "entries";
pub const CARRYOVERS: &str = "carryovers";
pub const NOTES: &str = "notes";
pub const ASSETS: &str = "assets";
pub const ASSET_TRANSACTIONS: &str = "asset_transactions";
pub const EXCHANGE_RATES: &str = "exchange_rates";
pub const SCHEMA_MIGRATIONS: &str = "schema_migrations";
/// Hane verisi taşıyan koleksiyonlar (`household_id` alanı var).
pub const HOUSEHOLD_SCOPED: &[&str] = &[
USERS,
HOUSEHOLDS,
INVITES,
PERIODS,
CATEGORIES,
PAYMENT_METHODS,
PLAN_ITEMS,
BUDGETS,
ENTRIES,
CARRYOVERS,
NOTES,
ASSETS,
ASSET_TRANSACTIONS,
];
}
+6
View File
@@ -2,6 +2,7 @@ mod config;
mod db;
#[allow(dead_code)] // AppError, Faz 1 – Adım 3 itibarıyla route'larda kullanılacak
mod error;
mod models;
mod routes;
mod state;
@@ -44,6 +45,11 @@ async fn main() -> ExitCode {
}
};
if let Err(e) = db::prepare(&db).await {
tracing::error!("Veritabanı hazırlanamadı (migration/indeks): {e}");
return ExitCode::FAILURE;
}
let port = config.port;
let state = AppState {
db,
View File
+70
View File
@@ -0,0 +1,70 @@
//! Tüm koleksiyonlarda ortak alanlar için yardımcılar (DATA_MODEL.md §1.1).
//!
//! Her kayıt şu alanları taşır:
//! `_id` (UUID v7), `household_id`, `created_at`, `updated_at`, `deleted_at`, `version`
#![allow(dead_code)] // Faz 1 – Adım 3 itibarıyla kullanılacak
use mongodb::bson::{DateTime, Document, Uuid, doc};
/// Yeni UUID v7 üretir. İlk 48 bit zaman damgası olduğu için
/// sonradan üretilen kimlik her zaman daha büyüktür (zamana göre sıralanabilir).
pub fn new_id() -> Uuid {
Uuid::from_bytes(uuid::Uuid::now_v7().into_bytes())
}
/// Şu anki zaman (UTC, BSON DateTime).
pub fn now() -> DateTime {
DateTime::now()
}
/// Yeni kayıt için ortak alanlar. Modele özgü alanlarla birleştirilir:
/// `let mut d = new_meta(hid); d.insert("name", "Market");`
pub fn new_meta(household_id: Uuid) -> Document {
let ts = now();
doc! {
"_id": new_id(),
"household_id": household_id,
"created_at": ts,
"updated_at": ts,
"deleted_at": null,
"version": 1_i32,
}
}
/// Hane kapsamlı, silinmemiş kayıtlar için temel filtre.
/// Tüm okuma sorguları bununla başlar → başka hanenin verisi asla dönmez.
pub fn scope(household_id: Uuid) -> Document {
doc! { "household_id": household_id, "deleted_at": null }
}
/// Güncellemelerde eklenecek ortak kısım: `updated_at` yenilenir, `version` +1.
pub fn touch() -> Document {
doc! {
"$set": { "updated_at": now() },
"$inc": { "version": 1_i32 },
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_id_is_v7_and_sortable() {
let a = uuid::Uuid::from_bytes(new_id().bytes());
std::thread::sleep(std::time::Duration::from_millis(2));
let b = uuid::Uuid::from_bytes(new_id().bytes());
assert_eq!(a.get_version_num(), 7);
assert!(b > a, "sonra üretilen UUID daha büyük olmalı");
}
#[test]
fn scope_always_filters_household_and_deleted() {
let hid = new_id();
let f = scope(hid);
assert_eq!(f.get("household_id"), Some(&hid.into()));
assert!(f.contains_key("deleted_at"));
}
}
+4
View File
@@ -0,0 +1,4 @@
//! Veritabanı modelleri. Her koleksiyonun yapısı, onu kullanan adımda eklenir.
//! Şema: `docs/DATA_MODEL.md`
pub mod common;
+2 -1
View File
@@ -60,7 +60,7 @@ Renkler ve ikonlar frontend tasarım token'larında.
### 3.2 `households`
```js
{ _id, name, owner_id,
{ _id, household_id /* = _id */, name, owner_id,
settings: {
currency: "TRY",
budget_split_pct: { needs: 50, wants: 30, culture: 10, unexpected: 10 },
@@ -68,6 +68,7 @@ Renkler ve ikonlar frontend tasarım token'larında.
},
created_at, updated_at, deleted_at, version }
```
- `household_id` = `_id` (ortak `scope()` filtresi tüm koleksiyonlarda aynı çalışsın diye).
- Prompttaki `settings` koleksiyonu yerine **haneye gömülü** (her hanede tek kayıt olduğu için ayrı koleksiyona gerek yok).
### 3.3 `invites`
+5 -3
View File
@@ -1,16 +1,17 @@
# 🧠 Proje Hafızası
## ▶️ Sıradaki Adım
**Faz 1 – Adım 2:** Ortak model alanları (UUID v7, zaman damgaları, version), `schema_migrations` ile migration mekanizması, `DATA_MODEL.md` §4 indeksleri (açılışta otomatik).
**Faz 1 – Adım 3:** Kimlik doğrulama: `POST /api/auth/register` (kullanıcı + hane + örnek kategori/ödeme türü, transaction), `login`, `logout`, `GET /api/auth/me`; argon2, JWT httpOnly cookie, giriş rate limit.
## 📌 Alınan Kararlar
- Sürüm kontrolü: Gitea (`https://gitea.ta1sph.info/suphicakir/kakeibo.git`), dal `main`.
- Her faz sonunda Claude commit + push yapar (kullanıcı onayı verildi).
- Her adım sonunda Claude commit atar ve haber verir; push'u kullanıcı kendi uygulamasıyla yapar (Claude `git push` çalıştırmaz).
- Node v26 (LTS) sistemde mevcut; ayrı kurulum gerekmedi.
- Git kimlik bilgisi: libsecret (KWallet) credential helper + Gitea token.
- Yerel MongoDB host portu 27020 (27017 sistem mongod, 27018/27019 başka projeler).
- Frontend lint aracı: oxlint (Vite şablonu varsayılanı), ESLint yerine.
- Veri modeli v1 onaylandı (2026-11-11): ölçekli tam sayı (`_e6`), tarih `YYYY-MM-DD` metin, dönem bitişi hariç, kullanıcı = tek hane, ayarlar haneye gömülü, haftalık bütçe hesaplanır.
- Model yaklaşımı: `#[serde(flatten)]` yerine ortak alanlar `models/common.rs` yardımcılarıyla (`new_meta`, `scope`, `touch`). Modeller ilgili adımda eklenir.
- `households.household_id = _id` (scope filtresi her yerde aynı).
- Sürümler: Rust 1.99.1 (edition 2024), Axum 0.8, React 19, Vite 8, TypeScript 7.
## ❓ Açık Konular
@@ -30,3 +31,4 @@
### 2026-11-11 — Faz 1
- **Adım 1:** `config.rs` (env + doğrulama, JWT_SECRET ≥ 32), `db.rs` (5 sn timeout + ping), `error.rs` (`AppError` → `{error:{code,message}}` Türkçe; 5xx detayı yalnızca logda), `state.rs`, `routes/` (health: DB yoksa 503). Açılışta DB/ayar hatasında anlaşılır log + çıkış kodu 1. Elle test: 4 senaryo geçti.
- **Adım 2:** `db/` modülü: `col` sabitleri, `migrations.rs` (`schema_migrations`, v1 = koleksiyonları oluştur), `indexes.rs` (DATA_MODEL §4 + tüm hane koleksiyonlarına `(household_id, updated_at)`). `models/common.rs`: UUID v7, `new_meta`, `scope`, `touch`. Açılışta `db::prepare`. 4 birim testi; idempotency ve tek açık dönem indeksi elle doğrulandı. `uuid` (v7) eklendi.
+2 -2
View File
@@ -26,7 +26,7 @@
## Faz 1 Adımları
- ✅ 1. `config.rs` + `db.rs` (MongoDB bağlantısı), Türkçe hata yapısı, health check'e DB durumu
- 🔄 2. Modeller, migration mekanizması ve indeksler
- ⏳ 3. Kimlik doğrulama: kayıt (+ hane + örnek kategori/ödeme türü), giriş, çıkış, `me`; argon2 + JWT cookie + rate limit
- ✅ 2. Modeller, migration mekanizması ve indeksler
- 🔄 3. Kimlik doğrulama: kayıt (+ hane + örnek kategori/ödeme türü), giriş, çıkış, `me`; argon2 + JWT cookie + rate limit
- ⏳ 4. Hane izolasyonu (middleware), üye davet kodu
- ⏳ 5. Kategori ve ödeme türü uç noktaları + hane izolasyonu testleri, `api.http`