jj workspaces over the network
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(server): add SQLite schema and repository management

+260
+31
crates/tandem-server/migrations/001_init.sql
··· 1 + CREATE TABLE IF NOT EXISTS repos ( 2 + id TEXT PRIMARY KEY, 3 + name TEXT NOT NULL, 4 + org TEXT NOT NULL, 5 + created_at TEXT DEFAULT (datetime('now')), 6 + UNIQUE(org, name) 7 + ); 8 + 9 + CREATE TABLE IF NOT EXISTS users ( 10 + id TEXT PRIMARY KEY, 11 + email TEXT UNIQUE NOT NULL, 12 + name TEXT, 13 + password_hash TEXT NOT NULL, 14 + created_at TEXT DEFAULT (datetime('now')) 15 + ); 16 + 17 + CREATE TABLE IF NOT EXISTS repo_access ( 18 + repo_id TEXT REFERENCES repos(id) ON DELETE CASCADE, 19 + user_id TEXT REFERENCES users(id) ON DELETE CASCADE, 20 + role TEXT CHECK(role IN ('read', 'write', 'admin')) NOT NULL, 21 + PRIMARY KEY (repo_id, user_id) 22 + ); 23 + 24 + CREATE TABLE IF NOT EXISTS auth_tokens ( 25 + token TEXT PRIMARY KEY, 26 + user_id TEXT REFERENCES users(id) ON DELETE CASCADE, 27 + expires_at TEXT NOT NULL 28 + ); 29 + 30 + CREATE INDEX IF NOT EXISTS idx_repo_access_user ON repo_access(user_id); 31 + CREATE INDEX IF NOT EXISTS idx_auth_tokens_user ON auth_tokens(user_id);
+229
crates/tandem-server/src/db.rs
··· 1 + use chrono::{DateTime, Utc}; 2 + use sqlx::{FromRow, sqlite::SqlitePool}; 3 + 4 + #[derive(Debug, Clone, FromRow)] 5 + pub struct RepoRow { 6 + pub id: String, 7 + pub name: String, 8 + pub org: String, 9 + pub created_at: DateTime<Utc>, 10 + } 11 + 12 + #[derive(Debug, Clone, FromRow)] 13 + pub struct UserRow { 14 + pub id: String, 15 + pub email: String, 16 + pub name: Option<String>, 17 + pub password_hash: String, 18 + pub created_at: DateTime<Utc>, 19 + } 20 + 21 + #[derive(Debug, Clone, FromRow)] 22 + pub struct RepoAccessRow { 23 + pub repo_id: String, 24 + pub user_id: String, 25 + pub role: String, 26 + } 27 + 28 + #[derive(Debug, Clone, FromRow)] 29 + pub struct AuthTokenRow { 30 + pub token: String, 31 + pub user_id: String, 32 + pub expires_at: DateTime<Utc>, 33 + } 34 + 35 + #[derive(Clone)] 36 + pub struct Database { 37 + pool: SqlitePool, 38 + } 39 + 40 + impl Database { 41 + pub async fn new(database_url: &str) -> Result<Self, sqlx::Error> { 42 + let pool = SqlitePool::connect(database_url).await?; 43 + Ok(Self { pool }) 44 + } 45 + 46 + pub async fn run_migrations(&self) -> Result<(), sqlx::Error> { 47 + sqlx::query(include_str!("../migrations/001_init.sql")) 48 + .execute(&self.pool) 49 + .await?; 50 + Ok(()) 51 + } 52 + 53 + // Repo operations 54 + pub async fn list_repos(&self) -> Result<Vec<RepoRow>, sqlx::Error> { 55 + sqlx::query_as::<_, RepoRow>( 56 + "SELECT id, name, org, created_at FROM repos ORDER BY created_at DESC", 57 + ) 58 + .fetch_all(&self.pool) 59 + .await 60 + } 61 + 62 + /// List repos that a user has access to 63 + pub async fn list_repos_for_user(&self, user_id: &str) -> Result<Vec<RepoRow>, sqlx::Error> { 64 + sqlx::query_as::<_, RepoRow>( 65 + r#" 66 + SELECT r.id, r.name, r.org, r.created_at 67 + FROM repos r 68 + INNER JOIN repo_access ra ON r.id = ra.repo_id 69 + WHERE ra.user_id = ? 70 + ORDER BY r.created_at DESC 71 + "# 72 + ) 73 + .bind(user_id) 74 + .fetch_all(&self.pool) 75 + .await 76 + } 77 + 78 + pub async fn get_repo(&self, id: &str) -> Result<Option<RepoRow>, sqlx::Error> { 79 + sqlx::query_as::<_, RepoRow>("SELECT id, name, org, created_at FROM repos WHERE id = ?") 80 + .bind(id) 81 + .fetch_optional(&self.pool) 82 + .await 83 + } 84 + 85 + pub async fn create_repo( 86 + &self, 87 + id: &str, 88 + name: &str, 89 + org: &str, 90 + ) -> Result<RepoRow, sqlx::Error> { 91 + let now = Utc::now(); 92 + sqlx::query("INSERT INTO repos (id, name, org, created_at) VALUES (?, ?, ?, ?)") 93 + .bind(id) 94 + .bind(name) 95 + .bind(org) 96 + .bind(now.to_rfc3339()) 97 + .execute(&self.pool) 98 + .await?; 99 + 100 + Ok(RepoRow { 101 + id: id.to_string(), 102 + name: name.to_string(), 103 + org: org.to_string(), 104 + created_at: now, 105 + }) 106 + } 107 + 108 + pub async fn delete_repo(&self, id: &str) -> Result<bool, sqlx::Error> { 109 + let result = sqlx::query("DELETE FROM repos WHERE id = ?") 110 + .bind(id) 111 + .execute(&self.pool) 112 + .await?; 113 + Ok(result.rows_affected() > 0) 114 + } 115 + 116 + // User operations 117 + pub async fn get_user(&self, id: &str) -> Result<Option<UserRow>, sqlx::Error> { 118 + sqlx::query_as::<_, UserRow>( 119 + "SELECT id, email, name, password_hash, created_at FROM users WHERE id = ?", 120 + ) 121 + .bind(id) 122 + .fetch_optional(&self.pool) 123 + .await 124 + } 125 + 126 + pub async fn get_user_by_email(&self, email: &str) -> Result<Option<UserRow>, sqlx::Error> { 127 + sqlx::query_as::<_, UserRow>( 128 + "SELECT id, email, name, password_hash, created_at FROM users WHERE email = ?", 129 + ) 130 + .bind(email) 131 + .fetch_optional(&self.pool) 132 + .await 133 + } 134 + 135 + pub async fn create_user( 136 + &self, 137 + id: &str, 138 + email: &str, 139 + name: Option<&str>, 140 + password_hash: &str, 141 + ) -> Result<UserRow, sqlx::Error> { 142 + let now = Utc::now(); 143 + sqlx::query( 144 + "INSERT INTO users (id, email, name, password_hash, created_at) VALUES (?, ?, ?, ?, ?)", 145 + ) 146 + .bind(id) 147 + .bind(email) 148 + .bind(name) 149 + .bind(password_hash) 150 + .bind(now.to_rfc3339()) 151 + .execute(&self.pool) 152 + .await?; 153 + 154 + Ok(UserRow { 155 + id: id.to_string(), 156 + email: email.to_string(), 157 + name: name.map(String::from), 158 + password_hash: password_hash.to_string(), 159 + created_at: now, 160 + }) 161 + } 162 + 163 + // Access control 164 + pub async fn get_user_role( 165 + &self, 166 + user_id: &str, 167 + repo_id: &str, 168 + ) -> Result<Option<String>, sqlx::Error> { 169 + let result = sqlx::query_as::<_, (String,)>( 170 + "SELECT role FROM repo_access WHERE user_id = ? AND repo_id = ?", 171 + ) 172 + .bind(user_id) 173 + .bind(repo_id) 174 + .fetch_optional(&self.pool) 175 + .await?; 176 + Ok(result.map(|r| r.0)) 177 + } 178 + 179 + pub async fn set_user_role( 180 + &self, 181 + user_id: &str, 182 + repo_id: &str, 183 + role: &str, 184 + ) -> Result<(), sqlx::Error> { 185 + sqlx::query("INSERT OR REPLACE INTO repo_access (user_id, repo_id, role) VALUES (?, ?, ?)") 186 + .bind(user_id) 187 + .bind(repo_id) 188 + .bind(role) 189 + .execute(&self.pool) 190 + .await?; 191 + Ok(()) 192 + } 193 + 194 + // Auth tokens 195 + pub async fn create_token( 196 + &self, 197 + token: &str, 198 + user_id: &str, 199 + expires_at: DateTime<Utc>, 200 + ) -> Result<(), sqlx::Error> { 201 + sqlx::query("INSERT INTO auth_tokens (token, user_id, expires_at) VALUES (?, ?, ?)") 202 + .bind(token) 203 + .bind(user_id) 204 + .bind(expires_at.to_rfc3339()) 205 + .execute(&self.pool) 206 + .await?; 207 + Ok(()) 208 + } 209 + 210 + pub async fn verify_token(&self, token: &str) -> Result<Option<UserRow>, sqlx::Error> { 211 + sqlx::query_as::<_, UserRow>( 212 + "SELECT u.id, u.email, u.name, u.password_hash, u.created_at 213 + FROM users u 214 + INNER JOIN auth_tokens t ON u.id = t.user_id 215 + WHERE t.token = ? AND t.expires_at > datetime('now')", 216 + ) 217 + .bind(token) 218 + .fetch_optional(&self.pool) 219 + .await 220 + } 221 + 222 + pub async fn delete_token(&self, token: &str) -> Result<(), sqlx::Error> { 223 + sqlx::query("DELETE FROM auth_tokens WHERE token = ?") 224 + .bind(token) 225 + .execute(&self.pool) 226 + .await?; 227 + Ok(()) 228 + } 229 + }