Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions collector/supabase_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,10 @@
# key_hash — kimlik kanıtının kendisi; cihaz kendi anahtarını seçemez.
# device_name — dashboard'un alanı (db/rls.sql: grant update (device_name)).
#
# logging_enabled M6'da listeye EKLENDİ (bkz. md/memory/decisions.md → "Komutlar
# (M6)"): pause/resume durumunun sunucu kopyasını, agent komutu ack'leyince
# collector yazar. Değer istek gövdesinden gelmez — `commands` satırındaki
# `type` alanından türetilir; agent'ın gönderdiği tek şey komut id'sidir.
# logging_enabled M6'da listeye EKLENDİ: pause/resume durumunun sunucu
# kopyasını, agent komutu ack'leyince collector yazar. Değer istek gövdesinden
# gelmez — `commands` satırındaki `type` alanından türetilir; agent'ın
# gönderdiği tek şey komut id'sidir.
#
# Buraya sütun eklemek bilinçli bir güvenlik kararıdır.
DEVICE_WRITABLE_COLUMNS = frozenset(
Expand Down
2 changes: 1 addition & 1 deletion db/migrations/0003_drop_pending_delete.sql
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
-- çalıştırılırsa canlı collector olmayan bir sütunu istemeye devam eder ve
-- cihaz kimliği doğrulanamaz (device key ile gelen her istek hata alır).
--
-- TARİH: 2026-08-26 — M6 (Komutlar). Karar: md/memory/decisions.md.
-- TARİH: 2026-08-26 — M6 (Komutlar).
-- =============================================================================

alter table public.devices drop column if exists pending_delete;
Expand Down
102 changes: 102 additions & 0 deletions db/retention.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
-- =============================================================================
-- TraceBox — db/retention.sql
--
-- Her gece çalışan otomatik silme işi (retention). Hesabın `retention_days`
-- politikasından daha eski satırları dört veri tablosundan siler:
-- metrics, logs, crash_snapshots ve commands.
--
-- Süre hesabı satır başına değil HESAP başına yapılır: her satır kendi
-- sahibinin accounts.retention_days değerine göre ölçülür (varsayılan 10 gün).
--
-- CLAUDE.md §5 — silme ölçütü sunucunun yazdığı zaman damgasıdır.
-- =============================================================================


-- -----------------------------------------------------------------------------
-- 1) pg_cron
--
-- Zamanlanmış işleri veritabanının içinde çalıştıran uzantı. `if not exists`
-- sayesinde uzantı zaten kuruluysa bu satır sessizce geçer, hata vermez.
-- Kurulumla birlikte `cron` şeması gelir; işler `cron.job` tablosunda durur.
-- -----------------------------------------------------------------------------
create extension if not exists pg_cron;


-- -----------------------------------------------------------------------------
-- 2) Zamanlanmış iş
--
-- cron.schedule(jobname, schedule, command) üç parça alır:
--
-- jobname 'tracebox_retention' — işin adı. AYNI adla ikinci kez çalıştırılırsa
-- pg_cron yeni bir iş EKLEMEZ, mevcut işin üzerine yazar (upsert).
-- Dosyanın tekrar tekrar çalıştırılması kopya iş üretmez.
-- schedule '0 0 * * *' — beş alanlı cron ifadesi: dakika 0, saat 0, her gün,
-- her ay, haftanın her günü. Yani her gece 00:00. Saat dilimi
-- veritabanının saatidir (Supabase'de UTC).
-- command $$ ... $$ arasındaki SQL. İş her tetiklendiğinde bu blok çalışır.
-- Dolar işaretli sınırlayıcı ($$) kullanılır; blok içindeki tek
-- tırnakların kaçırılması (escape) gerekmesin diye.
--
-- Blok içindeki dört DELETE tek bir transaction'da çalışır: dördü birden
-- başarılı olur ya da hiçbiri uygulanmaz.
--
-- İş, onu zamanlayan rolün yetkileriyle koşar; RLS politikaları bu role
-- uygulanmaz, dolayısıyla silme tüm hesapların satırlarını görebilir.
-- -----------------------------------------------------------------------------
select cron.schedule('tracebox_retention', '0 0 * * *', $$

-- --- metrics -------------------------------------------------------------
-- `using accounts` join'i, satırın sahibinin retention_days değerine ulaşmak
-- için. Ölçüt received_at: satırın SUNUCUYA vardığı an. (measured_at'i agent
-- yazar; o sütun zaman çizelgesi içindir ve silmeye ölçüt olamaz.)
-- (a.retention_days || ' days')::interval → örn. 10 → '10 days' → interval.
delete from public.metrics m
using public.accounts a
where m.account_id = a.id
and m.received_at < now() - (a.retention_days || ' days')::interval;

-- --- logs ----------------------------------------------------------------
delete from public.logs l
using public.accounts a
where l.account_id = a.id
and l.received_at < now() - (a.retention_days || ' days')::interval;

-- --- crash_snapshots -----------------------------------------------------
delete from public.crash_snapshots c
using public.accounts a
where c.account_id = a.id
and c.received_at < now() - (a.retention_days || ' days')::interval;

-- --- commands ------------------------------------------------------------
-- İki farkla:
--
-- 1) Ölçüt created_at. Bu tabloda received_at sütunu yok; satırı agent
-- değil dashboard yazar ve created_at zaten `default now()` ile
-- veritabanı saatinden dolar. db/rls.sql, INSERT yetkisini kolon
-- düzeyinde (device_id, account_id, type) daraltır — created_at o
-- listede olmadığı için istemci bu sütuna değer veremez.
--
-- 2) status = 'applied' süzgeci. Yalnızca UYGULANMIŞ komutlar silinir.
-- 'pending' satır henüz teslim edilmemiş bir emirdir; yaşı ne olursa
-- olsun burada silinmez. (Çevrimdışı bir cihaz için bekleyen komut
-- cihaz geri dönene kadar durur; cihaz hiç dönmezse satır, devices
-- kaydının silinmesiyle CASCADE üzerinden gider.)
delete from public.commands c
using public.accounts a
where c.account_id = a.id
and c.status = 'applied'
and c.created_at < now() - (a.retention_days || ' days')::interval;

$$);


-- =============================================================================
-- DOĞRULAMA — TEK satır dönmeli.
--
-- active = true → iş zamanlanmış ve çalışmaya hazır.
-- schedule → '0 0 * * *' olmalı.
-- Sıfır satır dönerse cron.schedule çağrısı işi kaydetmemiştir.
-- =============================================================================
select jobid, jobname, schedule, active
from cron.job
where jobname = 'tracebox_retention';
173 changes: 173 additions & 0 deletions tests/test_retention_sql.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
"""
db/retention.sql — sözleşme testleri.

Bu dosya bir birim testinde ÇALIŞTIRILAMAZ: pg_cron uzantısı ve canlı bir
Postgres ister; test ortamında ikisi de yok. Doğru çalıştığı Supabase'de elle
doğrulanır.

Buradaki testler farklı bir soruya bakar: dosya SÖZLEŞMESİNİ hâlâ tutuyor mu?

Retention'ın bozulma biçimi sessizdir — hiçbir hata üretmez:
* bir tablo listeden düşerse o tablo sonsuza kadar birikir,
* ölçüt measured_at'e kayarsa silme kararını cihaz verir,
* status süzgeci düşerse teslim edilmemiş komutlar yok olur,
* bir DELETE $$ bloğunun dışına taşarsa gece değil, yalnızca kurulumda çalışır.

Dördünde de sistem "başarılı" der. Kaybı fark eden başka hiçbir şey yok.
"""

from __future__ import annotations

import re
from pathlib import Path

import pytest

RETENTION = Path(__file__).resolve().parent.parent / "db" / "retention.sql"

# Gece silinen dört veri tablosu. Listeden düşen tablo sessizce birikmeye başlar.
DATA_TABLES = ["metrics", "logs", "crash_snapshots", "commands"]

# Sunucunun yazdığı damgayı taşıyan üç tablo. commands'ta received_at yoktur;
# onun karşılığı created_at'tir ve ayrıca test edilir.
SERVER_STAMPED_TABLES = ["metrics", "logs", "crash_snapshots"]

JOB_NAME = "tracebox_retention"


def code(body: str) -> str:
"""SQL yorumlarını atar.

Yorumlar bu dosyada iddiaların hepsini kelime kelime anlatıyor — süzgeç
olmadan `status = 'applied'` koddan silinse bile testler yeşil kalırdı.
Satır içi `--`'den sonrası da atılır.
"""
return "\n".join(line.split("--")[0] for line in body.splitlines())


@pytest.fixture(scope="module")
def sql() -> str:
"""Yorumları ayıklanmış dosya içeriği."""
return code(RETENTION.read_text(encoding="utf-8"))


@pytest.fixture(scope="module")
def job_body(sql: str) -> str:
"""cron.schedule'a geçilen $$ ... $$ bloğunun içi.

Gece tekrar tekrar çalışan SQL yalnızca burasıdır. Blok dışına düşen bir
ifade dosya çalıştırıldığında BİR KEZ koşar, sonra bir daha asla.
"""
match = re.search(r"\$\$(.*?)\$\$", sql, re.DOTALL)
assert match, "cron.schedule'ın $$ ... $$ gövdesi bulunamadı"
return match.group(1)


def delete_statement(body: str, table: str) -> str:
"""Bir tablonun DELETE ifadesini `delete from`'dan `;`'e kadar döndürür."""
match = re.search(rf"delete\s+from\s+public\.{table}\b.*?;", body, re.DOTALL)
assert match, f"{table} için DELETE ifadesi bulunamadı"
return match.group(0)


# --- Dosyanın kendisi ------------------------------------------------------


def test_file_exists():
"""retention.sql, verinin silinmesini sağlayan TEK dosya; eksikse hiçbir şey silinmez."""
assert RETENTION.is_file()


# --- Hangi tablolar siliniyor ----------------------------------------------


@pytest.mark.parametrize("table", DATA_TABLES)
def test_every_data_table_is_cleaned(job_body, table):
"""Dört veri tablosunun dördü de gece işinde silinmeli.

Biri listeden düşerse hata çıkmaz; o tablo yalnızca büyümeye devam eder.
"""
assert re.search(rf"delete\s+from\s+public\.{table}\b", job_body)


@pytest.mark.parametrize("table", DATA_TABLES)
def test_every_delete_lives_inside_the_scheduled_block(sql, job_body, table):
"""DELETE'ler $$ bloğunun İÇİNDE olmalı — dışarı taşan ifade yalnızca bir kez koşar."""
statement = delete_statement(sql, table)
assert statement in job_body


# --- Silme ölçütü ----------------------------------------------------------


@pytest.mark.parametrize("table", SERVER_STAMPED_TABLES)
def test_criterion_is_the_server_written_timestamp(job_body, table):
"""metrics/logs/crash_snapshots received_at'e bakar — sunucunun yazdığı damgaya."""
assert re.search(r"received_at\s*<", delete_statement(job_body, table))


def test_measured_at_is_never_a_deletion_criterion(job_body):
"""measured_at'i AGENT yazar.

Ölçüt oraya kayarsa satırın silinip silinmeyeceğine veriyi gönderen cihaz
karar verir: damgayı geleceğe yazan bir cihazın verisi asla eskimez.
"""
assert "measured_at" not in job_body


def test_commands_is_measured_by_created_at(job_body):
"""commands'ta received_at sütunu yok; sunucu damgası created_at'tir."""
assert re.search(r"created_at\s*<", delete_statement(job_body, "commands"))


# --- pending komutların korunması ------------------------------------------


def test_only_applied_commands_are_deleted(job_body):
"""'pending' satır teslim EDİLMEMİŞ bir emirdir; yaşı ne olursa olsun silinmez.

Süzgeç düşerse çevrimdışı bir cihaza verilen delete/pause komutu, cihaz
geri dönmeden önce yok olur — ve hiçbir yerde hata üretilmez.
"""
statement = delete_statement(job_body, "commands")
assert re.search(r"status\s*=\s*'applied'", statement)


# --- Politikanın hesap başına uygulanması ----------------------------------


@pytest.mark.parametrize("table", DATA_TABLES)
def test_age_limit_comes_from_the_owning_account(job_body, table):
"""Süre sabit değil, satırın SAHİBİNİN retention_days politikasından gelir.

Sabit bir süre bugün doğru sonucu verir (tüm hesaplar varsayılan 10 gün),
yarın farklı politikalı bir hesap eklendiğinde sessizce yanlış olur.
"""
statement = delete_statement(job_body, table)
assert re.search(r"using\s+public\.accounts\s+a\b", statement)
assert "a.retention_days" in statement


@pytest.mark.parametrize("table", DATA_TABLES)
def test_rows_are_matched_to_their_own_account(job_body, table):
"""Join şartı olmadan `using` çapraz birleşime döner: her satır her hesapla eşleşir."""
assert re.search(r"account_id\s*=\s*a\.id", delete_statement(job_body, table))


# --- Zamanlama -------------------------------------------------------------


def test_job_runs_every_night_at_midnight(sql):
"""'0 0 * * *' — dakika 0, saat 0, her gün. Geçerli ama yanlış bir ifade sessizdir."""
assert re.search(r"cron\.schedule\(\s*'[^']+'\s*,\s*'0 0 \* \* \*'", sql)


def test_job_name_is_the_same_in_schedule_and_verification(sql):
"""İşin adı pg_cron'un tekillik anahtarıdır.

Aynı adla yeniden çalıştırmak mevcut işi günceller; ad kayarsa eskisi
silinmeden İKİNCİ bir iş kurulur ve dosyanın sonundaki doğrulama sorgusu
yanlış işe bakar.
"""
assert re.search(rf"cron\.schedule\(\s*'{JOB_NAME}'", sql)
assert re.search(rf"from\s+cron\.job\s+where\s+jobname\s*=\s*'{JOB_NAME}'", sql, re.DOTALL)
Loading