asurada 0.3.0

Asurada — a memory + cognition daemon that grows with the user. Local-first, BYOK, shared by Devist/Webchemist Core/etc.
// brain.db 스키마 마이그레이션.
//
// 한 곳에서 모든 마이그레이션을 정의 — 사용자 머신마다 자동 적용.
// 추가 시: SCHEMA_VERSION 올리고 MIGRATIONS 에 추가. 절대 기존 버전 SQL 수정 금지.

use anyhow::{Context, Result};
use rusqlite::Connection;

const SCHEMA_VERSION_TABLE: &str = r#"
CREATE TABLE IF NOT EXISTS _schema_version (
    version INTEGER PRIMARY KEY,
    applied_at TEXT NOT NULL DEFAULT (datetime('now'))
);
"#;

const MIGRATIONS: &[&str] = &[
    // v1: memories core + FTS5
    r#"
    CREATE TABLE IF NOT EXISTS memories (
        id TEXT PRIMARY KEY,
        user_id TEXT NOT NULL,
        text TEXT NOT NULL,
        scope TEXT NOT NULL,        -- 'user' | 'project' | 'tech'
        priority TEXT NOT NULL,     -- 'constraint' | 'strong' | 'preference' | 'info'
        source TEXT NOT NULL,       -- 'user' | 'asurada' | 'imported'
        project TEXT,
        tech TEXT,
        metadata TEXT NOT NULL DEFAULT '{}',
        status TEXT NOT NULL DEFAULT 'active',  -- 'active' | 'proposed' | 'archived' | 'deleted'
        created_at TEXT NOT NULL,
        updated_at TEXT NOT NULL,
        deleted_at TEXT
    );

    CREATE INDEX IF NOT EXISTS idx_memories_user ON memories(user_id);
    CREATE INDEX IF NOT EXISTS idx_memories_user_scope ON memories(user_id, scope);
    CREATE INDEX IF NOT EXISTS idx_memories_user_status ON memories(user_id, status);
    CREATE INDEX IF NOT EXISTS idx_memories_user_project ON memories(user_id, project);

    -- FTS5 가상 테이블: text 컬럼 키워드 검색.
    CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
        text,
        content='memories',
        content_rowid='rowid',
        tokenize='unicode61 remove_diacritics 2'
    );

    -- 트리거로 memories 변경 시 fts 자동 동기화.
    CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN
        INSERT INTO memories_fts(rowid, text) VALUES (new.rowid, new.text);
    END;
    CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN
        INSERT INTO memories_fts(memories_fts, rowid, text) VALUES('delete', old.rowid, old.text);
    END;
    CREATE TRIGGER IF NOT EXISTS memories_au AFTER UPDATE ON memories BEGIN
        INSERT INTO memories_fts(memories_fts, rowid, text) VALUES('delete', old.rowid, old.text);
        INSERT INTO memories_fts(rowid, text) VALUES (new.rowid, new.text);
    END;
    "#,
    // v2: events / advice / projects + sync metadata.
    //
    // 양방향 sync (brain.db ↔ Supabase Postgres) 를 위한 메타데이터:
    //   - synced_at: 마지막으로 클라우드와 일치한 시점. NULL = 아직 push 안 됨.
    //   - 각 row 의 updated_at > synced_at 이면 push 후보.
    //   - cloud 의 updated_at > local synced_at 이면 pull 적용.
    r#"
    -- v1 의 memories 에도 sync metadata 추가
    ALTER TABLE memories ADD COLUMN synced_at TEXT;
    CREATE INDEX IF NOT EXISTS idx_memories_unsynced
        ON memories(synced_at) WHERE synced_at IS NULL;

    -- 파일 변경 / 활동 이벤트 (Devist 가 INSERT, Asurada 가 처리)
    CREATE TABLE IF NOT EXISTS events (
        id TEXT PRIMARY KEY,
        user_id TEXT NOT NULL,
        project TEXT NOT NULL,
        event_type TEXT NOT NULL,           -- 'file_changed' | 'file_created' | 'file_deleted' | 'scan' | etc.
        path TEXT,
        payload TEXT NOT NULL DEFAULT '{}',
        created_at TEXT NOT NULL,
        updated_at TEXT NOT NULL,
        synced_at TEXT
    );
    CREATE INDEX IF NOT EXISTS idx_events_user ON events(user_id);
    CREATE INDEX IF NOT EXISTS idx_events_project ON events(user_id, project);
    CREATE INDEX IF NOT EXISTS idx_events_unsynced
        ON events(synced_at) WHERE synced_at IS NULL;
    CREATE INDEX IF NOT EXISTS idx_events_created ON events(created_at);

    -- 어드바이스 (Asurada 생성, 사용자가 confirm/intentional 등으로 처리)
    CREATE TABLE IF NOT EXISTS advice (
        id TEXT PRIMARY KEY,
        user_id TEXT NOT NULL,
        project TEXT NOT NULL,
        text TEXT NOT NULL,
        severity TEXT NOT NULL,             -- info | suggest | warn | block
        paths TEXT NOT NULL DEFAULT '[]',   -- JSON array of relative paths
        verifiable INTEGER NOT NULL DEFAULT 0,
        state TEXT NOT NULL DEFAULT 'pending',  -- pending | requested | running | verifying | done | error
        confirmed_at TEXT,
        confirmed_by TEXT,                  -- 'user' | 'apply' | 'verify' | 'audit' | 'intentional'
        metadata TEXT NOT NULL DEFAULT '{}',
        created_at TEXT NOT NULL,
        updated_at TEXT NOT NULL,
        synced_at TEXT
    );
    CREATE INDEX IF NOT EXISTS idx_advice_user ON advice(user_id);
    CREATE INDEX IF NOT EXISTS idx_advice_user_project ON advice(user_id, project);
    CREATE INDEX IF NOT EXISTS idx_advice_unsynced
        ON advice(synced_at) WHERE synced_at IS NULL;
    CREATE INDEX IF NOT EXISTS idx_advice_pending
        ON advice(user_id, confirmed_at) WHERE confirmed_at IS NULL;

    -- 프로젝트 레지스트리 (Devist 가 관리, Asurada 가 sync)
    CREATE TABLE IF NOT EXISTS projects (
        user_id TEXT NOT NULL,
        name TEXT NOT NULL,
        path TEXT NOT NULL,
        metadata TEXT NOT NULL DEFAULT '{}',
        created_at TEXT NOT NULL,
        updated_at TEXT NOT NULL,
        synced_at TEXT,
        PRIMARY KEY (user_id, name)
    );
    CREATE INDEX IF NOT EXISTS idx_projects_unsynced
        ON projects(synced_at) WHERE synced_at IS NULL;

    -- Sync 상태: 테이블별 마지막 push/pull 시점
    CREATE TABLE IF NOT EXISTS _sync_state (
        table_name TEXT PRIMARY KEY,
        last_pushed_at TEXT,
        last_pulled_at TEXT,
        updated_at TEXT NOT NULL DEFAULT (datetime('now'))
    );
    "#,
    // v3: intents — 사용자 개인성을 의도(intent)로 저장.
    //
    // strength 별 처리:
    //   preference → UserPromptSubmit hook 으로 컨텍스트 주입 (부드러운 가이드)
    //   principle  → PreToolUse hook 으로 차단/확인 (능동적 게이트)
    //   context    → CLAUDE.md / .claude/agents/* 파일 생성 (정적 정보)
    //
    // metadata 의 trigger (principle 한정):
    //   { "trigger": { "tool": "Bash", "contains": "git push --force" } }
    r#"
    CREATE TABLE IF NOT EXISTS intents (
        id TEXT PRIMARY KEY,
        user_id TEXT NOT NULL,
        project TEXT,                       -- NULL = 전역 (모든 프로젝트)
        strength TEXT NOT NULL,             -- 'preference' | 'principle' | 'context'
        intent_text TEXT NOT NULL,
        source TEXT NOT NULL,               -- 'user' | 'advice_promotion' | 'inferred'
        source_signal_ids TEXT NOT NULL DEFAULT '[]',
        status TEXT NOT NULL DEFAULT 'active',  -- 'active' | 'archived'
        metadata TEXT NOT NULL DEFAULT '{}',
        created_at TEXT NOT NULL,
        updated_at TEXT NOT NULL,
        synced_at TEXT
    );
    CREATE INDEX IF NOT EXISTS idx_intents_user ON intents(user_id);
    CREATE INDEX IF NOT EXISTS idx_intents_user_project ON intents(user_id, project);
    CREATE INDEX IF NOT EXISTS idx_intents_active ON intents(user_id, status);
    CREATE INDEX IF NOT EXISTS idx_intents_unsynced
        ON intents(synced_at) WHERE synced_at IS NULL;
    "#,
    // v4: harnesses — 자동 생성된 하네스의 풍부한 메타데이터.
    //
    // 사용자가 깊게 이해할 수 있도록:
    //   - title, reason (narrative — 왜/언제/어떤 신호로 만들어졌는지)
    //   - file_paths (어떤 파일들이 디스크에 있는지)
    //   - usage_count, last_used_at (얼마나 사용되는지)
    //   - evolution_log (어떻게 진화했는지 — 변경 내역 타임라인)
    //
    // (user_id, project, slug) 가 조합 고유키 — 같은 슬러그가 다른 프로젝트엔 가능.
    r#"
    CREATE TABLE IF NOT EXISTS harnesses (
        id TEXT PRIMARY KEY,
        user_id TEXT NOT NULL,
        project TEXT NOT NULL,
        slug TEXT NOT NULL,
        title TEXT NOT NULL,
        description TEXT NOT NULL,
        reason TEXT NOT NULL,
        file_paths TEXT NOT NULL DEFAULT '[]',
        usage_count INTEGER NOT NULL DEFAULT 0,
        last_used_at TEXT,
        evolution_log TEXT NOT NULL DEFAULT '[]',
        source_signal_ids TEXT NOT NULL DEFAULT '[]',
        source_cluster_signature INTEGER,
        status TEXT NOT NULL DEFAULT 'active',
        metadata TEXT NOT NULL DEFAULT '{}',
        created_at TEXT NOT NULL,
        updated_at TEXT NOT NULL,
        synced_at TEXT,
        UNIQUE(user_id, project, slug)
    );
    CREATE INDEX IF NOT EXISTS idx_harnesses_user ON harnesses(user_id);
    CREATE INDEX IF NOT EXISTS idx_harnesses_user_project ON harnesses(user_id, project);
    CREATE INDEX IF NOT EXISTS idx_harnesses_active ON harnesses(user_id, status);
    CREATE INDEX IF NOT EXISTS idx_harnesses_signature ON harnesses(user_id, source_cluster_signature);
    CREATE INDEX IF NOT EXISTS idx_harnesses_unsynced
        ON harnesses(synced_at) WHERE synced_at IS NULL;
    "#,
    // v5: issues — 작업 세션 단위 기록 (사용자가 한 호흡으로 한 일).
    //
    // brief / reflection 이 *시간 윈도우 기준 자동 회고* 라면, issues 는
    // *작업 단위 응결* — 사용자가 명시적으로 capture 시점에 만들거나, Asurada 가
    // 큰 작업 끝맺음을 감지해 제안. Dashboard 의 Issues 페이지가 표시.
    r#"
    CREATE TABLE IF NOT EXISTS issues (
        id TEXT PRIMARY KEY,
        user_id TEXT NOT NULL,
        title TEXT NOT NULL,
        summary TEXT NOT NULL,
        projects TEXT NOT NULL DEFAULT '[]',  -- JSON array of project names
        status TEXT NOT NULL DEFAULT 'completed',  -- 'active' | 'completed' | 'paused'
        started_at TEXT NOT NULL,
        ended_at TEXT,
        event_count INTEGER NOT NULL DEFAULT 0,
        metadata TEXT NOT NULL DEFAULT '{}',
        created_at TEXT NOT NULL,
        updated_at TEXT NOT NULL,
        synced_at TEXT
    );
    CREATE INDEX IF NOT EXISTS idx_issues_user ON issues(user_id);
    CREATE INDEX IF NOT EXISTS idx_issues_user_status ON issues(user_id, status);
    CREATE INDEX IF NOT EXISTS idx_issues_started ON issues(user_id, started_at);
    CREATE INDEX IF NOT EXISTS idx_issues_unsynced
        ON issues(synced_at) WHERE synced_at IS NULL;
    "#,
    // v6: harnesses → patterns 리네이밍.
    //
    // 단어 의미 정리: harness 가 영어로 "통제·구속" 결인데 Asurada 의 이 도메인은
    // "자주 하는 작업의 결정체 (재사용)" 이라 의미가 어긋남. 통제는 intents/principle
    // 이 담당. 그래서 사용자 노출 표면(CLI/Dashboard/DB) 모두 patterns 로 통일.
    //
    // DB 안 메타데이터 라벨 (`pattern_type = 'harness_evolution'`, `signal.harness_use`,
    // `$.harness_id` JSON path 등) 은 기존 row 호환을 위해 그대로 보존.
    r#"
    ALTER TABLE harnesses RENAME TO patterns;
    DROP INDEX IF EXISTS idx_harnesses_user;
    DROP INDEX IF EXISTS idx_harnesses_user_project;
    DROP INDEX IF EXISTS idx_harnesses_active;
    DROP INDEX IF EXISTS idx_harnesses_signature;
    DROP INDEX IF EXISTS idx_harnesses_unsynced;
    CREATE INDEX IF NOT EXISTS idx_patterns_user ON patterns(user_id);
    CREATE INDEX IF NOT EXISTS idx_patterns_user_project ON patterns(user_id, project);
    CREATE INDEX IF NOT EXISTS idx_patterns_active ON patterns(user_id, status);
    CREATE INDEX IF NOT EXISTS idx_patterns_signature ON patterns(user_id, source_cluster_signature);
    CREATE INDEX IF NOT EXISTS idx_patterns_unsynced
        ON patterns(synced_at) WHERE synced_at IS NULL;
    "#,
];

pub fn migrate(conn: &Connection) -> Result<()> {
    conn.execute_batch(SCHEMA_VERSION_TABLE)
        .context("create _schema_version")?;

    let current: i64 = conn
        .query_row(
            "SELECT COALESCE(MAX(version), 0) FROM _schema_version",
            [],
            |row| row.get(0),
        )
        .unwrap_or(0);

    for (idx, sql) in MIGRATIONS.iter().enumerate() {
        let version = (idx + 1) as i64;
        if version <= current {
            continue;
        }
        conn.execute_batch(sql)
            .with_context(|| format!("apply migration v{}", version))?;
        conn.execute(
            "INSERT INTO _schema_version (version) VALUES (?1)",
            [version],
        )?;
        tracing::info!("applied migration v{}", version);
    }
    Ok(())
}