asurada 0.1.0

Asurada — a memory + cognition daemon that grows with the user. Local-first, BYOK, shared by Devist/Webchemist Core/etc.
// 프로젝트 레지스트리 (Devist 가 관리, Asurada 가 sync).
// PRIMARY KEY (user_id, name).

use anyhow::{Context, Result};
use chrono::Utc;
use rusqlite::{params, Connection};
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Project {
    pub user_id: String,
    pub name: String,
    pub path: String,
    pub metadata: serde_json::Value,
    pub created_at: String,
    pub updated_at: String,
    pub synced_at: Option<String>,
}

#[derive(Debug, Clone, Deserialize)]
pub struct ProjectInput {
    pub user_id: String,
    pub name: String,
    pub path: String,
    #[serde(default)]
    pub metadata: Option<serde_json::Value>,
}

pub fn upsert(conn: &Connection, input: ProjectInput) -> Result<Project> {
    let now = Utc::now().to_rfc3339();
    let meta_json = input
        .metadata
        .map(|m| m.to_string())
        .unwrap_or_else(|| "{}".into());

    conn.execute(
        r#"INSERT INTO projects (user_id, name, path, metadata, created_at, updated_at)
           VALUES (?1, ?2, ?3, ?4, ?5, ?5)
           ON CONFLICT(user_id, name) DO UPDATE SET
               path = excluded.path,
               metadata = excluded.metadata,
               updated_at = excluded.updated_at,
               synced_at = NULL"#,
        params![input.user_id, input.name, input.path, meta_json, now],
    )
    .context("upsert project")?;

    get(conn, &input.user_id, &input.name)?.context("project missing after upsert")
}

pub fn get(conn: &Connection, user_id: &str, name: &str) -> Result<Option<Project>> {
    let mut stmt = conn.prepare(
        r#"SELECT user_id, name, path, metadata, created_at, updated_at, synced_at
           FROM projects
           WHERE user_id = ?1 AND name = ?2"#,
    )?;
    Ok(stmt.query_row(params![user_id, name], row_to_project).ok())
}

pub fn list(conn: &Connection, user_id: &str) -> Result<Vec<Project>> {
    let mut stmt = conn.prepare(
        r#"SELECT user_id, name, path, metadata, created_at, updated_at, synced_at
           FROM projects
           WHERE user_id = ?1
           ORDER BY name ASC"#,
    )?;
    let rows: Vec<Project> = stmt
        .query_map(params![user_id], row_to_project)?
        .filter_map(|r| r.ok())
        .collect();
    Ok(rows)
}

pub fn list_unsynced(conn: &Connection, limit: usize) -> Result<Vec<Project>> {
    let mut stmt = conn.prepare(
        r#"SELECT user_id, name, path, metadata, created_at, updated_at, synced_at
           FROM projects
           WHERE synced_at IS NULL OR updated_at > synced_at
           ORDER BY updated_at ASC
           LIMIT ?1"#,
    )?;
    let rows: Vec<Project> = stmt
        .query_map(params![limit as i64], row_to_project)?
        .filter_map(|r| r.ok())
        .collect();
    Ok(rows)
}

pub fn mark_synced(
    conn: &Connection,
    keys: &[(String, String)], // (user_id, name)
    when: &str,
) -> Result<()> {
    let tx = conn.unchecked_transaction()?;
    for (user_id, name) in keys {
        tx.execute(
            "UPDATE projects SET synced_at = ?1 WHERE user_id = ?2 AND name = ?3",
            params![when, user_id, name],
        )?;
    }
    tx.commit()?;
    Ok(())
}

fn row_to_project(row: &rusqlite::Row<'_>) -> rusqlite::Result<Project> {
    let meta_str: String = row.get(3)?;
    let metadata = serde_json::from_str(&meta_str).unwrap_or_else(|_| serde_json::json!({}));
    Ok(Project {
        user_id: row.get(0)?,
        name: row.get(1)?,
        path: row.get(2)?,
        metadata,
        created_at: row.get(4)?,
        updated_at: row.get(5)?,
        synced_at: row.get(6)?,
    })
}