From 26de97968c938937db3afc3a51853542f0836a78 Mon Sep 17 00:00:00 2001 From: denis Date: Thu, 27 Aug 2026 16:46:22 +0300 Subject: [PATCH 1/2] =?UTF-8?q?feat(m8):=20retention=20=E2=80=94=20pg=5Fcr?= =?UTF-8?q?on=20ile=20gece=20silme=20i=C5=9Fi?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit db/retention.sql eklendi: her gece 00:00 UTC'de çalışan, hesabın retention_days politikasından daha eski satırları silen pg_cron işi. Silinen dört tablo ve ölçütleri: metrics, logs, crash_snapshots → received_at commands → created_at + status = 'applied' Ölçüt her zaman SUNUCUNUN yazdığı damgadır. measured_at'i agent koyar; oraya bakılsaydı bir satırın silinip silinmeyeceğine veriyi gönderen cihaz karar verirdi ve damgayı geleceğe yazan bir cihazın verisi hiç eskimezdi. commands tablosu listeye M8'de dahil edildi. Komut satırı bir telemetri artığı değil, kullanıcının makineyi yönettiğinin kaydıdır — "ne zaman durdurdum, ne zaman yeniden başlattım". Metrik ve logla aynı soruya cevap verdiği için aynı ömre tabi tutuldu. status = 'applied' süzgeci zorunlu: 'pending' satır teslim EDİLMEMİŞ bir emirdir. Süzgeç olmasaydı, çevrimdışı bir cihaza verilen delete komutu cihaz geri dönmeden silinirdi; cihaz açılır, bekleyen komut bulamaz, kendini silmez ve hiçbir yerde hata üretilmez. Bekleyen satırlar zaten birikmez — çalışan cihaz komutu saniyeler içinde alır, hiç dönmezse satır devices kaydıyla CASCADE üzerinden gider. commands için ayrı bir received_at sütunu gerekmedi: 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ıyor, yani istemci o sütuna değer veremiyor. Şema değişmedi — migration yok. tests/test_retention_sql.py: 25 sözleşme testi. SQL bir birim testinde çalıştırılamıyor (pg_cron + canlı Postgres ister), bu yüzden install.sh için kullanılan yöntem uygulandı — dosyanın sözleşmesi statik olarak korunuyor. Testler SQL yorumlarını ayıklıyor; aksi halde iddialar koddan silinse bile yorum metniyle tatmin olurdu. 24 sabotajın tamamı yakalandı: her tablonun listeden düşürülmesi, ölçütün measured_at'e kaydırılması, applied süzgecinin silinmesi, bir DELETE'in $$ bloğunun dışına taşınması, sürenin sabitlenmesi ve zamanlamanın günlükten aylığa kaydırılması. Suite 385 → 410. --- db/retention.sql | 102 +++++++++++++++++++++ tests/test_retention_sql.py | 173 ++++++++++++++++++++++++++++++++++++ 2 files changed, 275 insertions(+) create mode 100644 db/retention.sql create mode 100644 tests/test_retention_sql.py diff --git a/db/retention.sql b/db/retention.sql new file mode 100644 index 0000000..ffc32a1 --- /dev/null +++ b/db/retention.sql @@ -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'; diff --git a/tests/test_retention_sql.py b/tests/test_retention_sql.py new file mode 100644 index 0000000..c7cf191 --- /dev/null +++ b/tests/test_retention_sql.py @@ -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) From 20f4ca48bfa2a72b8968be635db365080218aa73 Mon Sep 17 00:00:00 2001 From: denis Date: Thu, 27 Aug 2026 16:46:22 +0300 Subject: [PATCH 2/2] =?UTF-8?q?docs:=20public=20repo'daki=20iki=20md/=20at?= =?UTF-8?q?=C4=B1f=C4=B1=20kald=C4=B1r=C4=B1ld=C4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dokümantasyon ayrı bir private repo'da (md/) duruyor; kod repo'sundan oraya atıf yapılmaz. Okuyucunun açamayacağı bir yola işaret eden referans bilgi vermez, eksik bırakır. Kural gereği gerekçe yorumun İÇİNE yazılır, atıf atılır. M6 sırasında iki dosyaya sızmıştı: collector/supabase_client.py — DEVICE_WRITABLE_COLUMNS listesinin üstündeki "logging_enabled neden eklendi" notu db/migrations/0003_drop_pending_delete.sql — başlıktaki TARİH satırının sonu İkisinde de gerekçe zaten yorumun içinde tam olarak yazılıydı; yalnızca dışarı açılan kapı duruyordu. Bilgi kaybı yok. --- collector/supabase_client.py | 8 ++++---- db/migrations/0003_drop_pending_delete.sql | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/collector/supabase_client.py b/collector/supabase_client.py index 7b35e6f..eda7d7a 100644 --- a/collector/supabase_client.py +++ b/collector/supabase_client.py @@ -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( diff --git a/db/migrations/0003_drop_pending_delete.sql b/db/migrations/0003_drop_pending_delete.sql index 0520d4d..3000b5e 100644 --- a/db/migrations/0003_drop_pending_delete.sql +++ b/db/migrations/0003_drop_pending_delete.sql @@ -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;