diff --git a/Cargo.lock b/Cargo.lock index 26b83adc0..fda8f52d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3372,7 +3372,7 @@ dependencies = [ [[package]] name = "tokscale-cli" -version = "2.1.0" +version = "2.1.2" dependencies = [ "ab_glyph", "anyhow", @@ -3411,7 +3411,7 @@ dependencies = [ [[package]] name = "tokscale-core" -version = "2.1.0" +version = "2.1.2" dependencies = [ "bincode", "chrono", diff --git a/crates/tokscale-cli/src/auth.rs b/crates/tokscale-cli/src/auth.rs index 41c0177a7..5352ad967 100644 --- a/crates/tokscale-cli/src/auth.rs +++ b/crates/tokscale-cli/src/auth.rs @@ -3,7 +3,9 @@ use serde::{Deserialize, Serialize}; use std::fs; use std::io::IsTerminal; use std::io::Write; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; +use std::thread; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; const API_TOKEN_ENV_VAR: &str = "TOKSCALE_API_TOKEN"; @@ -73,6 +75,19 @@ fn get_credentials_path() -> Result { Ok(home_dir()?.join(".config/tokscale/credentials.json")) } +fn get_source_id_path() -> Result { + Ok(home_dir()?.join(".config/tokscale/source-id")) +} + +fn get_source_id_lock_path() -> Result { + Ok(home_dir()?.join(".config/tokscale/source-id.lock")) +} + +const SOURCE_ID_LOCK_RETRY_DELAY: Duration = Duration::from_millis(25); +const SOURCE_ID_LOCK_STALE_AFTER: Duration = Duration::from_secs(2); +const SOURCE_ID_LOCK_MAX_WAIT: Duration = Duration::from_secs(10); +const SOURCE_ID_LOCK_FORCE_STALE_AFTER: Duration = SOURCE_ID_LOCK_MAX_WAIT; + fn ensure_config_dir() -> Result<()> { let config_dir = home_dir()?.join(".config/tokscale"); @@ -171,6 +186,291 @@ fn get_device_name() -> String { format!("CLI on {}", hostname) } +fn read_source_id(path: &Path) -> Option { + let content = fs::read_to_string(path).ok()?; + let trimmed = content.trim(); + if trimmed.is_empty() { + return None; + } + Some(trimmed.to_string()) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct SourceIdLockState { + pid: u32, + created_at_ms: u128, +} + +fn current_unix_ms() -> u128 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() +} + +fn serialize_source_id_lock_state(state: SourceIdLockState) -> String { + format!("pid={}\ncreated_at_ms={}\n", state.pid, state.created_at_ms) +} + +fn parse_source_id_lock_state(content: &str) -> Option { + let mut pid = None; + let mut created_at_ms = None; + + for line in content.lines() { + // Skip lines without `=` (blank lines, trailing whitespace, or any + // future metadata key we don't recognize) instead of aborting the + // whole parse. A stray malformed line would otherwise force + // lock_age into its mtime fallback and delay stale-lock cleanup. + let Some((key, value)) = line.split_once('=') else { + continue; + }; + match key.trim() { + "pid" => pid = value.trim().parse::().ok(), + "created_at_ms" => created_at_ms = value.trim().parse::().ok(), + _ => {} + } + } + + Some(SourceIdLockState { + pid: pid?, + created_at_ms: created_at_ms?, + }) +} + +fn read_source_id_lock_state(path: &Path) -> Option { + let content = fs::read_to_string(path).ok()?; + parse_source_id_lock_state(&content) +} + +fn lock_age(path: &Path, state: Option) -> Duration { + if let Some(state) = state { + let now_ms = current_unix_ms(); + let age_ms = now_ms.saturating_sub(state.created_at_ms); + return Duration::from_millis(age_ms.min(u64::MAX as u128) as u64); + } + + // Malformed lock file (no parseable state). If mtime is unreadable or in + // the future (clock skew), treat it as stale so we recycle instead of + // stalling on the per-iteration retry up to FORCE_STALE_AFTER. + match fs::metadata(path).and_then(|metadata| metadata.modified()) { + Ok(modified) => modified + .elapsed() + .unwrap_or(SOURCE_ID_LOCK_FORCE_STALE_AFTER), + Err(_) => SOURCE_ID_LOCK_FORCE_STALE_AFTER, + } +} + +// On non-Windows targets the helper is only exercised by unit tests; silence +// the unused-fn lint so `cargo build` stays clean everywhere. +#[cfg_attr(not(windows), allow(dead_code))] +/// Given the stdout of `tasklist /FI "PID eq N" /FO CSV /NH`, decide +/// whether a matching process exists. +/// +/// `/FI "PID eq N"` filters server-side. When no PID matches, tasklist +/// still emits a localized banner on stdout — e.g. English: +/// `INFO: No tasks are running which match the specified criteria.` +/// — so "any non-empty line" over-matches. Because `/FO CSV` wraps +/// every field of a data row in double quotes, a CSV data row always +/// starts with `"`, while the banner never does (and the banner is +/// locale-dependent, so we can't string-match it directly). Gating on +/// `line.trim().starts_with('"')` is both locale-agnostic and +/// robust to process names that contain commas (which would break a +/// naive `split(',')` attempt to read the PID column). +fn tasklist_output_indicates_match(stdout: &str) -> bool { + stdout.lines().any(|line| { + let trimmed = line.trim(); + !trimmed.is_empty() && trimmed.starts_with('"') + }) +} + +fn lock_owner_is_alive(pid: u32) -> Option { + #[cfg(unix)] + { + std::process::Command::new("kill") + .args(["-0", &pid.to_string()]) + .status() + .ok() + .map(|status| status.success()) + } + + #[cfg(windows)] + { + let output = std::process::Command::new("tasklist") + .args(["/FI", &format!("PID eq {}", pid), "/FO", "CSV", "/NH"]) + .output(); + + match output { + Ok(output) if output.status.success() => { + let stdout = String::from_utf8_lossy(&output.stdout); + Some(tasklist_output_indicates_match(&stdout)) + } + Ok(_) => None, + Err(_) => None, + } + } + + #[cfg(not(any(unix, windows)))] + { + let _ = pid; + None + } +} + +fn should_remove_stale_source_id_lock(age: Duration, owner_is_alive: Option) -> bool { + if age >= SOURCE_ID_LOCK_FORCE_STALE_AFTER { + return true; + } + + match owner_is_alive { + Some(false) | None => age >= SOURCE_ID_LOCK_STALE_AFTER, + Some(true) => false, + } +} + +fn write_source_id_lock_state(mut file: fs::File, state: SourceIdLockState) -> Result<()> { + let payload = serialize_source_id_lock_state(state); + file.write_all(payload.as_bytes())?; + file.sync_all()?; + Ok(()) +} + +fn remove_source_id_lock_if_matches(path: &Path, expected: Option) -> bool { + let current_state = read_source_id_lock_state(path); + if current_state != expected { + return false; + } + + match fs::remove_file(path) { + Ok(()) => true, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => false, + Err(_) => false, + } +} + +struct SourceIdLock { + path: PathBuf, + state: SourceIdLockState, +} + +impl Drop for SourceIdLock { + fn drop(&mut self) { + let _ = remove_source_id_lock_if_matches(&self.path, Some(self.state)); + } +} + +fn acquire_source_id_lock() -> Result { + ensure_config_dir()?; + let lock_path = get_source_id_lock_path()?; + let deadline = Instant::now() + SOURCE_ID_LOCK_MAX_WAIT; + + loop { + match fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&lock_path) + { + Ok(file) => { + let state = SourceIdLockState { + pid: std::process::id(), + created_at_ms: current_unix_ms(), + }; + + if let Err(err) = write_source_id_lock_state(file, state) { + let _ = fs::remove_file(&lock_path); + return Err(err); + } + + return Ok(SourceIdLock { + path: lock_path, + state, + }); + } + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => { + let state = read_source_id_lock_state(&lock_path); + let age = lock_age(&lock_path, state); + let owner_is_alive = match state { + Some(lock_state) => lock_owner_is_alive(lock_state.pid), + None => None, + }; + + if should_remove_stale_source_id_lock(age, owner_is_alive) { + let _ = remove_source_id_lock_if_matches(&lock_path, state); + continue; + } + + if Instant::now() >= deadline { + break; + } + + thread::sleep(SOURCE_ID_LOCK_RETRY_DELAY); + } + Err(err) => return Err(err.into()), + } + } + + anyhow::bail!("Could not acquire source ID lock after waiting for stale lock cleanup"); +} + +fn write_source_id(path: &Path, source_id: &str) -> Result<()> { + let temp_path = path.with_extension(format!("tmp-{}", std::process::id())); + + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + + let mut file = fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .mode(0o600) + .open(&temp_path)?; + file.write_all(source_id.as_bytes())?; + file.write_all(b"\n")?; + } + + #[cfg(not(unix))] + { + fs::write(&temp_path, format!("{source_id}\n"))?; + } + + fs::rename(&temp_path, path)?; + Ok(()) +} + +pub fn get_submit_source_id() -> Result> { + if let Some(source_id) = std::env::var_os("TOKSCALE_SOURCE_ID") { + let trimmed = source_id.to_string_lossy().trim().to_string(); + if !trimmed.is_empty() { + return Ok(Some(trimmed)); + } + } + + ensure_config_dir()?; + let path = get_source_id_path()?; + + if let Some(existing) = read_source_id(&path) { + return Ok(Some(existing)); + } + + let _lock = acquire_source_id_lock()?; + + if let Some(existing) = read_source_id(&path) { + return Ok(Some(existing)); + } + + let source_id = uuid::Uuid::new_v4().to_string(); + write_source_id(&path, &source_id)?; + Ok(Some(source_id)) +} + +pub fn get_submit_source_name() -> Option { + std::env::var("TOKSCALE_SOURCE_NAME") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .or_else(|| Some(get_device_name())) +} + #[cfg(target_os = "linux")] fn has_non_empty_env_var(name: &str) -> bool { std::env::var_os(name).is_some_and(|value| !value.is_empty()) @@ -635,6 +935,95 @@ mod tests { } } + #[test] + #[serial] + fn test_get_submit_source_id_uses_env_override() { + let temp_dir = TempDir::new().unwrap(); + unsafe { + env::set_var("HOME", temp_dir.path()); + env::set_var("TOKSCALE_SOURCE_ID", " source-from-env "); + } + + let source_id = get_submit_source_id().unwrap(); + + assert_eq!(source_id.as_deref(), Some("source-from-env")); + assert!(!get_source_id_path().unwrap().exists()); + + unsafe { + env::remove_var("TOKSCALE_SOURCE_ID"); + env::remove_var("HOME"); + } + } + + #[test] + #[serial] + fn test_get_submit_source_id_persists_generated_value() { + let temp_dir = TempDir::new().unwrap(); + unsafe { + env::set_var("HOME", temp_dir.path()); + env::remove_var("TOKSCALE_SOURCE_ID"); + } + + let first = get_submit_source_id().unwrap(); + let second = get_submit_source_id().unwrap(); + let path = get_source_id_path().unwrap(); + + assert!(path.exists()); + assert_eq!(first, second); + assert_eq!(read_source_id(&path), first); + + unsafe { + env::remove_var("HOME"); + } + } + + #[test] + #[serial] + fn test_get_submit_source_name_uses_trimmed_env_override() { + unsafe { + env::set_var("TOKSCALE_SOURCE_NAME", " Work Laptop "); + } + + assert_eq!(get_submit_source_name().as_deref(), Some("Work Laptop")); + + unsafe { + env::remove_var("TOKSCALE_SOURCE_NAME"); + } + } + + #[test] + fn test_should_remove_stale_source_id_lock_when_owner_dead_after_stale_threshold() { + assert!(should_remove_stale_source_id_lock( + SOURCE_ID_LOCK_STALE_AFTER, + Some(false) + )); + } + + #[test] + fn test_should_remove_stale_source_id_lock_when_probe_is_unknown_after_stale_threshold() { + assert!(should_remove_stale_source_id_lock( + SOURCE_ID_LOCK_STALE_AFTER, + None + )); + } + + #[test] + fn test_should_remove_stale_source_id_lock_when_age_exceeds_force_threshold_even_if_pid_is_alive( + ) { + assert!(should_remove_stale_source_id_lock( + SOURCE_ID_LOCK_FORCE_STALE_AFTER, + Some(true) + )); + } + + #[test] + fn test_should_not_remove_live_source_id_lock_before_force_threshold() { + assert!(!should_remove_stale_source_id_lock( + SOURCE_ID_LOCK_STALE_AFTER, + Some(true) + )); + } + #[test] #[serial] fn test_save_credentials() { @@ -818,6 +1207,461 @@ mod tests { } } + // ===================================================================== + // Lock state serialization / parsing + // ===================================================================== + + #[test] + fn test_serialize_source_id_lock_state_emits_expected_format() { + let state = SourceIdLockState { + pid: 4242, + created_at_ms: 1_700_000_000_000, + }; + assert_eq!( + serialize_source_id_lock_state(state), + "pid=4242\ncreated_at_ms=1700000000000\n" + ); + } + + #[test] + fn test_parse_source_id_lock_state_round_trips_serialized_output() { + let state = SourceIdLockState { + pid: 99, + created_at_ms: 1_699_999_999_999, + }; + let serialized = serialize_source_id_lock_state(state); + + let parsed = parse_source_id_lock_state(&serialized).unwrap(); + assert_eq!(parsed, state); + } + + #[test] + fn test_parse_source_id_lock_state_tolerates_whitespace_and_unknown_keys() { + let content = " pid = 1234 \n created_at_ms = 42 \nunknown_key=ignored\n"; + let parsed = parse_source_id_lock_state(content).unwrap(); + assert_eq!(parsed.pid, 1234); + assert_eq!(parsed.created_at_ms, 42); + } + + #[test] + fn test_parse_source_id_lock_state_returns_none_on_missing_fields() { + assert!(parse_source_id_lock_state("pid=1\n").is_none()); + assert!(parse_source_id_lock_state("created_at_ms=1\n").is_none()); + assert!(parse_source_id_lock_state("").is_none()); + } + + #[test] + fn test_parse_source_id_lock_state_skips_lines_without_equals() { + // Lines without '=' (blank line, garbage, future metadata) must be + // skipped — not abort the whole parse. Key/value pairs around them + // still produce a valid state. + let parsed = + parse_source_id_lock_state("garbage\npid=1\n\ncreated_at_ms=2\nrandom-trailer\n") + .unwrap(); + assert_eq!(parsed.pid, 1); + assert_eq!(parsed.created_at_ms, 2); + } + + // ===================================================================== + // lock_age + // ===================================================================== + + #[test] + fn test_lock_age_from_state_clamps_future_created_at_to_zero() { + // If the lock claims a created_at in the future (clock skew on a + // cross-machine file system), saturating_sub makes age = 0. That's + // the "treat as fresh" branch — the stale checker takes over via + // the force-stale timer. + let temp = TempDir::new().unwrap(); + let path = temp.path().join("irrelevant.lock"); + let future_ms = current_unix_ms() + 10_000; + let state = SourceIdLockState { + pid: 1, + created_at_ms: future_ms, + }; + let age = lock_age(&path, Some(state)); + assert_eq!(age, Duration::ZERO); + } + + #[test] + fn test_lock_age_from_state_returns_elapsed_ms_for_past_created_at() { + let temp = TempDir::new().unwrap(); + let path = temp.path().join("irrelevant.lock"); + let past_ms = current_unix_ms().saturating_sub(250); + let state = SourceIdLockState { + pid: 1, + created_at_ms: past_ms, + }; + let age = lock_age(&path, Some(state)); + assert!( + age >= Duration::from_millis(200) && age < Duration::from_secs(5), + "unexpected age: {:?}", + age + ); + } + + #[test] + fn test_lock_age_without_state_returns_force_stale_when_metadata_missing() { + let temp = TempDir::new().unwrap(); + let missing = temp.path().join("does-not-exist.lock"); + assert_eq!(lock_age(&missing, None), SOURCE_ID_LOCK_FORCE_STALE_AFTER); + } + + // ===================================================================== + // tasklist_output_indicates_match (Windows PID-probe helper, + // compiled on all platforms for testability) + // ===================================================================== + + #[test] + fn test_tasklist_output_indicates_match_matches_csv_data_row() { + let stdout = "\"chrome.exe\",\"1234\",\"Console\",\"1\",\"256,000 K\"\n"; + assert!(tasklist_output_indicates_match(stdout)); + } + + #[test] + fn test_tasklist_output_indicates_match_rejects_english_info_banner() { + // This is the exact failure mode of the earlier naive + // "any non-empty line" check — tasklist prints the banner even + // when the PID filter found nothing. + let stdout = "INFO: No tasks are running which match the specified criteria.\n"; + assert!(!tasklist_output_indicates_match(stdout)); + } + + #[test] + fn test_tasklist_output_indicates_match_rejects_localized_info_banner() { + // Non-English Windows banners don't start with "INFO:" either, but + // they still don't start with a double quote. The CSV-data + // heuristic is locale-agnostic precisely because /FO CSV wraps + // fields in quotes deterministically. + let stdout = "정보: 지정된 조건과 일치하는 태스크가 실행되고 있지 않습니다.\n"; + assert!(!tasklist_output_indicates_match(stdout)); + } + + #[test] + fn test_tasklist_output_indicates_match_rejects_empty_output() { + assert!(!tasklist_output_indicates_match("")); + assert!(!tasklist_output_indicates_match("\n\n")); + } + + #[test] + fn test_tasklist_output_indicates_match_tolerates_process_name_with_commas() { + // A commaed image name is CSV-quoted and the row still starts + // with a double quote, so the heuristic identifies the match + // without needing to parse the PID column. + let stdout = "\"evil,name.exe\",\"4242\",\"Services\",\"0\",\"1,024 K\"\n"; + assert!(tasklist_output_indicates_match(stdout)); + } + + #[test] + fn test_tasklist_output_indicates_match_ignores_blank_lines_around_data() { + let stdout = "\n\"chrome.exe\",\"1234\",\"Console\",\"1\",\"256,000 K\"\n\n"; + assert!(tasklist_output_indicates_match(stdout)); + } + + // ===================================================================== + // read_source_id_lock_state / remove_source_id_lock_if_matches + // ===================================================================== + + #[test] + fn test_read_source_id_lock_state_reads_and_parses_file() { + let temp = TempDir::new().unwrap(); + let path = temp.path().join("present.lock"); + let state = SourceIdLockState { + pid: 7, + created_at_ms: 123, + }; + fs::write(&path, serialize_source_id_lock_state(state)).unwrap(); + assert_eq!(read_source_id_lock_state(&path), Some(state)); + } + + #[test] + fn test_read_source_id_lock_state_returns_none_when_file_missing() { + let temp = TempDir::new().unwrap(); + let missing = temp.path().join("missing.lock"); + assert!(read_source_id_lock_state(&missing).is_none()); + } + + #[test] + fn test_remove_source_id_lock_if_matches_deletes_only_on_exact_match() { + let temp = TempDir::new().unwrap(); + let path = temp.path().join("match.lock"); + let state = SourceIdLockState { + pid: 10, + created_at_ms: 1000, + }; + fs::write(&path, serialize_source_id_lock_state(state)).unwrap(); + + // Wrong expected state → do not delete. + let other = SourceIdLockState { + pid: 11, + created_at_ms: 1000, + }; + assert!(!remove_source_id_lock_if_matches(&path, Some(other))); + assert!(path.exists()); + + // Matching expected state → delete. + assert!(remove_source_id_lock_if_matches(&path, Some(state))); + assert!(!path.exists()); + } + + #[test] + fn test_remove_source_id_lock_if_matches_returns_false_when_file_missing() { + let temp = TempDir::new().unwrap(); + let missing = temp.path().join("gone.lock"); + assert!(!remove_source_id_lock_if_matches(&missing, None)); + } + + // ===================================================================== + // read_source_id / write_source_id + // ===================================================================== + + #[test] + fn test_read_source_id_returns_trimmed_content() { + let temp = TempDir::new().unwrap(); + let path = temp.path().join("source-id"); + fs::write(&path, " abc-123 \n").unwrap(); + assert_eq!(read_source_id(&path).as_deref(), Some("abc-123")); + } + + #[test] + fn test_read_source_id_returns_none_for_empty_file() { + let temp = TempDir::new().unwrap(); + let path = temp.path().join("empty"); + fs::write(&path, " \n").unwrap(); + assert!(read_source_id(&path).is_none()); + } + + #[test] + fn test_read_source_id_returns_none_when_file_missing() { + let temp = TempDir::new().unwrap(); + let missing = temp.path().join("never-written"); + assert!(read_source_id(&missing).is_none()); + } + + #[test] + fn test_write_source_id_creates_file_with_trailing_newline() { + let temp = TempDir::new().unwrap(); + let path = temp.path().join("source-id"); + write_source_id(&path, "fresh-id").unwrap(); + + let content = fs::read_to_string(&path).unwrap(); + assert_eq!(content, "fresh-id\n"); + } + + #[test] + fn test_write_source_id_overwrites_existing_file_atomically() { + let temp = TempDir::new().unwrap(); + let path = temp.path().join("source-id"); + fs::write(&path, "old-id\n").unwrap(); + write_source_id(&path, "new-id").unwrap(); + assert_eq!(fs::read_to_string(&path).unwrap(), "new-id\n"); + + // Temp file name is of the form "source-id.tmp-"; it must not + // be left behind after a successful rename. + let temp_pattern = format!("source-id.tmp-{}", std::process::id()); + assert!(!temp.path().join(temp_pattern).exists()); + } + + // ===================================================================== + // get_device_name + // ===================================================================== + + #[test] + fn test_get_device_name_is_prefixed_with_cli_on() { + let name = get_device_name(); + assert!( + name.starts_with("CLI on "), + "unexpected device name format: {}", + name + ); + assert!( + name.len() > "CLI on ".len(), + "device name missing host component: {}", + name + ); + } + + // ===================================================================== + // should_remove_stale_source_id_lock: remaining branches + // ===================================================================== + + #[test] + fn test_should_not_remove_lock_when_owner_dead_but_age_below_stale_threshold() { + // Dead owner alone is not enough; we still need SOURCE_ID_LOCK_STALE_AFTER. + let tiny = Duration::from_millis(100); + assert!(!should_remove_stale_source_id_lock(tiny, Some(false))); + } + + #[test] + fn test_should_not_remove_lock_when_probe_unknown_but_age_below_stale_threshold() { + let tiny = Duration::from_millis(100); + assert!(!should_remove_stale_source_id_lock(tiny, None)); + } + + // ===================================================================== + // acquire_source_id_lock end-to-end + // ===================================================================== + + #[test] + #[serial] + fn test_acquire_source_id_lock_creates_and_drops_lock_file() { + let temp_dir = TempDir::new().unwrap(); + unsafe { + env::set_var("HOME", temp_dir.path()); + } + + let lock_path = get_source_id_lock_path().unwrap(); + { + let _lock = acquire_source_id_lock().unwrap(); + assert!(lock_path.exists()); + } + assert!(!lock_path.exists(), "lock should be released on Drop"); + + unsafe { + env::remove_var("HOME"); + } + } + + #[test] + #[serial] + fn test_acquire_source_id_lock_takes_over_a_stale_lock_past_force_threshold() { + let temp_dir = TempDir::new().unwrap(); + unsafe { + env::set_var("HOME", temp_dir.path()); + } + + ensure_config_dir().unwrap(); + let lock_path = get_source_id_lock_path().unwrap(); + + // Plant a stale lock with an ancient created_at_ms — past the + // FORCE_STALE threshold, so the acquire loop must reclaim it. + let ancient = SourceIdLockState { + pid: u32::MAX, // Very unlikely to match a real PID on this host. + created_at_ms: 1, // epoch-adjacent + }; + fs::write(&lock_path, serialize_source_id_lock_state(ancient)).unwrap(); + + { + let lock = acquire_source_id_lock().unwrap(); + // The new owner's state replaces the stale one. + let on_disk = read_source_id_lock_state(&lock_path).unwrap(); + assert_ne!(on_disk, ancient); + assert_eq!(on_disk.pid, std::process::id()); + drop(lock); + } + assert!(!lock_path.exists()); + + unsafe { + env::remove_var("HOME"); + } + } + + #[test] + #[serial] + fn test_get_source_id_path_is_under_home_config_tokscale() { + let temp_dir = TempDir::new().unwrap(); + unsafe { + env::set_var("HOME", temp_dir.path()); + } + + let expected = temp_dir.path().join(".config/tokscale/source-id"); + assert_eq!(get_source_id_path().unwrap(), expected); + + let lock_expected = temp_dir.path().join(".config/tokscale/source-id.lock"); + assert_eq!(get_source_id_lock_path().unwrap(), lock_expected); + + unsafe { + env::remove_var("HOME"); + } + } + + #[test] + #[serial] + fn test_get_submit_source_id_uses_env_override_skipping_disk() { + // Covers the early return that does NOT touch the config dir. + let temp_dir = TempDir::new().unwrap(); + unsafe { + env::set_var("HOME", temp_dir.path()); + env::set_var("TOKSCALE_SOURCE_ID", "env-wins"); + } + + let result = get_submit_source_id().unwrap(); + assert_eq!(result.as_deref(), Some("env-wins")); + // No disk file was created. + assert!(!get_source_id_path().unwrap().exists()); + + unsafe { + env::remove_var("TOKSCALE_SOURCE_ID"); + env::remove_var("HOME"); + } + } + + #[test] + #[serial] + fn test_get_submit_source_id_falls_through_when_env_is_whitespace() { + let temp_dir = TempDir::new().unwrap(); + unsafe { + env::set_var("HOME", temp_dir.path()); + env::set_var("TOKSCALE_SOURCE_ID", " "); + } + + let id = get_submit_source_id().unwrap().unwrap(); + assert!(!id.is_empty()); + // Whitespace-only env var is ignored; value is persisted. + assert!(get_source_id_path().unwrap().exists()); + + unsafe { + env::remove_var("TOKSCALE_SOURCE_ID"); + env::remove_var("HOME"); + } + } + + #[test] + #[serial] + fn test_get_submit_source_name_falls_back_to_device_name() { + unsafe { + env::remove_var("TOKSCALE_SOURCE_NAME"); + } + let name = get_submit_source_name().unwrap(); + assert!( + name.starts_with("CLI on "), + "fallback must use get_device_name: {}", + name + ); + } + + #[test] + #[serial] + fn test_get_submit_source_name_ignores_whitespace_only_env() { + unsafe { + env::set_var("TOKSCALE_SOURCE_NAME", " "); + } + let name = get_submit_source_name().unwrap(); + assert!( + name.starts_with("CLI on "), + "whitespace env should fall back to device name: {}", + name + ); + unsafe { + env::remove_var("TOKSCALE_SOURCE_NAME"); + } + } + + // ===================================================================== + // current_unix_ms (trivial but covers the happy path) + // ===================================================================== + + #[test] + fn test_current_unix_ms_is_in_plausible_range() { + let now = current_unix_ms(); + // 2025-01-01 UTC = 1735689600000 ms — we expect something later. + assert!( + now > 1_735_689_600_000, + "clock reported unexpected time: {}", + now + ); + } + #[test] #[serial] fn test_load_api_token_from_env_trims_value() { diff --git a/crates/tokscale-cli/src/main.rs b/crates/tokscale-cli/src/main.rs index 2cd53bcf8..da4fb2b4e 100644 --- a/crates/tokscale-cli/src/main.rs +++ b/crates/tokscale-cli/src/main.rs @@ -3299,6 +3299,10 @@ struct TsDataSummary { struct TsExportMeta { generated_at: String, version: String, + #[serde(skip_serializing_if = "Option::is_none")] + source_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + source_name: Option, date_range: DateRange, } @@ -3311,11 +3315,17 @@ struct TsTokenContributionData { contributions: Vec, } -fn to_ts_token_contribution_data(graph: &tokscale_core::GraphResult) -> TsTokenContributionData { +fn to_ts_token_contribution_data( + graph: &tokscale_core::GraphResult, + source_id: Option, + source_name: Option, +) -> TsTokenContributionData { TsTokenContributionData { meta: TsExportMeta { generated_at: graph.meta.generated_at.clone(), version: graph.meta.version.clone(), + source_id, + source_name, date_range: DateRange { start: graph.meta.date_range_start.clone(), end: graph.meta.date_range_end.clone(), @@ -3765,7 +3775,7 @@ fn run_graph_command( .map_err(|e| anyhow::anyhow!(e))?; let processing_time_ms = start.elapsed().as_millis() as u32; - let output_data = to_ts_token_contribution_data(&graph_result); + let output_data = to_ts_token_contribution_data(&graph_result, None, None); let json_output = serde_json::to_string_pretty(&output_data)?; if let Some(output_path) = output { @@ -4018,7 +4028,29 @@ fn run_submit_command( let api_url = auth::get_api_base_url(); - let submit_payload = to_ts_token_contribution_data(&graph_result); + let (source_id, source_name) = match auth::get_submit_source_id() { + Ok(source_id) => { + let source_name = if source_id.is_some() { + auth::get_submit_source_name() + } else { + None + }; + (source_id, source_name) + } + Err(err) => { + eprintln!( + "{}", + format!( + " Warning: failed to determine submit source identity: {}", + err + ) + .yellow() + ); + (None, None) + } + }; + + let submit_payload = to_ts_token_contribution_data(&graph_result, source_id, source_name); let response = rt.block_on(async { reqwest::Client::new() @@ -4588,6 +4620,27 @@ mod tests { // `defaultClients` set would break the assertions. The wrapper is // covered separately by tests that pass an explicit `&[]`. + #[test] + fn test_to_ts_token_contribution_data_includes_source_metadata() { + let graph = graph_result_with_contributions(vec![daily_contribution( + "2026-03-24", + 100, + 1.25, + "claude", + "claude-sonnet", + )]); + + let output = to_ts_token_contribution_data( + &graph, + Some("source-id-1".to_string()), + Some("Workstation".to_string()), + ); + let payload = serde_json::to_value(output).unwrap(); + + assert_eq!(payload["meta"]["sourceId"], "source-id-1"); + assert_eq!(payload["meta"]["sourceName"], "Workstation"); + } + #[test] fn test_build_client_filter_all_false() { let flags = ClientFlags::default(); diff --git a/crates/tokscale-core/src/sessions/droid.rs b/crates/tokscale-core/src/sessions/droid.rs index 4cd2be3ac..e3f33b207 100644 --- a/crates/tokscale-core/src/sessions/droid.rs +++ b/crates/tokscale-core/src/sessions/droid.rs @@ -284,6 +284,220 @@ mod tests { assert_eq!(get_default_model_from_provider("custom"), "custom-unknown"); } + #[test] + fn test_normalize_model_name_collapses_duplicate_hyphens() { + // Brackets that collapse to nothing can leave adjacent hyphens. + assert_eq!( + normalize_model_name("Claude-[Anthropic]-Opus-4"), + "claude-opus-4" + ); + } + + #[test] + fn test_extract_model_from_jsonl_finds_system_reminder_model() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("session.jsonl"); + std::fs::write( + &path, + "{\"type\":\"user\"}\n{\"type\":\"system-reminder\",\"text\":\"Model: Claude Sonnet 4.5 [Anthropic]\"}\n", + ) + .unwrap(); + assert_eq!( + extract_model_from_jsonl(&path).as_deref(), + Some("claude sonnet 4-5") + ); + } + + #[test] + fn test_extract_model_from_jsonl_returns_none_when_pattern_absent() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("session.jsonl"); + std::fs::write(&path, "{\"type\":\"user\"}\n").unwrap(); + assert!(extract_model_from_jsonl(&path).is_none()); + } + + #[test] + fn test_parse_droid_file_happy_path_with_full_settings() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("abc-123.settings.json"); + std::fs::write( + &path, + r#"{ + "model": "custom:Claude-Sonnet-4-[Anthropic]", + "providerLock": "anthropic", + "providerLockTimestamp": "2026-01-15T12:00:00Z", + "tokenUsage": { + "inputTokens": 100, + "outputTokens": 50, + "cacheCreationTokens": 10, + "cacheReadTokens": 5, + "thinkingTokens": 2 + } + }"#, + ) + .unwrap(); + + let messages = parse_droid_file(&path); + assert_eq!(messages.len(), 1); + let m = &messages[0]; + assert_eq!(m.client, "droid"); + assert_eq!(m.model_id, "claude-sonnet-4"); + assert_eq!(m.provider_id, "anthropic"); + assert_eq!(m.session_id, "abc-123"); + assert_eq!(m.tokens.input, 100); + assert_eq!(m.tokens.output, 50); + assert_eq!(m.tokens.cache_write, 10); + assert_eq!(m.tokens.cache_read, 5); + assert_eq!(m.tokens.reasoning, 2); + // providerLockTimestamp parses to 2026-01-15T12:00:00Z + let expected = chrono::DateTime::parse_from_rfc3339("2026-01-15T12:00:00Z") + .unwrap() + .timestamp_millis(); + assert_eq!(m.timestamp, expected); + } + + #[test] + fn test_parse_droid_file_returns_empty_for_missing_file() { + let dir = tempfile::tempdir().unwrap(); + let missing = dir.path().join("nope.settings.json"); + assert!(parse_droid_file(&missing).is_empty()); + } + + #[test] + fn test_parse_droid_file_returns_empty_for_malformed_json() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("bad.settings.json"); + std::fs::write(&path, "{ not valid json").unwrap(); + assert!(parse_droid_file(&path).is_empty()); + } + + #[test] + fn test_parse_droid_file_returns_empty_when_token_usage_missing() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("empty.settings.json"); + std::fs::write(&path, r#"{"model":"gpt-4o","providerLock":"openai"}"#).unwrap(); + assert!(parse_droid_file(&path).is_empty()); + } + + #[test] + fn test_parse_droid_file_returns_empty_when_all_tokens_are_zero() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("zero.settings.json"); + std::fs::write( + &path, + r#"{ + "model": "claude-sonnet-4", + "providerLock": "anthropic", + "providerLockTimestamp": "2026-01-15T12:00:00Z", + "tokenUsage": { + "inputTokens": 0, + "outputTokens": 0, + "cacheCreationTokens": 0, + "cacheReadTokens": 0, + "thinkingTokens": 0 + } + }"#, + ) + .unwrap(); + assert!(parse_droid_file(&path).is_empty()); + } + + #[test] + fn test_parse_droid_file_infers_provider_from_model_when_lock_missing() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("inferred.settings.json"); + std::fs::write( + &path, + r#"{ + "model": "gpt-4o", + "providerLockTimestamp": "2026-01-15T12:00:00Z", + "tokenUsage": { "inputTokens": 1 } + }"#, + ) + .unwrap(); + + let messages = parse_droid_file(&path); + assert_eq!(messages.len(), 1); + assert_eq!(messages[0].provider_id, "openai"); + assert_eq!(messages[0].model_id, "gpt-4o"); + } + + #[test] + fn test_parse_droid_file_uses_default_model_when_no_model_and_no_jsonl() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("nomodel.settings.json"); + std::fs::write( + &path, + r#"{ + "providerLock": "anthropic", + "providerLockTimestamp": "2026-01-15T12:00:00Z", + "tokenUsage": { "inputTokens": 1 } + }"#, + ) + .unwrap(); + + let messages = parse_droid_file(&path); + assert_eq!(messages.len(), 1); + assert_eq!(messages[0].model_id, "claude-unknown"); + } + + #[test] + fn test_parse_droid_file_extracts_model_from_sibling_jsonl() { + let dir = tempfile::tempdir().unwrap(); + let settings_path = dir.path().join("sess.settings.json"); + let jsonl_path = dir.path().join("sess.jsonl"); + std::fs::write( + &settings_path, + r#"{ + "providerLock": "anthropic", + "providerLockTimestamp": "2026-01-15T12:00:00Z", + "tokenUsage": { "outputTokens": 5 } + }"#, + ) + .unwrap(); + std::fs::write( + &jsonl_path, + "{\"type\":\"system-reminder\",\"text\":\"Model: Claude Haiku 4 [Anthropic]\"}\n", + ) + .unwrap(); + + let messages = parse_droid_file(&settings_path); + assert_eq!(messages.len(), 1); + assert_eq!(messages[0].model_id, "claude haiku 4"); + } + + #[test] + fn test_parse_droid_file_clamps_negative_tokens_and_falls_back_to_mtime() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("neg.settings.json"); + std::fs::write( + &path, + r#"{ + "model": "claude-sonnet-4", + "providerLock": "anthropic", + "tokenUsage": { + "inputTokens": -10, + "outputTokens": 1, + "cacheCreationTokens": -5, + "cacheReadTokens": -3, + "thinkingTokens": -2 + } + }"#, + ) + .unwrap(); + + let messages = parse_droid_file(&path); + assert_eq!(messages.len(), 1); + let m = &messages[0]; + assert_eq!(m.tokens.input, 0); + assert_eq!(m.tokens.output, 1); + assert_eq!(m.tokens.cache_write, 0); + assert_eq!(m.tokens.cache_read, 0); + assert_eq!(m.tokens.reasoning, 0); + // Without providerLockTimestamp, we fall back to file mtime. + assert!(m.timestamp > 0); + } + #[test] fn test_parse_droid_settings_structure() { let json = r#"{ diff --git a/crates/tokscale-core/src/sessions/kilo.rs b/crates/tokscale-core/src/sessions/kilo.rs index 721b325c5..67228adac 100644 --- a/crates/tokscale-core/src/sessions/kilo.rs +++ b/crates/tokscale-core/src/sessions/kilo.rs @@ -109,8 +109,6 @@ pub fn parse_kilo_sqlite_with_fallback( None => continue, }; - let dedup_key = msg.id.or(Some(row_id)); - let model_id = match msg.model_id { Some(m) => m, None => continue, @@ -146,7 +144,7 @@ pub fn parse_kilo_sqlite_with_fallback( msg.cost.unwrap_or(0.0).max(0.0), agent, ); - unified.dedup_key = dedup_key; + unified.dedup_key = msg.id.or(Some(row_id)); messages.push(unified); } @@ -208,8 +206,361 @@ mod tests { assert_eq!(msg.model_id, Some("minimax/m2.5".to_string())); } + fn setup_kilo_db(path: &Path) -> Connection { + let conn = Connection::open(path).unwrap(); + conn.execute_batch( + "CREATE TABLE message ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + data TEXT NOT NULL + );", + ) + .unwrap(); + conn + } + + fn insert_message(conn: &Connection, id: &str, session: &str, data: &str) { + conn.execute( + "INSERT INTO message (id, session_id, data) VALUES (?1, ?2, ?3)", + rusqlite::params![id, session, data], + ) + .unwrap(); + } + #[test] - fn test_parse_kilo_sqlite_reads_assistant_rows() { + fn test_parse_kilo_sqlite_happy_path() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("kilo.db"); + let conn = setup_kilo_db(&db); + insert_message( + &conn, + "m1", + "s1", + r#"{ + "session_id": "s1", + "role": "assistant", + "modelID": "claude-sonnet-4", + "providerID": "anthropic", + "cost": 0.5, + "tokens": { + "input": 100, + "output": 50, + "reasoning": 10, + "cache": {"read": 20, "write": 5} + }, + "time": {"created": 1700000000000.0} + }"#, + ); + drop(conn); + + let messages = parse_kilo_sqlite(&db); + assert_eq!(messages.len(), 1); + let m = &messages[0]; + assert_eq!(m.client, "kilo"); + assert_eq!(m.model_id, "claude-sonnet-4"); + assert_eq!(m.provider_id, "anthropic"); + assert_eq!(m.session_id, "s1"); + assert_eq!(m.tokens.input, 100); + assert_eq!(m.tokens.output, 50); + assert_eq!(m.tokens.reasoning, 10); + assert_eq!(m.tokens.cache_read, 20); + assert_eq!(m.tokens.cache_write, 5); + assert_eq!(m.cost, 0.5); + assert_eq!(m.timestamp, 1700000000000); + } + + #[test] + fn test_parse_kilo_sqlite_returns_empty_for_missing_file() { + let dir = tempfile::tempdir().unwrap(); + let missing = dir.path().join("does-not-exist.db"); + assert!(parse_kilo_sqlite(&missing).is_empty()); + } + + #[test] + fn test_parse_kilo_sqlite_filters_user_messages_via_sql() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("kilo.db"); + let conn = setup_kilo_db(&db); + // user message — the SQL WHERE json_extract role='assistant' filters it. + insert_message( + &conn, + "u1", + "s1", + r#"{ + "role": "user", + "modelID": "whatever", + "tokens": { + "input": 1, "output": 1, + "cache": {"read": 0, "write": 0} + } + }"#, + ); + // assistant message without tokens → filtered by SQL. + insert_message( + &conn, + "a1", + "s1", + r#"{ + "role": "assistant", + "modelID": "whatever" + }"#, + ); + drop(conn); + + assert!(parse_kilo_sqlite(&db).is_empty()); + } + + #[test] + fn test_parse_kilo_sqlite_skips_rows_without_model_id() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("kilo.db"); + let conn = setup_kilo_db(&db); + insert_message( + &conn, + "m1", + "s1", + r#"{ + "role": "assistant", + "providerID": "anthropic", + "tokens": { + "input": 1, "output": 1, + "cache": {"read": 0, "write": 0} + }, + "time": {"created": 1700000000000.0} + }"#, + ); + drop(conn); + assert!(parse_kilo_sqlite(&db).is_empty()); + } + + #[test] + fn test_parse_kilo_sqlite_uses_fallback_timestamp_when_time_missing() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("kilo.db"); + let conn = setup_kilo_db(&db); + insert_message( + &conn, + "m1", + "s1", + r#"{ + "role": "assistant", + "modelID": "claude-sonnet-4", + "tokens": { + "input": 1, "output": 1, + "cache": {"read": 0, "write": 0} + } + }"#, + ); + drop(conn); + + let messages = parse_kilo_sqlite_with_fallback(&db, 4242); + assert_eq!(messages.len(), 1); + assert_eq!(messages[0].timestamp, 4242); + } + + #[test] + fn test_parse_kilo_sqlite_clamps_negative_tokens_to_zero() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("kilo.db"); + let conn = setup_kilo_db(&db); + insert_message( + &conn, + "m1", + "s1", + r#"{ + "role": "assistant", + "modelID": "claude-sonnet-4", + "cost": -2.0, + "tokens": { + "input": -10, "output": -5, "reasoning": -1, + "cache": {"read": -3, "write": -2} + }, + "time": {"created": 1700000000000.0} + }"#, + ); + drop(conn); + + let messages = parse_kilo_sqlite(&db); + assert_eq!(messages.len(), 1); + let m = &messages[0]; + assert_eq!(m.tokens.input, 0); + assert_eq!(m.tokens.output, 0); + assert_eq!(m.tokens.reasoning, 0); + assert_eq!(m.tokens.cache_read, 0); + assert_eq!(m.tokens.cache_write, 0); + assert_eq!(m.cost, 0.0); + } + + #[test] + fn test_parse_kilo_sqlite_falls_back_to_row_session_id() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("kilo.db"); + let conn = setup_kilo_db(&db); + insert_message( + &conn, + "m1", + "s1", + r#"{ + "role": "assistant", + "modelID": "claude-sonnet-4", + "tokens": { + "input": 1, "output": 1, + "cache": {"read": 0, "write": 0} + }, + "time": {"created": 1700000000000.0} + }"#, + ); + drop(conn); + let messages = parse_kilo_sqlite(&db); + assert_eq!(messages.len(), 1); + assert_eq!(messages[0].session_id, "s1"); + } + + #[test] + fn test_parse_kilo_sqlite_prefers_agent_over_mode() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("kilo.db"); + let conn = setup_kilo_db(&db); + insert_message( + &conn, + "m1", + "s1", + r#"{ + "session_id": "s1", + "role": "assistant", + "modelID": "claude-sonnet-4", + "agent": "explorer", + "mode": "chat", + "tokens": { + "input": 1, "output": 1, + "cache": {"read": 0, "write": 0} + }, + "time": {"created": 1700000000000.0} + }"#, + ); + // mode-only fallback + insert_message( + &conn, + "m2", + "s2", + r#"{ + "session_id": "s2", + "role": "assistant", + "modelID": "claude-sonnet-4", + "mode": "only-mode", + "tokens": { + "input": 1, "output": 1, + "cache": {"read": 0, "write": 0} + }, + "time": {"created": 1700000000000.0} + }"#, + ); + drop(conn); + + let messages = parse_kilo_sqlite(&db); + assert_eq!(messages.len(), 2); + let agents: Vec<_> = messages.iter().map(|m| m.agent.clone()).collect(); + assert!(agents.contains(&Some("explorer".to_string()))); + assert!(agents.contains(&Some("only-mode".to_string()))); + } + + #[test] + fn test_parse_kilo_sqlite_infers_provider_from_model_when_absent() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("kilo.db"); + let conn = setup_kilo_db(&db); + insert_message( + &conn, + "m1", + "s1", + r#"{ + "session_id": "s1", + "role": "assistant", + "modelID": "claude-sonnet-4", + "tokens": { + "input": 1, "output": 1, + "cache": {"read": 0, "write": 0} + }, + "time": {"created": 1700000000000.0} + }"#, + ); + drop(conn); + let messages = parse_kilo_sqlite(&db); + assert_eq!(messages.len(), 1); + // inferred_provider_from_model maps claude-* to anthropic. + assert_eq!(messages[0].provider_id, "anthropic"); + } + + #[test] + fn test_parse_kilo_sqlite_defaults_provider_to_kilo_for_unknown_model() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("kilo.db"); + let conn = setup_kilo_db(&db); + insert_message( + &conn, + "m1", + "s1", + r#"{ + "session_id": "s1", + "role": "assistant", + "modelID": "totally-unknown-model-xyz", + "tokens": { + "input": 1, "output": 1, + "cache": {"read": 0, "write": 0} + }, + "time": {"created": 1700000000000.0} + }"#, + ); + drop(conn); + let messages = parse_kilo_sqlite(&db); + assert_eq!(messages.len(), 1); + assert_eq!(messages[0].provider_id, "kilo"); + } + + #[test] + fn test_parse_kilo_sqlite_skips_malformed_json_rows() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("kilo.db"); + let conn = setup_kilo_db(&db); + // Row must still pass the SQL WHERE (role=assistant + tokens IS NOT + // NULL) but simd_json fails on this structure because "cache" is a + // string, not an object. The parser should silently skip it. + insert_message( + &conn, + "m1", + "s1", + r#"{ + "role": "assistant", + "modelID": "claude-sonnet-4", + "tokens": { + "input": 1, "output": 1, "cache": "not-an-object" + } + }"#, + ); + // A valid sibling proves we only skip the bad row, not the whole batch. + insert_message( + &conn, + "m2", + "s1", + r#"{ + "role": "assistant", + "modelID": "claude-sonnet-4", + "tokens": { + "input": 2, "output": 2, + "cache": {"read": 0, "write": 0} + }, + "time": {"created": 1700000000000.0} + }"#, + ); + drop(conn); + + let messages = parse_kilo_sqlite(&db); + assert_eq!(messages.len(), 1); + assert_eq!(messages[0].tokens.input, 2); + } + + #[test] + fn test_parse_kilo_sqlite_with_fallback_reads_assistant_rows() { let dir = TempDir::new().unwrap(); let db_path = create_kilo_sqlite_db(&dir); let conn = Connection::open(&db_path).unwrap(); @@ -253,7 +604,7 @@ mod tests { } #[test] - fn test_parse_kilo_sqlite_skips_invalid_rows_and_clamps_values() { + fn test_parse_kilo_sqlite_with_fallback_skips_invalid_rows_and_clamps_values() { let dir = TempDir::new().unwrap(); let db_path = create_kilo_sqlite_db(&dir); let conn = Connection::open(&db_path).unwrap(); @@ -289,7 +640,17 @@ mod tests { "tokens": {"input": 1, "output": 1, "cache": {"read": 0, "write": 0}} }"#, ); - insert_kilo_message(&conn, "row-invalid-json", "sess-invalid", "{not-json"); + insert_kilo_message( + &conn, + "row-invalid-shape", + "sess-invalid", + r#"{ + "session_id": "sess-invalid", + "role": "assistant", + "modelID": "gpt-5.4", + "tokens": {"input": 1, "output": 1, "cache": "not-an-object"} + }"#, + ); insert_kilo_message( &conn, "row-valid", @@ -326,10 +687,4 @@ mod tests { assert_eq!(msg.agent.as_deref(), Some("debug")); assert_eq!(msg.dedup_key.as_deref(), Some("row-valid")); } - - #[test] - fn test_parse_kilo_sqlite_returns_empty_for_missing_db() { - let messages = parse_kilo_sqlite(std::path::Path::new("/nonexistent/kilo.db")); - assert!(messages.is_empty()); - } } diff --git a/crates/tokscale-core/src/sessions/synthetic.rs b/crates/tokscale-core/src/sessions/synthetic.rs index f02e838a0..ab58630b6 100644 --- a/crates/tokscale-core/src/sessions/synthetic.rs +++ b/crates/tokscale-core/src/sessions/synthetic.rs @@ -362,4 +362,259 @@ mod tests { let result = parse_octofriend_sqlite(Path::new("/nonexistent/path/sqlite.db")); assert!(result.is_empty()); } + + #[test] + fn test_is_synthetic_gateway_combines_model_and_provider_checks() { + assert!(is_synthetic_gateway("hf:org/model", "unknown")); + assert!(is_synthetic_gateway("claude-sonnet-4", "synthetic")); + assert!(!is_synthetic_gateway("claude-sonnet-4", "anthropic")); + } + + #[test] + fn test_normalize_synthetic_model_hf_without_org_slash() { + // "hf:" with no "/" should still strip the prefix. + assert_eq!(normalize_synthetic_model("hf:just-a-name"), "just-a-name"); + } + + #[test] + fn test_normalize_synthetic_model_accounts_without_models_segment() { + // An "accounts/…" path without "/models/" falls through to lowercase. + assert_eq!( + normalize_synthetic_model("accounts/fireworks/other/thing"), + "accounts/fireworks/other/thing" + ); + } + + #[test] + fn test_normalize_synthetic_gateway_fields_returns_false_for_non_gateway() { + let mut model_id = "claude-sonnet-4".to_string(); + let mut provider_id = "anthropic".to_string(); + let matched = normalize_synthetic_gateway_fields(&mut model_id, &mut provider_id); + assert!(!matched); + assert_eq!(model_id, "claude-sonnet-4"); + assert_eq!(provider_id, "anthropic"); + } + + #[test] + fn test_normalize_synthetic_gateway_fields_replaces_empty_provider() { + let mut model_id = "hf:org/x".to_string(); + let mut provider_id = String::new(); + let matched = normalize_synthetic_gateway_fields(&mut model_id, &mut provider_id); + assert!(matched); + assert_eq!(provider_id, "synthetic"); + } + + #[test] + fn test_matches_synthetic_filter_matches_by_client_name_only() { + // Even without gateway markers, a client named "synthetic" qualifies. + assert!(matches_synthetic_filter("synthetic", "gpt-4o", "openai")); + } + + // ===================================================================== + // parse_octofriend_sqlite — Strategy 2 + // ===================================================================== + + use rusqlite::Connection; + + #[test] + fn test_parse_octofriend_sqlite_empty_when_no_known_tables() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("empty.db"); + let conn = Connection::open(&db).unwrap(); + // Unrelated table → parser's introspection probe returns false. + conn.execute_batch("CREATE TABLE other (x INTEGER);") + .unwrap(); + drop(conn); + + assert!(parse_octofriend_sqlite(&db).is_empty()); + } + + #[test] + fn test_parse_octofriend_sqlite_parses_messages_table() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("oct.db"); + let conn = Connection::open(&db).unwrap(); + conn.execute_batch( + "CREATE TABLE messages ( + id TEXT, + model TEXT, + input_tokens INTEGER, + output_tokens INTEGER, + cache_read_tokens INTEGER, + cache_write_tokens INTEGER, + reasoning_tokens INTEGER, + cost REAL, + timestamp REAL, + session_id TEXT, + provider TEXT + );", + ) + .unwrap(); + conn.execute( + "INSERT INTO messages VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11)", + rusqlite::params![ + "msg-1", + "hf:org/model-x", + 100, + 50, + 10, + 5, + 2, + 0.25, + // Timestamp > 1e12 is already in ms. + 1700000000000.0_f64, + "sess-1", + "synthetic", + ], + ) + .unwrap(); + drop(conn); + + let messages = parse_octofriend_sqlite(&db); + assert_eq!(messages.len(), 1); + let m = &messages[0]; + assert_eq!(m.client, "synthetic"); + assert_eq!(m.model_id, "model-x"); + assert_eq!(m.provider_id, "synthetic"); + assert_eq!(m.session_id, "sess-1"); + assert_eq!(m.tokens.input, 100); + assert_eq!(m.tokens.output, 50); + assert_eq!(m.tokens.cache_read, 10); + assert_eq!(m.tokens.cache_write, 5); + assert_eq!(m.tokens.reasoning, 2); + assert_eq!(m.cost, 0.25); + assert_eq!(m.timestamp, 1700000000000); + assert_eq!(m.dedup_key.as_deref(), Some("msg-1")); + } + + #[test] + fn test_parse_octofriend_sqlite_skips_zero_token_rows() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("oct.db"); + let conn = Connection::open(&db).unwrap(); + conn.execute_batch( + "CREATE TABLE messages ( + id TEXT, + model TEXT, + input_tokens INTEGER, + output_tokens INTEGER, + cache_read_tokens INTEGER, + cache_write_tokens INTEGER, + reasoning_tokens INTEGER, + cost REAL, + timestamp REAL, + session_id TEXT, + provider TEXT + );", + ) + .unwrap(); + conn.execute( + "INSERT INTO messages VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11)", + rusqlite::params![ + "zero", + "hf:x/y", + 0, + 0, + 0, + 0, + 0, + 0.0, + 1_700_000_000.0_f64, + "sess-1", + "synthetic", + ], + ) + .unwrap(); + drop(conn); + + assert!(parse_octofriend_sqlite(&db).is_empty()); + } + + #[test] + fn test_parse_octofriend_sqlite_converts_seconds_timestamp_to_ms() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("oct.db"); + let conn = Connection::open(&db).unwrap(); + conn.execute_batch( + "CREATE TABLE messages ( + id TEXT, + model TEXT, + input_tokens INTEGER, + output_tokens INTEGER, + cache_read_tokens INTEGER, + cache_write_tokens INTEGER, + reasoning_tokens INTEGER, + cost REAL, + timestamp REAL, + session_id TEXT, + provider TEXT + );", + ) + .unwrap(); + conn.execute( + "INSERT INTO messages VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11)", + rusqlite::params![ + "msg-sec", + "hf:x/y", + 10, + 0, + 0, + 0, + 0, + 0.0, + // Seconds-since-epoch (<= 1e12) should be multiplied by 1000. + 1_700_000_000.0_f64, + "sess-1", + "synthetic", + ], + ) + .unwrap(); + drop(conn); + + let messages = parse_octofriend_sqlite(&db); + assert_eq!(messages.len(), 1); + assert_eq!(messages[0].timestamp, 1_700_000_000_000); + } + + #[test] + fn test_parse_octofriend_sqlite_falls_back_to_token_usage_table() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("oct.db"); + let conn = Connection::open(&db).unwrap(); + // No "messages" table → the parser's introspection probe picks up + // "token_usage" instead (known-table list includes it). + conn.execute_batch( + "CREATE TABLE token_usage ( + id TEXT, + model TEXT, + input_tokens INTEGER, + output_tokens INTEGER, + timestamp REAL, + session_id TEXT + );", + ) + .unwrap(); + conn.execute( + "INSERT INTO token_usage VALUES (?1,?2,?3,?4,?5,?6)", + rusqlite::params![ + "tu-1", + "accounts/fireworks/models/deepseek-v3", + 200, + 100, + 1_700_000_000_000.0_f64, + "sess-x", + ], + ) + .unwrap(); + drop(conn); + + let messages = parse_octofriend_sqlite(&db); + assert_eq!(messages.len(), 1); + let m = &messages[0]; + assert_eq!(m.model_id, "deepseek-v3"); + assert_eq!(m.provider_id, "synthetic"); + assert_eq!(m.tokens.input, 200); + assert_eq!(m.tokens.output, 100); + assert_eq!(m.dedup_key.as_deref(), Some("tu-1")); + } } diff --git a/packages/frontend/__tests__/api/settingsSourcesRename.test.ts b/packages/frontend/__tests__/api/settingsSourcesRename.test.ts new file mode 100644 index 000000000..bf17401f0 --- /dev/null +++ b/packages/frontend/__tests__/api/settingsSourcesRename.test.ts @@ -0,0 +1,186 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +const mockState = vi.hoisted(() => { + const getSession = vi.fn(); + const updateReturning = vi.fn(); + const revalidateTag = vi.fn(); + + const db = { + update: () => ({ + set: () => ({ + where: () => ({ + returning: updateReturning, + }), + }), + }), + }; + + return { + getSession, + updateReturning, + revalidateTag, + db, + reset() { + getSession.mockReset(); + updateReturning.mockReset(); + revalidateTag.mockReset(); + }, + }; +}); + +vi.mock("@/lib/auth/session", () => ({ + getSession: mockState.getSession, +})); + +vi.mock("@/lib/db", () => ({ + db: mockState.db, + submissions: { + userId: Symbol("userId"), + sourceId: Symbol("sourceId"), + sourceName: Symbol("sourceName"), + updatedAt: Symbol("updatedAt"), + }, +})); + +vi.mock("next/cache", () => ({ + revalidateTag: mockState.revalidateTag, +})); + +type ModuleExports = typeof import("../../src/app/api/settings/sources/[sourceId]/route"); + +let PATCH: ModuleExports["PATCH"]; + +beforeAll(async () => { + const routeModule = await import( + "../../src/app/api/settings/sources/[sourceId]/route" + ); + PATCH = routeModule.PATCH; +}); + +beforeEach(() => { + mockState.reset(); +}); + +function buildRequest(body: unknown, { raw }: { raw?: string } = {}) { + return new Request("http://localhost:3000/api/settings/sources/source:abc", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: raw ?? JSON.stringify(body), + }); +} + +describe("PATCH /api/settings/sources/[sourceId]", () => { + it("returns 401 when unauthenticated", async () => { + mockState.getSession.mockResolvedValue(null); + + const response = await PATCH(buildRequest({ name: "Work" }), { + params: Promise.resolve({ sourceId: "source:abc" }), + }); + + expect(response.status).toBe(401); + expect(mockState.updateReturning).not.toHaveBeenCalled(); + }); + + it("returns 400 on invalid source id", async () => { + mockState.getSession.mockResolvedValue({ id: "user-1", username: "alice" }); + + const response = await PATCH(buildRequest({ name: "Work" }), { + params: Promise.resolve({ sourceId: "source:%ZZ" }), + }); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: "Invalid source id" }); + }); + + it("returns 400 when body is not JSON", async () => { + mockState.getSession.mockResolvedValue({ id: "user-1", username: "alice" }); + + const response = await PATCH(buildRequest(null, { raw: "not-json" }), { + params: Promise.resolve({ sourceId: "source:abc" }), + }); + + expect(response.status).toBe(400); + }); + + it("rejects control characters in name", async () => { + mockState.getSession.mockResolvedValue({ id: "user-1", username: "alice" }); + + const response = await PATCH(buildRequest({ name: "Evil\u0000Name" }), { + params: Promise.resolve({ sourceId: "source:abc" }), + }); + + expect(response.status).toBe(400); + expect(mockState.updateReturning).not.toHaveBeenCalled(); + }); + + it("renames a source and returns the updated row", async () => { + mockState.getSession.mockResolvedValue({ id: "user-1", username: "alice" }); + mockState.updateReturning.mockResolvedValue([ + { sourceId: "abc", sourceName: "Work Laptop" }, + ]); + + const response = await PATCH(buildRequest({ name: " Work Laptop " }), { + params: Promise.resolve({ sourceId: "source:abc" }), + }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + success: true, + source: { sourceId: "abc", sourceName: "Work Laptop" }, + }); + expect(mockState.revalidateTag).toHaveBeenCalledWith("user:alice", "max"); + }); + + it("allows clearing the name with null", async () => { + mockState.getSession.mockResolvedValue({ id: "user-1", username: "alice" }); + mockState.updateReturning.mockResolvedValue([ + { sourceId: "abc", sourceName: null }, + ]); + + const response = await PATCH(buildRequest({ name: null }), { + params: Promise.resolve({ sourceId: "source:abc" }), + }); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.source.sourceName).toBeNull(); + }); + + it("treats an empty string as null", async () => { + mockState.getSession.mockResolvedValue({ id: "user-1", username: "alice" }); + mockState.updateReturning.mockResolvedValue([ + { sourceId: "abc", sourceName: null }, + ]); + + const response = await PATCH(buildRequest({ name: " " }), { + params: Promise.resolve({ sourceId: "source:abc" }), + }); + + expect(response.status).toBe(200); + }); + + it("returns 404 when the source does not belong to the user", async () => { + mockState.getSession.mockResolvedValue({ id: "user-1", username: "alice" }); + mockState.updateReturning.mockResolvedValue([]); + + const response = await PATCH(buildRequest({ name: "Work" }), { + params: Promise.resolve({ sourceId: "source:abc" }), + }); + + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ error: "Source not found" }); + }); + + it("resolves __legacy__ to the unsourced row", async () => { + mockState.getSession.mockResolvedValue({ id: "user-1", username: "alice" }); + mockState.updateReturning.mockResolvedValue([ + { sourceId: null, sourceName: "Legacy" }, + ]); + + const response = await PATCH(buildRequest({ name: "Legacy" }), { + params: Promise.resolve({ sourceId: "__legacy__" }), + }); + + expect(response.status).toBe(200); + }); +}); diff --git a/packages/frontend/__tests__/api/submit.test.ts b/packages/frontend/__tests__/api/submit.test.ts index b65a997ae..5892954c3 100644 --- a/packages/frontend/__tests__/api/submit.test.ts +++ b/packages/frontend/__tests__/api/submit.test.ts @@ -143,13 +143,6 @@ describe('POST /api/submit - Client-Level Merge', () => { expect(data.contributions[0].clients[0].client).toBe('hermes'); }); - it('should support zed client in submission payload', () => { - const data = createMockSubmissionData({ clients: ['zed'] }); - - expect(data.summary.clients).toContain('zed'); - expect(data.contributions[0].clients[0].client).toBe('zed'); - }); - it('should pass validation for kilo client submissions', () => { const payload = { meta: { generatedAt: new Date().toISOString(), version: '1.0.0', dateRange: { start: '2024-12-01', end: '2024-12-01' } }, @@ -191,42 +184,243 @@ describe('POST /api/submit - Client-Level Merge', () => { expect(result.errors).toHaveLength(0); }); - it('should pass validation for zed client submissions', () => { + it("should accept source metadata for machine-scoped submissions", () => { const payload = { - meta: { generatedAt: new Date().toISOString(), version: '1.0.0', dateRange: { start: '2024-12-01', end: '2024-12-01' } }, - summary: { totalTokens: 1500, totalCost: 1.5, totalDays: 1, activeDays: 1, averagePerDay: 1.5, maxCostInSingleDay: 1.5, clients: ['zed' as const], models: ['claude-sonnet-4'] }, - years: [{ year: '2024', totalTokens: 1500, totalCost: 1.5, range: { start: '2024-12-01', end: '2024-12-01' } }], + meta: { + generatedAt: new Date().toISOString(), + version: "1.0.0", + sourceId: "machine-123", + sourceName: "Workstation", + dateRange: { start: "2024-12-01", end: "2024-12-01" }, + }, + summary: { + totalTokens: 1500, + totalCost: 1.5, + totalDays: 1, + activeDays: 1, + averagePerDay: 1.5, + maxCostInSingleDay: 1.5, + clients: ["claude" as const], + models: ["claude-sonnet-4"], + }, + years: [{ + year: "2024", + totalTokens: 1500, + totalCost: 1.5, + range: { start: "2024-12-01", end: "2024-12-01" }, + }], contributions: [{ - date: '2024-12-01', + date: "2024-12-01", totals: { tokens: 1500, cost: 1.5, messages: 5 }, intensity: 2 as const, - tokenBreakdown: { input: 1000, output: 500, cacheRead: 0, cacheWrite: 0, reasoning: 0 }, - clients: [{ client: 'zed' as const, modelId: 'claude-sonnet-4', tokens: { input: 1000, output: 500, cacheRead: 0, cacheWrite: 0, reasoning: 0 }, cost: 1.5, messages: 5 }], + tokenBreakdown: { + input: 1000, + output: 500, + cacheRead: 0, + cacheWrite: 0, + reasoning: 0, + }, + clients: [{ + client: "claude" as const, + modelId: "claude-sonnet-4", + tokens: { + input: 1000, + output: 500, + cacheRead: 0, + cacheWrite: 0, + reasoning: 0, + }, + cost: 1.5, + messages: 5, + }], }], }; + const result = validateSubmission(payload); expect(result.valid).toBe(true); - expect(result.errors).toHaveLength(0); + expect(result.data?.meta.sourceId).toBe("machine-123"); + expect(result.data?.meta.sourceName).toBe("Workstation"); }); - it('should pass validation for legacy zed source submissions', () => { + it.each([ + ["null byte", "Work\u0000Laptop"], + ["ANSI escape", "Work\u001b[31mLaptop"], + ["zero-width joiner", "Work\u200dLaptop"], + ["right-to-left override", "Work\u202eLaptop"], + ])("rejects control character in sourceName (%s)", (_label, evilName) => { const payload = { - meta: { generatedAt: new Date().toISOString(), version: '1.0.0', dateRange: { start: '2024-12-01', end: '2024-12-01' } }, - summary: { totalTokens: 1500, totalCost: 1.5, totalDays: 1, activeDays: 1, averagePerDay: 1.5, maxCostInSingleDay: 1.5, sources: ['zed'], models: ['claude-sonnet-4'] }, - years: [{ year: '2024', totalTokens: 1500, totalCost: 1.5, range: { start: '2024-12-01', end: '2024-12-01' } }], + meta: { + generatedAt: new Date().toISOString(), + version: "1.0.0", + sourceId: "machine-123", + sourceName: evilName, + dateRange: { start: "2024-12-01", end: "2024-12-01" }, + }, + summary: { + totalTokens: 1500, + totalCost: 1.5, + totalDays: 1, + activeDays: 1, + averagePerDay: 1.5, + maxCostInSingleDay: 1.5, + clients: ["claude" as const], + models: ["claude-sonnet-4"], + }, + years: [{ + year: "2024", + totalTokens: 1500, + totalCost: 1.5, + range: { start: "2024-12-01", end: "2024-12-01" }, + }], contributions: [{ - date: '2024-12-01', + date: "2024-12-01", totals: { tokens: 1500, cost: 1.5, messages: 5 }, intensity: 2 as const, - tokenBreakdown: { input: 1000, output: 500, cacheRead: 0, cacheWrite: 0, reasoning: 0 }, - sources: [{ source: 'zed', modelId: 'claude-sonnet-4', tokens: { input: 1000, output: 500, cacheRead: 0, cacheWrite: 0, reasoning: 0 }, cost: 1.5, messages: 5 }], + tokenBreakdown: { + input: 1000, + output: 500, + cacheRead: 0, + cacheWrite: 0, + reasoning: 0, + }, + clients: [{ + client: "claude" as const, + modelId: "claude-sonnet-4", + tokens: { + input: 1000, + output: 500, + cacheRead: 0, + cacheWrite: 0, + reasoning: 0, + }, + cost: 1.5, + messages: 5, + }], }], }; + + const result = validateSubmission(payload); + + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.includes("control characters"))).toBe(true); + }); + + it("rejects control character in sourceId (zero-width joiner)", () => { + const payload = { + meta: { + generatedAt: new Date().toISOString(), + version: "1.0.0", + sourceId: "machine\u200d123", + sourceName: "Workstation", + dateRange: { start: "2024-12-01", end: "2024-12-01" }, + }, + summary: { + totalTokens: 1500, + totalCost: 1.5, + totalDays: 1, + activeDays: 1, + averagePerDay: 1.5, + maxCostInSingleDay: 1.5, + clients: ["claude" as const], + models: ["claude-sonnet-4"], + }, + years: [{ + year: "2024", + totalTokens: 1500, + totalCost: 1.5, + range: { start: "2024-12-01", end: "2024-12-01" }, + }], + contributions: [{ + date: "2024-12-01", + totals: { tokens: 1500, cost: 1.5, messages: 5 }, + intensity: 2 as const, + tokenBreakdown: { + input: 1000, + output: 500, + cacheRead: 0, + cacheWrite: 0, + reasoning: 0, + }, + clients: [{ + client: "claude" as const, + modelId: "claude-sonnet-4", + tokens: { + input: 1000, + output: 500, + cacheRead: 0, + cacheWrite: 0, + reasoning: 0, + }, + cost: 1.5, + messages: 5, + }], + }], + }; + + const result = validateSubmission(payload); + + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.includes("control characters"))).toBe(true); + }); + + it("should normalize blank source metadata to undefined", () => { + const payload = { + meta: { + generatedAt: new Date().toISOString(), + version: "1.0.0", + sourceId: " ", + sourceName: "", + dateRange: { start: "2024-12-01", end: "2024-12-01" }, + }, + summary: { + totalTokens: 1500, + totalCost: 1.5, + totalDays: 1, + activeDays: 1, + averagePerDay: 1.5, + maxCostInSingleDay: 1.5, + clients: ["claude" as const], + models: ["claude-sonnet-4"], + }, + years: [{ + year: "2024", + totalTokens: 1500, + totalCost: 1.5, + range: { start: "2024-12-01", end: "2024-12-01" }, + }], + contributions: [{ + date: "2024-12-01", + totals: { tokens: 1500, cost: 1.5, messages: 5 }, + intensity: 2 as const, + tokenBreakdown: { + input: 1000, + output: 500, + cacheRead: 0, + cacheWrite: 0, + reasoning: 0, + }, + clients: [{ + client: "claude" as const, + modelId: "claude-sonnet-4", + tokens: { + input: 1000, + output: 500, + cacheRead: 0, + cacheWrite: 0, + reasoning: 0, + }, + cost: 1.5, + messages: 5, + }], + }], + }; + const result = validateSubmission(payload); expect(result.valid).toBe(true); - expect(result.errors).toHaveLength(0); + expect(result.data?.meta.sourceId).toBeUndefined(); + expect(result.data?.meta.sourceName).toBeUndefined(); }); }); @@ -373,16 +567,10 @@ describe('POST /api/submit - Client-Level Merge', () => { expect(opencodeInDay).toBeDefined(); }); - it('should handle concurrent submissions without data loss', () => { - // This is tested at the database level with .for('update') locks - // Here we just verify the concept - const submission1Clients = ['claude']; - const submission2Clients = ['cursor']; - - // Both should be present after sequential processing - const finalClients = new Set([...submission1Clients, ...submission2Clients]); - expect(finalClients.size).toBe(2); - }); + // Concurrent-submit correctness is exercised in dbHelpers.test.ts + // (resolveSubmissionScope) and submitAuth.test.ts (409 path). The + // actual DB-level races are guarded by SELECT ... FOR UPDATE and the + // onConflictDoNothing fallback in the route handler. it('should treat contribution clients as submitted even if summary.clients is incomplete', () => { const data = createMockSubmissionData({ diff --git a/packages/frontend/__tests__/api/submitAuth.test.ts b/packages/frontend/__tests__/api/submitAuth.test.ts index 9852e0d9d..b2659b2b4 100644 --- a/packages/frontend/__tests__/api/submitAuth.test.ts +++ b/packages/frontend/__tests__/api/submitAuth.test.ts @@ -3,49 +3,126 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const mockState = vi.hoisted(() => { const authenticatePersonalToken = vi.fn(); const validateSubmission = vi.fn(); - const generateSubmissionHash = vi.fn(() => "submission-hash"); const revalidateTag = vi.fn(); - const revalidateUsernamePaths = vi.fn(); + const revalidatePath = vi.fn(); const mergeClientBreakdowns = vi.fn(); const recalculateDayTotals = vi.fn(); const buildModelBreakdown = vi.fn(); const clientContributionToBreakdownData = vi.fn(); const mergeTimestampMs = vi.fn(); + const resolveSubmissionScope = vi.fn(); + const selectResults: Array>> = []; + + const submissions = { + id: "submissions.id", + userId: "submissions.userId", + sourceId: "submissions.sourceId", + totalTokens: "submissions.totalTokens", + totalCost: "submissions.totalCost", + inputTokens: "submissions.inputTokens", + outputTokens: "submissions.outputTokens", + cacheCreationTokens: "submissions.cacheCreationTokens", + cacheReadTokens: "submissions.cacheReadTokens", + schemaVersion: "submissions.schemaVersion", + updatedAt: "submissions.updatedAt", + cliVersion: "submissions.cliVersion", + dateStart: "submissions.dateStart", + dateEnd: "submissions.dateEnd", + sourcesUsed: "submissions.sourcesUsed", + modelsUsed: "submissions.modelsUsed", + }; + + const dailyBreakdown = { + id: "dailyBreakdown.id", + submissionId: "dailyBreakdown.submissionId", + date: "dailyBreakdown.date", + tokens: "dailyBreakdown.tokens", + cost: "dailyBreakdown.cost", + inputTokens: "dailyBreakdown.inputTokens", + outputTokens: "dailyBreakdown.outputTokens", + timestampMs: "dailyBreakdown.timestampMs", + sourceBreakdown: "dailyBreakdown.sourceBreakdown", + }; + + const apiTokens = { + id: "apiTokens.id", + lastUsedAt: "apiTokens.lastUsedAt", + }; + + const eq = vi.fn(() => "eq"); + const and = vi.fn(() => "and"); + const isNull = vi.fn(() => "isNull"); + const sql = Object.assign( + () => ({ + as: () => ({}), + }), + { + raw: vi.fn(), + } + ); const db = { transaction: vi.fn(), + select: vi.fn(() => { + const builder = { + from: vi.fn(() => builder), + where: vi.fn(() => builder), + innerJoin: vi.fn(() => builder), + then: (resolve: (value: unknown) => unknown) => + resolve(selectResults.shift() ?? []), + }; + + return builder; + }), }; return { authenticatePersonalToken, validateSubmission, - generateSubmissionHash, revalidateTag, - revalidateUsernamePaths, + revalidatePath, mergeClientBreakdowns, recalculateDayTotals, buildModelBreakdown, clientContributionToBreakdownData, mergeTimestampMs, + resolveSubmissionScope, + apiTokens, + submissions, + dailyBreakdown, + eq, + and, + isNull, + sql, db, reset() { authenticatePersonalToken.mockReset(); validateSubmission.mockReset(); - generateSubmissionHash.mockClear(); revalidateTag.mockClear(); - revalidateUsernamePaths.mockReset(); + revalidatePath.mockClear(); mergeClientBreakdowns.mockReset(); recalculateDayTotals.mockReset(); buildModelBreakdown.mockReset(); clientContributionToBreakdownData.mockReset(); mergeTimestampMs.mockReset(); + resolveSubmissionScope.mockReset(); db.transaction.mockReset(); + db.select.mockClear(); + selectResults.length = 0; + eq.mockClear(); + and.mockClear(); + isNull.mockClear(); + sql.raw.mockClear(); + }, + pushSelectResult(rows: Array>) { + selectResults.push(rows); }, }; }); vi.mock("next/cache", () => ({ revalidateTag: mockState.revalidateTag, + revalidatePath: mockState.revalidatePath, })); vi.mock("@/lib/auth/personalTokens", () => ({ @@ -54,43 +131,13 @@ vi.mock("@/lib/auth/personalTokens", () => ({ vi.mock("@/lib/db", () => ({ db: mockState.db, - apiTokens: { - id: "apiTokens.id", - }, - submissions: { - id: "submissions.id", - userId: "submissions.userId", - totalTokens: "submissions.totalTokens", - totalCost: "submissions.totalCost", - inputTokens: "submissions.inputTokens", - outputTokens: "submissions.outputTokens", - cacheCreationTokens: "submissions.cacheCreationTokens", - cacheReadTokens: "submissions.cacheReadTokens", - reasoningTokens: "submissions.reasoningTokens", - dateStart: "submissions.dateStart", - dateEnd: "submissions.dateEnd", - sourcesUsed: "submissions.sourcesUsed", - modelsUsed: "submissions.modelsUsed", - cliVersion: "submissions.cliVersion", - submissionHash: "submissions.submissionHash", - schemaVersion: "submissions.schemaVersion", - }, - dailyBreakdown: { - id: "dailyBreakdown.id", - submissionId: "dailyBreakdown.submissionId", - date: "dailyBreakdown.date", - timestampMs: "dailyBreakdown.timestampMs", - sourceBreakdown: "dailyBreakdown.sourceBreakdown", - tokens: "dailyBreakdown.tokens", - cost: "dailyBreakdown.cost", - inputTokens: "dailyBreakdown.inputTokens", - outputTokens: "dailyBreakdown.outputTokens", - }, + apiTokens: mockState.apiTokens, + submissions: mockState.submissions, + dailyBreakdown: mockState.dailyBreakdown, })); vi.mock("@/lib/validation/submission", () => ({ validateSubmission: mockState.validateSubmission, - generateSubmissionHash: mockState.generateSubmissionHash, })); vi.mock("@/lib/db/helpers", () => ({ @@ -99,20 +146,25 @@ vi.mock("@/lib/db/helpers", () => ({ buildModelBreakdown: mockState.buildModelBreakdown, clientContributionToBreakdownData: mockState.clientContributionToBreakdownData, mergeTimestampMs: mockState.mergeTimestampMs, + resolveSubmissionScope: mockState.resolveSubmissionScope, })); -vi.mock("@/lib/db/usernameLookup", () => ({ - normalizeUsernameCacheKey: (username: string) => username.toLowerCase(), - revalidateUsernamePaths: mockState.revalidateUsernamePaths, +vi.mock("drizzle-orm", () => ({ + eq: mockState.eq, + and: mockState.and, + isNull: mockState.isNull, + sql: mockState.sql, })); type ModuleExports = typeof import("../../src/app/api/submit/route"); let POST: ModuleExports["POST"]; +let SourceIdentityRequiredError: ModuleExports["SourceIdentityRequiredError"]; beforeAll(async () => { const routeModule = await import("../../src/app/api/submit/route"); POST = routeModule.POST; + SourceIdentityRequiredError = routeModule.SourceIdentityRequiredError; }); beforeEach(() => { @@ -201,7 +253,7 @@ describe("POST /api/submit auth path", () => { }); }); - it("accepts the bearer scheme case-insensitively", async () => { + it("returns 409 when source identity is required after scoped mode begins", async () => { mockState.authenticatePersonalToken.mockResolvedValue({ status: "valid", tokenId: "token-1", @@ -213,65 +265,131 @@ describe("POST /api/submit auth path", () => { expiresAt: null, }); mockState.validateSubmission.mockReturnValue({ - valid: false, - data: null, - errors: ["bad payload"], + valid: true, + data: { + meta: { + generatedAt: new Date().toISOString(), + version: "1.0.0", + dateRange: { start: "2024-12-01", end: "2024-12-01" }, + }, + summary: { + totalTokens: 1500, + totalCost: 1.5, + totalDays: 1, + activeDays: 1, + averagePerDay: 1.5, + maxCostInSingleDay: 1.5, + clients: ["claude"], + models: ["claude-sonnet-4"], + }, + years: [], + contributions: [ + { + date: "2024-12-01", + totals: { tokens: 1500, cost: 1.5, messages: 5 }, + intensity: 2, + tokenBreakdown: { + input: 1000, + output: 500, + cacheRead: 0, + cacheWrite: 0, + reasoning: 0, + }, + clients: [ + { + client: "claude", + modelId: "claude-sonnet-4", + tokens: { + input: 1000, + output: 500, + cacheRead: 0, + cacheWrite: 0, + reasoning: 0, + }, + cost: 1.5, + messages: 5, + }, + ], + }, + ], + }, + errors: [], + warnings: [], }); + mockState.db.transaction.mockRejectedValue(new SourceIdentityRequiredError()); const response = await POST( new Request("http://localhost:3000/api/submit", { method: "POST", headers: { - Authorization: "bearer tt_valid", + Authorization: "Bearer tt_valid", "Content-Type": "application/json", }, body: JSON.stringify({ meta: {}, contributions: [] }), }) ); - expect(response.status).toBe(400); - expect(mockState.authenticatePersonalToken).toHaveBeenCalledWith("tt_valid", { - touchLastUsedAt: false, + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ + error: "Source identity is required for accounts with source-scoped submissions", + hint: "Upgrade the CLI or set TOKSCALE_SOURCE_ID before submitting from this machine.", }); }); - it("revalidates username ISR paths after a successful submit", async () => { + it("end-to-end rollout flow: scoped account rejects an unsourced submit with 409+hint", async () => { + // Rollout scenario: a user has already submitted from a new-CLI machine + // with meta.sourceId, so their submissions table has source-scoped rows. + // An older CLI on a different machine then submits WITHOUT sourceId. + // We want the real route code (transaction callback → resolveSubmissionScope + // → SourceIdentityRequiredError throw → outer catch) to end at 409+hint. mockState.authenticatePersonalToken.mockResolvedValue({ status: "valid", tokenId: "token-1", userId: "user-1", - username: "Alice", + username: "alice", displayName: "Alice", avatarUrl: null, isAdmin: false, expiresAt: null, }); - mockState.validateSubmission.mockReturnValue({ valid: true, data: { + // Note: NO meta.sourceId — simulates old CLI. meta: { - version: "2.0.0", - dateRange: { start: "2026-04-30", end: "2026-04-30" }, + generatedAt: new Date().toISOString(), + version: "0.9.0", + dateRange: { start: "2024-12-01", end: "2024-12-01" }, }, summary: { - clients: ["codex"], + totalTokens: 100, + totalCost: 0.1, + totalDays: 1, + activeDays: 1, + averagePerDay: 0.1, + maxCostInSingleDay: 0.1, + clients: ["claude"], + models: ["claude-sonnet-4"], }, + years: [], contributions: [ { - date: "2026-04-30", - timestampMs: 123, + date: "2024-12-01", + totals: { tokens: 100, cost: 0.1, messages: 1 }, + intensity: 1, + tokenBreakdown: { + input: 60, + output: 40, + cacheRead: 0, + cacheWrite: 0, + reasoning: 0, + }, clients: [ { - client: "codex", - modelId: "gpt-5.5", - tokens: 12, - cost: 0.5, - input: 7, - output: 5, - cacheRead: 0, - cacheWrite: 0, - reasoning: 0, + client: "claude", + modelId: "claude-sonnet-4", + tokens: { input: 60, output: 40, cacheRead: 0, cacheWrite: 0, reasoning: 0 }, + cost: 0.1, messages: 1, }, ], @@ -282,93 +400,530 @@ describe("POST /api/submit auth path", () => { warnings: [], }); - mockState.clientContributionToBreakdownData.mockReturnValue({ - tokens: 12, - cost: 0.5, - input: 7, - output: 5, + // The account already has a source-scoped row; resolveSubmissionScope + // sees sourceId=null and rows with non-null source_id → reject. + mockState.resolveSubmissionScope.mockReturnValue({ + kind: "rejectMissingSourceIdentity", + }); + + // Drive the real route code through the transaction callback so + // SourceIdentityRequiredError is thrown from within the route (not + // mocked at the transaction boundary). + mockState.db.transaction.mockImplementation(async (callback) => { + const selectResults = [ + // 3a scope select: returns an existing source-scoped row. + [{ id: "submission-scoped", sourceId: "machine-a" }], + ]; + const tx = { + update: vi.fn(() => ({ + set: vi.fn(() => ({ + where: vi.fn(async () => []), + })), + })), + select: vi.fn(() => { + const builder = { + from: vi.fn(() => builder), + innerJoin: vi.fn(() => builder), + where: vi.fn(() => builder), + limit: vi.fn(async () => selectResults.shift() ?? []), + for: vi.fn(() => builder), + then: (resolve: (value: unknown) => unknown) => + resolve(selectResults.shift() ?? []), + }; + return builder; + }), + insert: vi.fn(() => ({ values: vi.fn(async () => []) })), + execute: vi.fn(async () => []), + }; + + return callback(tx as never); + }); + + const response = await POST( + new Request("http://localhost:3000/api/submit", { + method: "POST", + headers: { + Authorization: "Bearer tt_valid", + "Content-Type": "application/json", + }, + body: JSON.stringify({ meta: {}, contributions: [] }), + }) + ); + + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ + error: "Source identity is required for accounts with source-scoped submissions", + hint: "Upgrade the CLI or set TOKSCALE_SOURCE_ID before submitting from this machine.", + }); + // resolveSubmissionScope saw the existing scoped row and a null sourceId. + expect(mockState.resolveSubmissionScope).toHaveBeenCalledWith( + [{ id: "submission-scoped", sourceId: "machine-a" }], + null + ); + }); + + it('returns mode "merge" when the transaction falls back to an existing submission row', async () => { + mockState.authenticatePersonalToken.mockResolvedValue({ + status: "valid", + tokenId: "token-1", + userId: "user-1", + username: "alice", + displayName: "Alice", + avatarUrl: null, + isAdmin: false, + expiresAt: null, + }); + mockState.validateSubmission.mockReturnValue({ + valid: true, + data: { + meta: { + generatedAt: new Date().toISOString(), + version: "1.0.0", + dateRange: { start: "2024-12-01", end: "2024-12-01" }, + }, + summary: { + totalTokens: 1500, + totalCost: 1.5, + totalDays: 1, + activeDays: 1, + averagePerDay: 1.5, + maxCostInSingleDay: 1.5, + clients: ["claude"], + models: ["claude-sonnet-4"], + }, + years: [], + contributions: [ + { + date: "2024-12-01", + totals: { tokens: 1500, cost: 1.5, messages: 5 }, + intensity: 2, + tokenBreakdown: { + input: 1000, + output: 500, + cacheRead: 0, + cacheWrite: 0, + reasoning: 0, + }, + clients: [ + { + client: "claude", + modelId: "claude-sonnet-4", + tokens: { + input: 1000, + output: 500, + cacheRead: 0, + cacheWrite: 0, + reasoning: 0, + }, + cost: 1.5, + messages: 5, + }, + ], + }, + ], + }, + errors: [], + warnings: [], + }); + const mockModelData = { + tokens: 1500, + cost: 1.5, + input: 1000, + output: 500, cacheRead: 0, cacheWrite: 0, reasoning: 0, - messages: 1, - }); + messages: 5, + }; + const mockSourceBreakdown = { + claude: { + ...mockModelData, + models: { + "claude-sonnet-4": { ...mockModelData }, + }, + }, + }; + + mockState.clientContributionToBreakdownData.mockReturnValue(mockModelData); + mockState.mergeClientBreakdowns.mockImplementation((_existing: unknown, incoming: unknown) => incoming); mockState.recalculateDayTotals.mockReturnValue({ - tokens: 12, - cost: 0.5, - inputTokens: 7, - outputTokens: 5, - }); - mockState.buildModelBreakdown.mockReturnValue({ "gpt-5.5": 12 }); - mockState.mergeTimestampMs.mockImplementation((_existing: unknown, incoming: unknown) => incoming); - - const selectResults = [ - [], - [], - [{ - totalTokens: 12, - totalCost: "0.5000", - inputTokens: 7, - outputTokens: 5, - dateStart: "2026-04-30", - dateEnd: "2026-04-30", - activeDays: 1, - rowCount: 1, - }], - [{ - sourceBreakdown: { - codex: { - cacheRead: 0, - cacheWrite: 0, - reasoning: 0, - modelId: "gpt-5.5", - models: { "gpt-5.5": { tokens: 12 } }, + tokens: 1500, + cost: 1.5, + inputTokens: 1000, + outputTokens: 500, + cacheReadTokens: 0, + cacheWriteTokens: 0, + reasoningTokens: 0, + }); + mockState.buildModelBreakdown.mockReturnValue({ + "claude-sonnet-4": 1500, + }); + mockState.mergeTimestampMs.mockReturnValue(null); + mockState.resolveSubmissionScope.mockReturnValue({ kind: "create" }); + + mockState.db.transaction.mockImplementation(async (callback) => { + const selectResults = [ + [], // 3a: scope select (.for → builder, awaited via .then) + [{ id: "submission-1" }], // 3a fallback: .for(...).limit(1) → consumed by .limit + [], // 3b: daily breakdown FOR UPDATE (.for → builder, awaited via .then) + [ + { + totalTokens: 1500, + totalCost: "1.5000", + inputTokens: 1000, + outputTokens: 500, + dateStart: "2024-12-01", + dateEnd: "2024-12-01", + activeDays: 1, + rowCount: 1, }, + ], // 3d: aggregates + [{ sourceBreakdown: mockSourceBreakdown }], // 3d: allDays + [ + { + totalTokens: 1500, + totalCost: "1.5000", + dateStart: "2024-12-01", + dateEnd: "2024-12-01", + }, + ], // metrics: user aggregates + [{ activeDays: 1 }], // metrics: user day aggregates + [{ sourcesUsed: ["claude"] }], // metrics: user submissions + ]; + const tx = { + update: vi.fn(() => { + const builder = { + set: vi.fn(() => ({ + where: vi.fn(async () => []), + })), + }; + return builder; + }), + select: vi.fn(() => { + const builder = { + from: vi.fn(() => builder), + innerJoin: vi.fn(() => builder), + where: vi.fn(() => builder), + limit: vi.fn(async () => selectResults.shift() ?? []), + // .for() is chainable — drizzle allows `.for(...).limit(1)` as + // well as terminal `.for(...)`. Return the builder so chained + // calls work; terminal `.for(...)` gets consumed via .then. + for: vi.fn(() => builder), + then: (resolve: (value: unknown) => unknown) => + resolve(selectResults.shift() ?? []), + }; + return builder; + }), + insert: vi.fn((table: unknown) => { + if (table === mockState.submissions) { + return { + values: vi.fn(() => ({ + onConflictDoNothing: vi.fn(() => ({ + returning: vi.fn(async () => []), + })), + })), + }; + } + + return { + values: vi.fn(async () => []), + }; + }), + execute: vi.fn(async () => []), + }; + + return callback(tx as never); + }); + + const response = await POST( + new Request("http://localhost:3000/api/submit", { + method: "POST", + headers: { + Authorization: "Bearer tt_valid", + "Content-Type": "application/json", }, - }], - ]; + body: JSON.stringify({ meta: {}, contributions: [] }), + }) + ); + const body = await response.json(); - function makeAwaitableBuilder(result: unknown) { - const builder = { - from: vi.fn(() => builder), - where: vi.fn(() => builder), - for: vi.fn(() => builder), - limit: vi.fn(() => builder), - then: (resolve: (value: unknown) => unknown) => Promise.resolve(resolve(result)), - }; - return builder; - } + expect(response.status).toBe(200); + expect(body.mode).toBe("merge"); + expect(body.metrics).toEqual({ + totalTokens: 1500, + totalCost: 1.5, + dateRange: { + start: "2024-12-01", + end: "2024-12-01", + }, + activeDays: 1, + clients: ["claude"], + }); + }); + + it("preserves a user-renamed sourceName — CLI default does NOT overwrite on subsequent merges", async () => { + mockState.authenticatePersonalToken.mockResolvedValue({ + status: "valid", + tokenId: "token-1", + userId: "user-1", + username: "alice", + displayName: "Alice", + avatarUrl: null, + isAdmin: false, + expiresAt: null, + }); + mockState.validateSubmission.mockReturnValue({ + valid: true, + data: { + meta: { + generatedAt: new Date().toISOString(), + version: "1.0.0", + sourceId: "machine-a", + // CLI's default hostname-derived name — the whole point of the + // test is that the submit path must NOT clobber the user's + // rename with this. + sourceName: "CLI on junhoyeo-mbp", + dateRange: { start: "2024-12-01", end: "2024-12-01" }, + }, + summary: { + totalTokens: 100, + totalCost: 0.1, + totalDays: 1, + activeDays: 1, + averagePerDay: 0.1, + maxCostInSingleDay: 0.1, + clients: ["claude"], + models: ["claude-sonnet-4"], + }, + years: [], + contributions: [ + { + date: "2024-12-01", + totals: { tokens: 100, cost: 0.1, messages: 1 }, + intensity: 1, + tokenBreakdown: { + input: 60, + output: 40, + cacheRead: 0, + cacheWrite: 0, + reasoning: 0, + }, + clients: [ + { + client: "claude", + modelId: "claude-sonnet-4", + tokens: { input: 60, output: 40, cacheRead: 0, cacheWrite: 0, reasoning: 0 }, + cost: 0.1, + messages: 1, + }, + ], + }, + ], + }, + errors: [], + warnings: [], + }); - let insertCall = 0; - const tx = { - update: vi.fn(() => { - const builder = { - set: vi.fn(() => builder), - where: vi.fn(() => Promise.resolve()), - }; - return builder; - }), - select: vi.fn(() => makeAwaitableBuilder(selectResults.shift() ?? [])), - insert: vi.fn(() => { - insertCall += 1; - if (insertCall === 1) { + // Row is existing source-scoped (not an upgrade) — no rename should flow. + mockState.resolveSubmissionScope.mockReturnValue({ + kind: "existing", + submissionId: "submission-1", + upgradeLegacyRow: false, + }); + mockState.clientContributionToBreakdownData.mockReturnValue({ + tokens: 100, cost: 0.1, input: 60, output: 40, + cacheRead: 0, cacheWrite: 0, reasoning: 0, messages: 1, + }); + mockState.mergeClientBreakdowns.mockImplementation( + (_existing: unknown, incoming: unknown) => incoming + ); + mockState.recalculateDayTotals.mockReturnValue({ + tokens: 100, cost: 0.1, inputTokens: 60, outputTokens: 40, + cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 0, + }); + mockState.buildModelBreakdown.mockReturnValue({ "claude-sonnet-4": 100 }); + mockState.mergeTimestampMs.mockReturnValue(null); + + const setCalls: Array> = []; + + mockState.db.transaction.mockImplementation(async (callback) => { + const selectResults = [ + [{ id: "submission-1", sourceId: "machine-a" }], // 3a scope + [], // 3b daily breakdown + [{ + totalTokens: 100, totalCost: "0.1000", + inputTokens: 60, outputTokens: 40, + dateStart: "2024-12-01", dateEnd: "2024-12-01", + activeDays: 1, rowCount: 1, + }], // 3d aggregates + [{ sourceBreakdown: { claude: { tokens: 100, cost: 0.1, input: 60, output: 40, + cacheRead: 0, cacheWrite: 0, reasoning: 0, messages: 1, + models: { "claude-sonnet-4": { tokens: 100, cost: 0.1, input: 60, output: 40, + cacheRead: 0, cacheWrite: 0, reasoning: 0, messages: 1 } } } } }], // 3d allDays + [{ totalTokens: 100, totalCost: "0.1000", + dateStart: "2024-12-01", dateEnd: "2024-12-01" }], // metrics + [{ activeDays: 1 }], + [{ sourcesUsed: ["claude"] }], + ]; + const tx = { + update: vi.fn((table: unknown) => ({ + set: vi.fn((payload: Record) => { + if (table === mockState.submissions) setCalls.push(payload); + return { where: vi.fn(async () => []) }; + }), + })), + select: vi.fn(() => { const builder = { - values: vi.fn(() => builder), - returning: vi.fn(() => Promise.resolve([{ id: "submission-1" }])), + from: vi.fn(() => builder), + innerJoin: vi.fn(() => builder), + where: vi.fn(() => builder), + limit: vi.fn(async () => selectResults.shift() ?? []), + for: vi.fn(() => builder), + then: (resolve: (value: unknown) => unknown) => + resolve(selectResults.shift() ?? []), }; return builder; - } + }), + insert: vi.fn(() => ({ + values: vi.fn(async () => []), + onConflictDoNothing: vi.fn(() => ({ returning: vi.fn(async () => []) })), + })), + execute: vi.fn(async () => []), + }; + return callback(tx as never); + }); - return { - values: vi.fn(() => Promise.resolve()), - }; - }), - execute: vi.fn(() => Promise.resolve()), - }; - type MockTransaction = typeof tx; + const response = await POST( + new Request("http://localhost:3000/api/submit", { + method: "POST", + headers: { + Authorization: "Bearer tt_valid", + "Content-Type": "application/json", + }, + body: JSON.stringify({ meta: {}, contributions: [] }), + }) + ); + + expect(response.status).toBe(200); + // The submission-row update must NOT include sourceName — otherwise the + // CLI's default would clobber a user rename from PATCH /sources/:id. + const submissionUpdate = setCalls.find( + (c) => c.totalTokens !== undefined + ); + expect(submissionUpdate).toBeDefined(); + expect(submissionUpdate).not.toHaveProperty("sourceName"); + // And it must NOT include sourceId either on a non-upgrade path. + expect(submissionUpdate).not.toHaveProperty("sourceId"); + }); + + it("writes sourceId+sourceName when upgrading a legacy unsourced row", async () => { + mockState.authenticatePersonalToken.mockResolvedValue({ + status: "valid", + tokenId: "token-1", + userId: "user-1", + username: "alice", + displayName: "Alice", + avatarUrl: null, + isAdmin: false, + expiresAt: null, + }); + mockState.validateSubmission.mockReturnValue({ + valid: true, + data: { + meta: { + generatedAt: new Date().toISOString(), + version: "1.0.0", + sourceId: "machine-a", + sourceName: "CLI on junhoyeo-mbp", + dateRange: { start: "2024-12-01", end: "2024-12-01" }, + }, + summary: { + totalTokens: 100, totalCost: 0.1, + totalDays: 1, activeDays: 1, + averagePerDay: 0.1, maxCostInSingleDay: 0.1, + clients: ["claude"], models: ["claude-sonnet-4"], + }, + years: [], + contributions: [{ + date: "2024-12-01", + totals: { tokens: 100, cost: 0.1, messages: 1 }, + intensity: 1, + tokenBreakdown: { input: 60, output: 40, cacheRead: 0, cacheWrite: 0, reasoning: 0 }, + clients: [{ + client: "claude", modelId: "claude-sonnet-4", + tokens: { input: 60, output: 40, cacheRead: 0, cacheWrite: 0, reasoning: 0 }, + cost: 0.1, messages: 1, + }], + }], + }, + errors: [], + warnings: [], + }); - mockState.db.transaction.mockImplementation(async (callback: (tx: MockTransaction) => Promise) => - callback(tx) + mockState.resolveSubmissionScope.mockReturnValue({ + kind: "existing", + submissionId: "legacy-row", + upgradeLegacyRow: true, + }); + mockState.clientContributionToBreakdownData.mockReturnValue({ + tokens: 100, cost: 0.1, input: 60, output: 40, + cacheRead: 0, cacheWrite: 0, reasoning: 0, messages: 1, + }); + mockState.mergeClientBreakdowns.mockImplementation( + (_existing: unknown, incoming: unknown) => incoming ); + mockState.recalculateDayTotals.mockReturnValue({ + tokens: 100, cost: 0.1, inputTokens: 60, outputTokens: 40, + cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 0, + }); + mockState.buildModelBreakdown.mockReturnValue({ "claude-sonnet-4": 100 }); + mockState.mergeTimestampMs.mockReturnValue(null); + + const setCalls: Array> = []; + + mockState.db.transaction.mockImplementation(async (callback) => { + const selectResults = [ + [{ id: "legacy-row", sourceId: null }], // 3a scope — legacy row + [], + [{ + totalTokens: 100, totalCost: "0.1000", + inputTokens: 60, outputTokens: 40, + dateStart: "2024-12-01", dateEnd: "2024-12-01", + activeDays: 1, rowCount: 1, + }], + [{ sourceBreakdown: {} }], + [{ totalTokens: 100, totalCost: "0.1000", + dateStart: "2024-12-01", dateEnd: "2024-12-01" }], + [{ activeDays: 1 }], + [{ sourcesUsed: ["claude"] }], + ]; + const tx = { + update: vi.fn((table: unknown) => ({ + set: vi.fn((payload: Record) => { + if (table === mockState.submissions) setCalls.push(payload); + return { where: vi.fn(async () => []) }; + }), + })), + select: vi.fn(() => { + const builder = { + from: vi.fn(() => builder), + innerJoin: vi.fn(() => builder), + where: vi.fn(() => builder), + limit: vi.fn(async () => selectResults.shift() ?? []), + for: vi.fn(() => builder), + then: (resolve: (value: unknown) => unknown) => + resolve(selectResults.shift() ?? []), + }; + return builder; + }), + insert: vi.fn(() => ({ + values: vi.fn(async () => []), + onConflictDoNothing: vi.fn(() => ({ returning: vi.fn(async () => []) })), + })), + execute: vi.fn(async () => []), + }; + return callback(tx as never); + }); const response = await POST( new Request("http://localhost:3000/api/submit", { @@ -382,10 +937,13 @@ describe("POST /api/submit auth path", () => { ); expect(response.status).toBe(200); - expect(mockState.revalidateTag).toHaveBeenNthCalledWith(1, "leaderboard", "max"); - expect(mockState.revalidateTag).toHaveBeenNthCalledWith(2, "user:alice", "max"); - expect(mockState.revalidateTag).toHaveBeenNthCalledWith(3, "user-rank", "max"); - expect(mockState.revalidateTag).toHaveBeenNthCalledWith(4, "user-rank:alice", "max"); - expect(mockState.revalidateUsernamePaths).toHaveBeenCalledWith("Alice"); + const submissionUpdate = setCalls.find((c) => c.totalTokens !== undefined); + expect(submissionUpdate).toBeDefined(); + // The upgrade path MUST stamp sourceId + the CLI-provided sourceName + // on the former legacy row. + expect(submissionUpdate).toMatchObject({ + sourceId: "machine-a", + sourceName: "CLI on junhoyeo-mbp", + }); }); }); diff --git a/packages/frontend/__tests__/api/userSourceDetail.test.ts b/packages/frontend/__tests__/api/userSourceDetail.test.ts new file mode 100644 index 000000000..588837c23 --- /dev/null +++ b/packages/frontend/__tests__/api/userSourceDetail.test.ts @@ -0,0 +1,293 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +const mockState = vi.hoisted(() => { + const selectResults: Array>> = []; + + const tables = { + users: { + id: "users.id", + username: "users.username", + displayName: "users.displayName", + avatarUrl: "users.avatarUrl", + }, + submissions: { + id: "submissions.id", + userId: "submissions.userId", + sourceId: "submissions.sourceId", + sourceName: "submissions.sourceName", + totalTokens: "submissions.totalTokens", + totalCost: "submissions.totalCost", + inputTokens: "submissions.inputTokens", + outputTokens: "submissions.outputTokens", + cacheReadTokens: "submissions.cacheReadTokens", + cacheCreationTokens: "submissions.cacheCreationTokens", + reasoningTokens: "submissions.reasoningTokens", + submitCount: "submissions.submitCount", + dateStart: "submissions.dateStart", + dateEnd: "submissions.dateEnd", + sourcesUsed: "submissions.sourcesUsed", + modelsUsed: "submissions.modelsUsed", + updatedAt: "submissions.updatedAt", + }, + dailyBreakdown: { + submissionId: "dailyBreakdown.submissionId", + date: "dailyBreakdown.date", + timestampMs: "dailyBreakdown.timestampMs", + tokens: "dailyBreakdown.tokens", + cost: "dailyBreakdown.cost", + inputTokens: "dailyBreakdown.inputTokens", + outputTokens: "dailyBreakdown.outputTokens", + sourceBreakdown: "dailyBreakdown.sourceBreakdown", + }, + }; + + function nextSelectResult() { + return selectResults.shift() ?? []; + } + + const db = { + select: vi.fn(() => { + const builder = { + from: vi.fn(() => builder), + where: vi.fn(() => builder), + innerJoin: vi.fn(() => builder), + orderBy: vi.fn(() => builder), + limit: vi.fn(() => builder), + then: (resolve: (value: unknown) => unknown) => resolve(nextSelectResult()), + }; + + return builder; + }), + }; + + const eq = vi.fn(() => "eq"); + const and = vi.fn(() => "and"); + const gte = vi.fn(() => "gte"); + const desc = vi.fn(() => "desc"); + const isNull = vi.fn(() => "isNull"); + + return { + db, + tables, + eq, + and, + gte, + desc, + isNull, + reset() { + selectResults.length = 0; + db.select.mockClear(); + eq.mockClear(); + and.mockClear(); + gte.mockClear(); + desc.mockClear(); + isNull.mockClear(); + }, + pushSelectResult(rows: Array>) { + selectResults.push(rows); + }, + }; +}); + +vi.mock("@/lib/db", () => ({ + db: mockState.db, + users: mockState.tables.users, + submissions: mockState.tables.submissions, + dailyBreakdown: mockState.tables.dailyBreakdown, +})); + +vi.mock("drizzle-orm", () => ({ + eq: mockState.eq, + and: mockState.and, + gte: mockState.gte, + desc: mockState.desc, + isNull: mockState.isNull, +})); + +type ModuleExports = typeof import("../../src/app/api/users/[username]/sources/[sourceId]/route"); + +let GET: ModuleExports["GET"]; + +beforeAll(async () => { + const routeModule = await import("../../src/app/api/users/[username]/sources/[sourceId]/route"); + GET = routeModule.GET; +}); + +beforeEach(() => { + mockState.reset(); +}); + +describe("GET /api/users/[username]/sources/[sourceId]", () => { + it("returns a detailed view for a concrete source", async () => { + mockState.pushSelectResult([ + { + id: "user-1", + username: "alice", + displayName: "Alice", + avatarUrl: null, + }, + ]); + mockState.pushSelectResult([ + { + id: "submission-1", + sourceId: "machine-a", + sourceName: "Work MacBook", + totalTokens: 1000, + totalCost: "10.5000", + inputTokens: 600, + outputTokens: 400, + cacheReadTokens: 100, + cacheCreationTokens: 20, + reasoningTokens: 10, + submitCount: 2, + dateStart: "2026-03-01", + dateEnd: "2026-03-02", + sourcesUsed: ["claude"], + modelsUsed: ["claude-sonnet-4"], + updatedAt: new Date("2026-03-02T10:00:00.000Z"), + }, + ]); + mockState.pushSelectResult([ + { + date: "2026-03-01", + timestampMs: 1700000000000, + tokens: 1000, + cost: "10.5000", + inputTokens: 600, + outputTokens: 400, + sourceBreakdown: { + claude: { + tokens: 1000, + cost: 10.5, + input: 600, + output: 400, + cacheRead: 100, + cacheWrite: 20, + reasoning: 10, + messages: 4, + models: { + "claude-sonnet-4": { + tokens: 1000, + cost: 10.5, + input: 600, + output: 400, + cacheRead: 100, + cacheWrite: 20, + reasoning: 10, + messages: 4, + }, + }, + }, + }, + }, + ]); + + const response = await GET( + new Request("http://localhost:3000/api/users/alice/sources/machine-a"), + { params: Promise.resolve({ username: "alice", sourceId: "machine-a" }) } + ); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.source).toMatchObject({ + sourceId: "machine-a", + sourceKey: "source:machine-a", + sourceName: "Work MacBook", + stats: { + totalTokens: 1000, + totalCost: 10.5, + submissionCount: 2, + activeDays: 1, + }, + clients: ["claude"], + models: ["claude-sonnet-4"], + }); + expect(body.source.contributions).toHaveLength(1); + }); + + it("maps __legacy__ to null source rows", async () => { + mockState.pushSelectResult([ + { + id: "user-1", + username: "alice", + displayName: "Alice", + avatarUrl: null, + }, + ]); + mockState.pushSelectResult([ + { + id: "submission-legacy", + sourceId: null, + sourceName: null, + totalTokens: 50, + totalCost: "0.5000", + inputTokens: 30, + outputTokens: 20, + cacheReadTokens: 0, + cacheCreationTokens: 0, + reasoningTokens: 0, + submitCount: 1, + dateStart: "2026-03-01", + dateEnd: "2026-03-01", + sourcesUsed: ["cursor"], + modelsUsed: ["gpt-4.1"], + updatedAt: new Date("2026-03-01T10:00:00.000Z"), + }, + ]); + mockState.pushSelectResult([]); + + const response = await GET( + new Request("http://localhost:3000/api/users/alice/sources/__legacy__"), + { params: Promise.resolve({ username: "alice", sourceId: "__legacy__" }) } + ); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.source.sourceId).toBeNull(); + expect(body.source.sourceKey).toBe("__legacy__"); + expect(body.source.sourceName).toBe("Legacy / Unknown device"); + }); + + it("treats a real __legacy__ sourceId as an addressable source, not the unsourced sentinel", async () => { + mockState.pushSelectResult([ + { + id: "user-1", + username: "alice", + displayName: "Alice", + avatarUrl: null, + }, + ]); + mockState.pushSelectResult([ + { + id: "submission-legacy-string", + sourceId: "__legacy__", + sourceName: "Literal __legacy__ device", + totalTokens: 75, + totalCost: "0.7500", + inputTokens: 50, + outputTokens: 25, + cacheReadTokens: 0, + cacheCreationTokens: 0, + reasoningTokens: 0, + submitCount: 1, + dateStart: "2026-03-01", + dateEnd: "2026-03-01", + sourcesUsed: ["claude"], + modelsUsed: ["claude-sonnet-4"], + updatedAt: new Date("2026-03-01T10:00:00.000Z"), + }, + ]); + mockState.pushSelectResult([]); + + const response = await GET( + new Request("http://localhost:3000/api/users/alice/sources/source%3A__legacy__"), + { params: Promise.resolve({ username: "alice", sourceId: "source:__legacy__" }) } + ); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.source.sourceId).toBe("__legacy__"); + expect(body.source.sourceKey).toBe("source:__legacy__"); + expect(body.source.sourceName).toBe("Literal __legacy__ device"); + }); +}); diff --git a/packages/frontend/__tests__/api/userSourceSummary.test.ts b/packages/frontend/__tests__/api/userSourceSummary.test.ts new file mode 100644 index 000000000..db0973933 --- /dev/null +++ b/packages/frontend/__tests__/api/userSourceSummary.test.ts @@ -0,0 +1,315 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +const mockState = vi.hoisted(() => { + const selectResults: Array>> = []; + + const tables = { + users: { + id: "users.id", + username: "users.username", + displayName: "users.displayName", + avatarUrl: "users.avatarUrl", + }, + submissions: { + id: "submissions.id", + userId: "submissions.userId", + sourceId: "submissions.sourceId", + sourceName: "submissions.sourceName", + totalTokens: "submissions.totalTokens", + totalCost: "submissions.totalCost", + inputTokens: "submissions.inputTokens", + outputTokens: "submissions.outputTokens", + cacheReadTokens: "submissions.cacheReadTokens", + cacheCreationTokens: "submissions.cacheCreationTokens", + reasoningTokens: "submissions.reasoningTokens", + submitCount: "submissions.submitCount", + dateStart: "submissions.dateStart", + dateEnd: "submissions.dateEnd", + sourcesUsed: "submissions.sourcesUsed", + modelsUsed: "submissions.modelsUsed", + updatedAt: "submissions.updatedAt", + }, + dailyBreakdown: { + submissionId: "dailyBreakdown.submissionId", + date: "dailyBreakdown.date", + timestampMs: "dailyBreakdown.timestampMs", + tokens: "dailyBreakdown.tokens", + cost: "dailyBreakdown.cost", + inputTokens: "dailyBreakdown.inputTokens", + outputTokens: "dailyBreakdown.outputTokens", + sourceBreakdown: "dailyBreakdown.sourceBreakdown", + }, + }; + + function nextSelectResult() { + return selectResults.shift() ?? []; + } + + const db = { + select: vi.fn(() => { + const builder = { + from: vi.fn(() => builder), + where: vi.fn(() => builder), + innerJoin: vi.fn(() => builder), + orderBy: vi.fn(() => builder), + limit: vi.fn(() => builder), + then: (resolve: (value: unknown) => unknown) => resolve(nextSelectResult()), + }; + return builder; + }), + }; + + const eq = vi.fn(() => "eq"); + const and = vi.fn(() => "and"); + const gte = vi.fn(() => "gte"); + const isNull = vi.fn(() => "isNull"); + + return { + db, + tables, + eq, + and, + gte, + isNull, + reset() { + selectResults.length = 0; + db.select.mockClear(); + eq.mockClear(); + and.mockClear(); + gte.mockClear(); + isNull.mockClear(); + }, + pushSelectResult(rows: Array>) { + selectResults.push(rows); + }, + }; +}); + +vi.mock("@/lib/db", () => ({ + db: mockState.db, + users: mockState.tables.users, + submissions: mockState.tables.submissions, + dailyBreakdown: mockState.tables.dailyBreakdown, +})); + +vi.mock("drizzle-orm", () => ({ + eq: mockState.eq, + and: mockState.and, + gte: mockState.gte, + isNull: mockState.isNull, +})); + +type ModuleExports = typeof import("../../src/app/api/users/[username]/sources/[sourceId]/summary/route"); + +let GET: ModuleExports["GET"]; + +beforeAll(async () => { + const routeModule = await import("../../src/app/api/users/[username]/sources/[sourceId]/summary/route"); + GET = routeModule.GET; +}); + +beforeEach(() => { + mockState.reset(); +}); + +describe("GET /api/users/[username]/sources/[sourceId]/summary", () => { + it("returns a lightweight source summary including top client and top model", async () => { + mockState.pushSelectResult([ + { + id: "user-1", + username: "alice", + displayName: "Alice", + avatarUrl: null, + }, + ]); + mockState.pushSelectResult([ + { + id: "submission-1", + sourceId: "machine-a", + sourceName: "Work MacBook", + totalTokens: 1000, + totalCost: "10.5000", + inputTokens: 600, + outputTokens: 400, + cacheReadTokens: 100, + cacheCreationTokens: 20, + reasoningTokens: 10, + submitCount: 2, + dateStart: "2026-03-01", + dateEnd: "2026-03-02", + sourcesUsed: ["claude"], + modelsUsed: ["claude-sonnet-4"], + updatedAt: new Date("2026-03-02T10:00:00.000Z"), + }, + ]); + mockState.pushSelectResult([ + { + date: "2026-03-01", + timestampMs: 1700000000000, + tokens: 1000, + cost: "10.5000", + inputTokens: 600, + outputTokens: 400, + sourceBreakdown: { + claude: { + tokens: 1000, + cost: 10.5, + input: 600, + output: 400, + cacheRead: 100, + cacheWrite: 20, + reasoning: 10, + messages: 4, + models: { + "claude-sonnet-4": { + tokens: 1000, + cost: 10.5, + input: 600, + output: 400, + cacheRead: 100, + cacheWrite: 20, + reasoning: 10, + messages: 4, + }, + }, + }, + }, + }, + ]); + + const response = await GET( + new Request("http://localhost:3000/api/users/alice/sources/machine-a/summary"), + { params: Promise.resolve({ username: "alice", sourceId: "machine-a" }) } + ); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.source).toMatchObject({ + sourceId: "machine-a", + sourceKey: "source:machine-a", + sourceName: "Work MacBook", + totalTokens: 1000, + totalCost: 10.5, + submissionCount: 2, + activeDays: 1, + topClient: "claude", + topModel: "claude-sonnet-4", + }); + }); + + it("breaks topClient / topModel ties alphabetically for determinism", async () => { + mockState.pushSelectResult([ + { id: "user-1", username: "alice", displayName: "Alice", avatarUrl: null }, + ]); + mockState.pushSelectResult([ + { + id: "submission-1", + sourceId: "machine-a", + sourceName: "Work", + totalTokens: 2000, + totalCost: "20.0000", + inputTokens: 1200, + outputTokens: 800, + cacheReadTokens: 0, + cacheCreationTokens: 0, + reasoningTokens: 0, + submitCount: 1, + dateStart: "2026-03-01", + dateEnd: "2026-03-01", + // Insertion order intentionally puts "zulu" first to prove that we + // do NOT return the first-inserted entry on ties. + sourcesUsed: ["zulu", "alpha"], + modelsUsed: ["zoo-model", "alpha-model"], + updatedAt: new Date("2026-03-01T10:00:00.000Z"), + }, + ]); + mockState.pushSelectResult([ + { + date: "2026-03-01", + timestampMs: 1700000000000, + tokens: 2000, + cost: "20.0000", + inputTokens: 1200, + outputTokens: 800, + sourceBreakdown: { + // Equal token counts — tie-break MUST resolve alphabetically. + zulu: { + tokens: 1000, + cost: 10, + input: 600, + output: 400, + cacheRead: 0, + cacheWrite: 0, + reasoning: 0, + messages: 2, + models: { + "zoo-model": { + tokens: 1000, + cost: 10, + input: 600, + output: 400, + cacheRead: 0, + cacheWrite: 0, + reasoning: 0, + messages: 2, + }, + }, + }, + alpha: { + tokens: 1000, + cost: 10, + input: 600, + output: 400, + cacheRead: 0, + cacheWrite: 0, + reasoning: 0, + messages: 2, + models: { + "alpha-model": { + tokens: 1000, + cost: 10, + input: 600, + output: 400, + cacheRead: 0, + cacheWrite: 0, + reasoning: 0, + messages: 2, + }, + }, + }, + }, + }, + ]); + + const response = await GET( + new Request("http://localhost:3000/api/users/alice/sources/machine-a/summary"), + { params: Promise.resolve({ username: "alice", sourceId: "machine-a" }) } + ); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.source.topClient).toBe("alpha"); + expect(body.source.topModel).toBe("alpha-model"); + }); + + it("returns 404 for an unknown source", async () => { + mockState.pushSelectResult([ + { + id: "user-1", + username: "alice", + displayName: "Alice", + avatarUrl: null, + }, + ]); + mockState.pushSelectResult([]); + mockState.pushSelectResult([]); + + const response = await GET( + new Request("http://localhost:3000/api/users/alice/sources/missing/summary"), + { params: Promise.resolve({ username: "alice", sourceId: "missing" }) } + ); + + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ error: "Source not found" }); + }); +}); diff --git a/packages/frontend/__tests__/api/userSources.test.ts b/packages/frontend/__tests__/api/userSources.test.ts new file mode 100644 index 000000000..ac97a3276 --- /dev/null +++ b/packages/frontend/__tests__/api/userSources.test.ts @@ -0,0 +1,229 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +const mockState = vi.hoisted(() => { + const selectResults: Array>> = []; + + const tables = { + users: { + id: "users.id", + username: "users.username", + displayName: "users.displayName", + avatarUrl: "users.avatarUrl", + }, + submissions: { + id: "submissions.id", + userId: "submissions.userId", + sourceId: "submissions.sourceId", + sourceName: "submissions.sourceName", + totalTokens: "submissions.totalTokens", + totalCost: "submissions.totalCost", + inputTokens: "submissions.inputTokens", + outputTokens: "submissions.outputTokens", + cacheReadTokens: "submissions.cacheReadTokens", + cacheCreationTokens: "submissions.cacheCreationTokens", + reasoningTokens: "submissions.reasoningTokens", + submitCount: "submissions.submitCount", + dateStart: "submissions.dateStart", + dateEnd: "submissions.dateEnd", + sourcesUsed: "submissions.sourcesUsed", + modelsUsed: "submissions.modelsUsed", + updatedAt: "submissions.updatedAt", + }, + dailyBreakdown: { + submissionId: "dailyBreakdown.submissionId", + date: "dailyBreakdown.date", + timestampMs: "dailyBreakdown.timestampMs", + tokens: "dailyBreakdown.tokens", + cost: "dailyBreakdown.cost", + inputTokens: "dailyBreakdown.inputTokens", + outputTokens: "dailyBreakdown.outputTokens", + sourceBreakdown: "dailyBreakdown.sourceBreakdown", + }, + }; + + function nextSelectResult() { + return selectResults.shift() ?? []; + } + + const db = { + select: vi.fn(() => { + const builder = { + from: vi.fn(() => builder), + where: vi.fn(() => builder), + innerJoin: vi.fn(() => builder), + orderBy: vi.fn(() => builder), + groupBy: vi.fn(() => builder), + limit: vi.fn(() => builder), + then: (resolve: (value: unknown) => unknown) => resolve(nextSelectResult()), + }; + + return builder; + }), + }; + + const eq = vi.fn(() => "eq"); + const and = vi.fn(() => "and"); + const gte = vi.fn(() => "gte"); + const desc = vi.fn(() => "desc"); + const sql = vi.fn(() => "sql"); + + return { + db, + tables, + eq, + and, + gte, + desc, + sql, + reset() { + selectResults.length = 0; + db.select.mockClear(); + eq.mockClear(); + and.mockClear(); + gte.mockClear(); + desc.mockClear(); + sql.mockClear(); + }, + pushSelectResult(rows: Array>) { + selectResults.push(rows); + }, + }; +}); + +vi.mock("@/lib/db", () => ({ + db: mockState.db, + users: mockState.tables.users, + submissions: mockState.tables.submissions, + dailyBreakdown: mockState.tables.dailyBreakdown, +})); + +vi.mock("drizzle-orm", () => ({ + eq: mockState.eq, + and: mockState.and, + gte: mockState.gte, + desc: mockState.desc, + sql: mockState.sql, +})); + +type ModuleExports = typeof import("../../src/app/api/users/[username]/sources/route"); + +let GET: ModuleExports["GET"]; + +beforeAll(async () => { + const routeModule = await import("../../src/app/api/users/[username]/sources/route"); + GET = routeModule.GET; +}); + +beforeEach(() => { + mockState.reset(); +}); + +describe("GET /api/users/[username]/sources", () => { + it("aggregates sources and preserves a legacy null source", async () => { + mockState.pushSelectResult([ + { + id: "user-1", + username: "alice", + displayName: "Alice", + avatarUrl: null, + }, + ]); + mockState.pushSelectResult([ + { + id: "submission-1", + sourceId: "machine-a", + sourceName: "Work MacBook", + totalTokens: 1000, + totalCost: "10.5000", + inputTokens: 600, + outputTokens: 400, + cacheReadTokens: 100, + cacheCreationTokens: 20, + reasoningTokens: 10, + submitCount: 2, + dateStart: "2026-03-01", + dateEnd: "2026-03-02", + sourcesUsed: ["claude", "kilocode"], + modelsUsed: ["claude-sonnet-4", "gpt-4.1"], + updatedAt: new Date("2026-03-02T10:00:00.000Z"), + }, + { + id: "submission-2", + sourceId: null, + sourceName: null, + totalTokens: 300, + totalCost: "3.2500", + inputTokens: 200, + outputTokens: 100, + cacheReadTokens: 0, + cacheCreationTokens: 0, + reasoningTokens: 0, + submitCount: 1, + dateStart: "2026-03-01", + dateEnd: "2026-03-01", + sourcesUsed: ["cursor"], + modelsUsed: ["gpt-4.1"], + updatedAt: new Date("2026-03-01T09:00:00.000Z"), + }, + ]); + mockState.pushSelectResult([ + { + sourceId: "machine-a", + activeDays: 1, + }, + { + sourceId: null, + activeDays: 1, + }, + ]); + + const response = await GET( + new Request("http://localhost:3000/api/users/alice/sources"), + { params: Promise.resolve({ username: "alice" }) } + ); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.sources).toHaveLength(2); + + expect(body.sources[0]).toMatchObject({ + sourceId: "machine-a", + sourceKey: "source:machine-a", + sourceName: "Work MacBook", + stats: { + totalTokens: 1000, + totalCost: 10.5, + submissionCount: 2, + activeDays: 1, + }, + clients: ["claude", "kilo"], + models: ["claude-sonnet-4", "gpt-4.1"], + }); + + expect(body.sources[1]).toMatchObject({ + sourceId: null, + sourceKey: "__legacy__", + sourceName: "Legacy / Unknown device", + stats: { + totalTokens: 300, + totalCost: 3.25, + submissionCount: 1, + activeDays: 1, + }, + clients: ["cursor"], + models: ["gpt-4.1"], + }); + }); + + it("returns 404 when the user does not exist", async () => { + mockState.pushSelectResult([]); + + const response = await GET( + new Request("http://localhost:3000/api/users/missing/sources"), + { params: Promise.resolve({ username: "missing" }) } + ); + + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ error: "User not found" }); + }); +}); diff --git a/packages/frontend/__tests__/api/usersProfile.test.ts b/packages/frontend/__tests__/api/usersProfile.test.ts index 653844b8c..7527045cf 100644 --- a/packages/frontend/__tests__/api/usersProfile.test.ts +++ b/packages/frontend/__tests__/api/usersProfile.test.ts @@ -14,6 +14,7 @@ const mockState = vi.hoisted(() => { createdAt: "users.createdAt", }, submissions: { + id: "submissions.id", userId: "submissions.userId", totalTokens: "submissions.totalTokens", totalCost: "submissions.totalCost", @@ -311,7 +312,7 @@ describe("GET /api/users/[username]", () => { cacheReadTokens: 100, cacheCreationTokens: 50, reasoningTokens: 25, - submissionCount: 2, + submissionCount: 5, earliestDate: "2026-01-01", latestDate: "2026-03-10", }, @@ -324,6 +325,13 @@ describe("GET /api/users/[username]", () => { cliVersion: "1.4.2", schemaVersion: 1, }, + { + sourcesUsed: ["claude"], + modelsUsed: ["gpt-4.1"], + updatedAt: new Date("2026-01-05T08:00:00.000Z"), + cliVersion: "1.4.0", + schemaVersion: 1, + }, ]); mockState.pushSelectResult([]); mockState.pushExecuteResult([{ rank: 3 }]); @@ -342,8 +350,9 @@ describe("GET /api/users/[username]", () => { isStale: true, }); expect(body.updatedAt).toBe("2026-01-10T10:00:00.000Z"); - expect(body.clients).toEqual(["cursor"]); - expect(body.models).toEqual(["claude-3-7-sonnet"]); + expect(body.stats.submissionCount).toBe(5); + expect(body.clients).toEqual(["claude", "cursor"]); + expect(body.models).toEqual(["claude-3-7-sonnet", "gpt-4.1"]); }); it("returns null freshness metadata when the user has no submission yet", async () => { @@ -386,4 +395,59 @@ describe("GET /api/users/[username]", () => { expect(body.clients).toEqual([]); expect(body.models).toEqual([]); }); + + it("orders latest submission freshness metadata deterministically on timestamp ties", async () => { + mockState.pushSelectResult([ + { + id: "user-3", + username: "tie-user", + displayName: "Tie User", + avatarUrl: null, + createdAt: "2026-03-01T00:00:00.000Z", + }, + ]); + mockState.pushSelectResult([ + { + totalTokens: 50, + totalCost: 0.5, + inputTokens: 20, + outputTokens: 30, + cacheReadTokens: 0, + cacheCreationTokens: 0, + reasoningTokens: 0, + submissionCount: 2, + earliestDate: "2026-03-01", + latestDate: "2026-03-02", + }, + ]); + mockState.pushSelectResult([ + { + id: "submission-a", + sourcesUsed: ["claude"], + modelsUsed: ["gpt-4.1"], + updatedAt: new Date("2026-03-10T10:00:00.000Z"), + cliVersion: "1.0.0", + schemaVersion: 0, + }, + { + id: "submission-b", + sourcesUsed: ["cursor"], + modelsUsed: ["claude-3-7-sonnet"], + updatedAt: new Date("2026-03-10T10:00:00.000Z"), + cliVersion: "1.1.0", + schemaVersion: 1, + }, + ]); + mockState.pushSelectResult([]); + mockState.pushExecuteResult([]); + + const response = await GET( + new Request("http://localhost:3000/api/users/tie-user"), + { params: Promise.resolve({ username: "tie-user" }) } + ); + + expect(response.status).toBe(200); + expect(mockState.desc).toHaveBeenNthCalledWith(1, mockState.tables.submissions.updatedAt); + expect(mockState.desc).toHaveBeenNthCalledWith(2, mockState.tables.submissions.id); + }); }); diff --git a/packages/frontend/__tests__/lib/dbHelpers.test.ts b/packages/frontend/__tests__/lib/dbHelpers.test.ts new file mode 100644 index 000000000..bccaa8606 --- /dev/null +++ b/packages/frontend/__tests__/lib/dbHelpers.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { resolveSubmissionScope } from "../../src/lib/db/helpers"; + +describe("resolveSubmissionScope", () => { + it("reuses the exact source-scoped row when one already exists", () => { + const result = resolveSubmissionScope( + [ + { id: "row-1", sourceId: "machine-a" }, + { id: "row-2", sourceId: "machine-b" }, + ], + "machine-b" + ); + + expect(result).toEqual({ + kind: "existing", + submissionId: "row-2", + upgradeLegacyRow: false, + }); + }); + + it("upgrades a lone legacy unsourced row on the first source-aware submit", () => { + const result = resolveSubmissionScope( + [{ id: "legacy-row", sourceId: null }], + "machine-a" + ); + + expect(result).toEqual({ + kind: "existing", + submissionId: "legacy-row", + upgradeLegacyRow: true, + }); + }); + + it("creates a new row for a new source after scoped mode already exists", () => { + const result = resolveSubmissionScope( + [{ id: "row-1", sourceId: "machine-a" }], + "machine-b" + ); + + expect(result).toEqual({ kind: "create" }); + }); + + it("rejects ambiguous unsourced submits after scoped mode begins", () => { + const result = resolveSubmissionScope( + [{ id: "row-1", sourceId: "machine-a" }], + null + ); + + expect(result).toEqual({ kind: "rejectMissingSourceIdentity" }); + }); + + it("keeps using the legacy unsourced row until scoped mode starts", () => { + const result = resolveSubmissionScope( + [{ id: "legacy-row", sourceId: null }], + null + ); + + expect(result).toEqual({ + kind: "existing", + submissionId: "legacy-row", + upgradeLegacyRow: false, + }); + }); + + it("rejects unsourced submits for mixed-state accounts", () => { + const result = resolveSubmissionScope( + [ + { id: "legacy-row", sourceId: null }, + { id: "row-1", sourceId: "machine-a" }, + ], + null + ); + + expect(result).toEqual({ kind: "rejectMissingSourceIdentity" }); + }); +}); diff --git a/packages/frontend/__tests__/lib/getUserEmbedStats.test.ts b/packages/frontend/__tests__/lib/getUserEmbedStats.test.ts index 124eff6b6..3988faabd 100644 --- a/packages/frontend/__tests__/lib/getUserEmbedStats.test.ts +++ b/packages/frontend/__tests__/lib/getUserEmbedStats.test.ts @@ -1,9 +1,8 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const mockState = vi.hoisted(() => { - const awaitedResults: unknown[] = []; + const selectResults: Array>> = []; const executeResults: Array>> = []; - const limitCalls: unknown[] = []; const tables = { users: { @@ -13,50 +12,24 @@ const mockState = vi.hoisted(() => { avatarUrl: "users.avatarUrl", }, submissions: { - id: "submissions.id", userId: "submissions.userId", totalTokens: "submissions.totalTokens", totalCost: "submissions.totalCost", submitCount: "submissions.submitCount", updatedAt: "submissions.updatedAt", }, - dailyBreakdown: { - submissionId: "dailyBreakdown.submissionId", - date: "dailyBreakdown.date", - tokens: "dailyBreakdown.tokens", - cost: "dailyBreakdown.cost", - }, }; - const eq = vi.fn(() => "eq"); - const and = vi.fn(() => "and"); - const gte = vi.fn(() => "gte"); - const sql = Object.assign( - vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ - strings: Array.from(strings), - values, - as: () => ({}), - })), - { - raw: vi.fn(), - } - ); - const db = { select: vi.fn(() => { const builder = { from: vi.fn(() => builder), leftJoin: vi.fn(() => builder), - innerJoin: vi.fn(() => builder), where: vi.fn(() => builder), groupBy: vi.fn(() => builder), - orderBy: vi.fn(() => builder), - limit: vi.fn((value: unknown) => { - limitCalls.push(value); - return builder; - }), + limit: vi.fn(() => builder), then: (resolve: (value: unknown) => unknown) => - resolve(awaitedResults.shift() ?? []), + resolve(selectResults.shift() ?? []), }; return builder; @@ -64,32 +37,35 @@ const mockState = vi.hoisted(() => { execute: vi.fn(async () => executeResults.shift() ?? []), }; + const eq = vi.fn(() => "eq"); + const sql = Object.assign( + () => ({ + as: () => ({}), + }), + { + raw: vi.fn(), + } + ); + return { db, - tables, eq, - and, - gte, sql, + tables, reset() { - awaitedResults.length = 0; + selectResults.length = 0; executeResults.length = 0; - limitCalls.length = 0; db.select.mockClear(); db.execute.mockClear(); eq.mockClear(); - and.mockClear(); - gte.mockClear(); - sql.mockClear(); sql.raw.mockClear(); }, - pushAwaitedResult(value: unknown) { - awaitedResults.push(value); + pushSelectResult(rows: Array>) { + selectResults.push(rows); }, pushExecuteResult(rows: Array>) { executeResults.push(rows); }, - limitCalls, }; }); @@ -101,129 +77,67 @@ vi.mock("@/lib/db", () => ({ db: mockState.db, users: mockState.tables.users, submissions: mockState.tables.submissions, - dailyBreakdown: mockState.tables.dailyBreakdown, })); -vi.mock("@/lib/db/usernameLookup", () => { - class AmbiguousUsernameError extends Error {} - - return { - AmbiguousUsernameError, - USERNAME_LOOKUP_LIMIT: 2, - getSingleUsernameMatch: (rows: readonly unknown[], username: string) => { - if (rows.length > 1) { - throw new AmbiguousUsernameError(`Multiple users match username ${username} case-insensitively`); - } - return rows[0] ?? null; - }, - normalizeUsernameCacheKey: (username: string) => username.toLowerCase(), - usernameEqualsIgnoreCase: (username: string) => - mockState.sql`lower(${mockState.tables.users.username}) = ${username.toLowerCase()}`, - }; -}); - vi.mock("drizzle-orm", () => ({ eq: mockState.eq, - and: mockState.and, - gte: mockState.gte, sql: mockState.sql, })); type ModuleExports = typeof import("../../src/lib/embed/getUserEmbedStats"); let getUserEmbedStats: ModuleExports["getUserEmbedStats"]; -let getUserEmbedContributions: ModuleExports["getUserEmbedContributions"]; - -function serializeSqlCalls(): string[] { - return mockState.sql.mock.calls.map((call) => { - const [strings, ...values] = call as [TemplateStringsArray, ...unknown[]]; - const textParts = Array.from(strings); - - return textParts.reduce((text, part, index) => { - const nextValue = index < values.length ? String(values[index]) : ""; - return `${text}${part}${nextValue}`; - }, ""); - }); -} beforeAll(async () => { - const embedModule = await import("../../src/lib/embed/getUserEmbedStats"); - getUserEmbedStats = embedModule.getUserEmbedStats; - getUserEmbedContributions = embedModule.getUserEmbedContributions; + const embedStatsLib = await import("../../src/lib/embed/getUserEmbedStats"); + getUserEmbedStats = embedStatsLib.getUserEmbedStats; }); beforeEach(() => { mockState.reset(); }); -describe("user embed data", () => { - it("looks up embed stats usernames case-insensitively and returns the canonical username", async () => { - mockState.pushAwaitedResult([ +describe("getUserEmbedStats", () => { + it("aggregates totals and submission count across multiple submission rows", async () => { + mockState.pushSelectResult([ { - id: "user-imlunahey", - username: "ImLunaHey", - displayName: "Luna", + id: "user-1", + username: "alice", + displayName: "Alice", avatarUrl: null, - totalTokens: 1200, - totalCost: 12, - submissionCount: 1, - updatedAt: new Date("2026-03-12T09:00:00.000Z"), + totalTokens: 3100, + totalCost: 17.75, + submissionCount: 5, + updatedAt: new Date("2026-04-01T08:00:00.000Z"), }, ]); - mockState.pushExecuteResult([{ rank: 4 }]); - - const stats = await getUserEmbedStats("imlunahey", "tokens"); - const sqlTexts = serializeSqlCalls(); - - expect(stats?.user.username).toBe("ImLunaHey"); - expect(stats?.stats.rank).toBe(4); - expect(mockState.limitCalls[0]).toBe(2); - expect(sqlTexts.some((text) => - text.toLowerCase().includes("lower(users.username) = imlunahey") - )).toBe(true); - }); + mockState.pushExecuteResult([{ rank: "2" }]); - it("looks up embed contributions usernames case-insensitively", async () => { - mockState.pushAwaitedResult([{ id: "user-imlunahey" }]); - mockState.pushAwaitedResult([]); + const result = await getUserEmbedStats("alice", "tokens"); - const contributions = await getUserEmbedContributions("IMLUNAHEY"); - const sqlTexts = serializeSqlCalls(); - - expect(contributions).toEqual([]); - expect(mockState.limitCalls[0]).toBe(2); - expect(sqlTexts.some((text) => - text.toLowerCase().includes("lower(users.username) = imlunahey") - )).toBe(true); - }); - - it("rejects ambiguous case-insensitive embed stats matches", async () => { - mockState.pushAwaitedResult([ - { - id: "user-imlunahey", - username: "ImLunaHey", - displayName: "Luna", + expect(result).toEqual({ + user: { + id: "user-1", + username: "alice", + displayName: "Alice", avatarUrl: null, - totalTokens: 1200, - totalCost: 12, - submissionCount: 1, - updatedAt: new Date("2026-03-12T09:00:00.000Z"), }, - { - id: "user-imlunahey-duplicate", - username: "imlunahey", - displayName: "Luna Duplicate", - avatarUrl: null, - totalTokens: 100, - totalCost: 1, - submissionCount: 1, - updatedAt: new Date("2026-03-12T09:00:00.000Z"), + stats: { + totalTokens: 3100, + totalCost: 17.75, + submissionCount: 5, + rank: 2, + updatedAt: "2026-04-01T08:00:00.000Z", }, - ]); + }); + }); + + it("returns null when the user is not found", async () => { + mockState.pushSelectResult([]); + + const result = await getUserEmbedStats("missing-user", "cost"); - await expect(getUserEmbedStats("imlunahey", "tokens")).rejects.toThrow( - "Multiple users match username imlunahey case-insensitively" - ); - expect(mockState.limitCalls[0]).toBe(2); + expect(result).toBeNull(); + expect(mockState.db.execute).not.toHaveBeenCalled(); }); }); diff --git a/packages/frontend/src/app/api/settings/sources/[sourceId]/route.ts b/packages/frontend/src/app/api/settings/sources/[sourceId]/route.ts new file mode 100644 index 000000000..24014e77b --- /dev/null +++ b/packages/frontend/src/app/api/settings/sources/[sourceId]/route.ts @@ -0,0 +1,119 @@ +import { NextResponse } from "next/server"; +import { and, eq, isNull } from "drizzle-orm"; +import { z } from "zod"; +import { revalidateTag } from "next/cache"; +import { db, submissions } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { + decodeSourceParam, + InvalidSourceParamError, +} from "../../../users/[username]/sources/shared"; + +const RenameBodySchema = z.object({ + // null / empty → clear the custom label and fall back to the default + // ("Legacy / Unknown device" / "Unknown device") at render time. + name: z + .union([z.string(), z.null()]) + .transform((value) => { + if (value == null) return null; + const trimmed = value.trim(); + return trimmed === "" ? null : trimmed; + }) + .refine( + (value) => value == null || value.length <= 255, + { message: "name must be 255 characters or fewer" } + ) + .refine( + (value) => value == null || !/\p{C}/u.test(value), + { message: "name must not contain control characters" } + ), +}); + +interface RouteParams { + params: Promise<{ sourceId: string }>; +} + +export async function PATCH(request: Request, { params }: RouteParams) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json( + { error: "Not authenticated" }, + { status: 401 } + ); + } + + const { sourceId: sourceIdParam } = await params; + const resolvedSourceId = decodeSourceParam(sourceIdParam); + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json( + { error: "Invalid JSON body" }, + { status: 400 } + ); + } + + const parsed = RenameBodySchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { + error: "Invalid request body", + details: parsed.error.issues.map((issue) => issue.message), + }, + { status: 400 } + ); + } + + const { name } = parsed.data; + + const scopeWhere = + resolvedSourceId === null + ? and(eq(submissions.userId, session.id), isNull(submissions.sourceId)) + : and( + eq(submissions.userId, session.id), + eq(submissions.sourceId, resolvedSourceId) + ); + + const updated = await db + .update(submissions) + .set({ sourceName: name, updatedAt: new Date() }) + .where(scopeWhere) + .returning({ + sourceId: submissions.sourceId, + sourceName: submissions.sourceName, + }); + + if (updated.length === 0) { + return NextResponse.json( + { error: "Source not found" }, + { status: 404 } + ); + } + + try { + revalidateTag(`user:${session.username}`, "max"); + } catch (e) { + console.error("Cache invalidation failed:", e); + } + + return NextResponse.json({ + success: true, + source: updated[0], + }); + } catch (error) { + if (error instanceof InvalidSourceParamError) { + return NextResponse.json( + { error: "Invalid source id" }, + { status: 400 } + ); + } + console.error("Source rename error:", error); + return NextResponse.json( + { error: "Failed to rename source" }, + { status: 500 } + ); + } +} diff --git a/packages/frontend/src/app/api/submit/route.ts b/packages/frontend/src/app/api/submit/route.ts index b81ff4f5e..7beb83d6c 100644 --- a/packages/frontend/src/app/api/submit/route.ts +++ b/packages/frontend/src/app/api/submit/route.ts @@ -1,10 +1,9 @@ import { NextResponse } from "next/server"; import { revalidateTag } from "next/cache"; import { db, apiTokens, submissions, dailyBreakdown } from "@/lib/db"; -import { eq, sql } from "drizzle-orm"; +import { and, eq, isNull, sql } from "drizzle-orm"; import { validateSubmission, - generateSubmissionHash, type SubmissionData, } from "@/lib/validation/submission"; import { authenticatePersonalToken } from "@/lib/auth/personalTokens"; @@ -15,10 +14,24 @@ import { buildModelBreakdown, clientContributionToBreakdownData, mergeTimestampMs, + resolveSubmissionScope, type ClientBreakdownData, } from "@/lib/db/helpers"; import { normalizeUsernameCacheKey, revalidateUsernamePaths } from "@/lib/db/usernameLookup"; +export const SOURCE_IDENTITY_REQUIRED_MESSAGE = + "Source identity is required for accounts with source-scoped submissions"; + +export const SOURCE_IDENTITY_REQUIRED_HINT = + "Upgrade the CLI or set TOKSCALE_SOURCE_ID before submitting from this machine."; + +export class SourceIdentityRequiredError extends Error { + constructor() { + super(SOURCE_IDENTITY_REQUIRED_MESSAGE); + this.name = "SourceIdentityRequiredError"; + } +} + function normalizeSubmissionData(data: unknown): void { if (!data || typeof data !== "object") return; const obj = data as Record; @@ -47,6 +60,64 @@ function normalizeSubmissionData(data: unknown): void { } } +function normalizeOptionalString(value: string | undefined): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed === "" ? null : trimmed; +} + +type TxClient = Parameters[0]>[0]; + +async function loadUserSubmitMetrics(tx: TxClient, userId: string) { + const [userAggregatesRows, userDayAggregatesRows, userSubmissionsRows] = + await Promise.all([ + tx + .select({ + totalTokens: sql`COALESCE(SUM(${submissions.totalTokens}), 0)::bigint`, + totalCost: sql`COALESCE(SUM(CAST(${submissions.totalCost} AS DECIMAL(12,4))), 0)::text`, + dateStart: sql`MIN(${submissions.dateStart})`, + dateEnd: sql`MAX(${submissions.dateEnd})`, + }) + .from(submissions) + .where(eq(submissions.userId, userId)), + tx + .select({ + activeDays: sql`COUNT(DISTINCT CASE WHEN ${dailyBreakdown.tokens} > 0 THEN ${dailyBreakdown.date} END)::int`, + }) + .from(dailyBreakdown) + .innerJoin(submissions, eq(dailyBreakdown.submissionId, submissions.id)) + .where(eq(submissions.userId, userId)), + tx + .select({ + sourcesUsed: submissions.sourcesUsed, + }) + .from(submissions) + .where(eq(submissions.userId, userId)), + ]); + + const [userAggregates] = userAggregatesRows; + const [userDayAggregates] = userDayAggregatesRows; + const userSubmissions = userSubmissionsRows; + + const userClients = new Set(); + for (const submission of userSubmissions) { + for (const client of submission.sourcesUsed || []) { + userClients.add(client === "kilocode" ? "kilo" : client); + } + } + + return { + totalTokens: userAggregates?.totalTokens ?? 0, + totalCost: parseFloat(userAggregates?.totalCost ?? "0"), + dateRange: { + start: userAggregates?.dateStart ?? null, + end: userAggregates?.dateEnd ?? null, + }, + activeDays: userDayAggregates?.activeDays ?? 0, + clients: Array.from(userClients).sort(), + }; +} + /** * POST /api/submit * Submit token usage data from CLI @@ -127,13 +198,10 @@ export async function POST(request: Request) { if (submittedClients.has("kilo")) { submittedClients.add("kilocode" as SubmissionData["summary"]["clients"][number]); } - const hashData: SubmissionData = { - ...data, - summary: { - ...data.summary, - clients: Array.from(submittedClients).sort(), - }, - }; + const sourceId = normalizeOptionalString(data.meta.sourceId); + const sourceName = sourceId + ? normalizeOptionalString(data.meta.sourceName) + : null; // ======================================== // STEP 3: DATABASE OPERATIONS IN TRANSACTION @@ -147,24 +215,39 @@ export async function POST(request: Request) { // ------------------------------------------ // STEP 3a: Get or create user's submission // ------------------------------------------ - const [existingSubmission] = await tx - .select({ id: submissions.id }) + const existingSubmissionRows = await tx + .select({ + id: submissions.id, + sourceId: submissions.sourceId, + }) .from(submissions) .where(eq(submissions.userId, tokenRecord.userId)) - .for('update') - .limit(1); + .for("update"); let submissionId: string; let isNewSubmission = false; + let upgradeLegacyRow = false; + + const scopeResolution = resolveSubmissionScope( + existingSubmissionRows, + sourceId + ); + + if (scopeResolution.kind === "rejectMissingSourceIdentity") { + throw new SourceIdentityRequiredError(); + } - if (existingSubmission) { - submissionId = existingSubmission.id; + if (scopeResolution.kind === "existing") { + submissionId = scopeResolution.submissionId; + upgradeLegacyRow = scopeResolution.upgradeLegacyRow; } else { isNewSubmission = true; const [newSubmission] = await tx .insert(submissions) .values({ userId: tokenRecord.userId, + sourceId, + sourceName, totalTokens: 0, totalCost: "0", inputTokens: 0, @@ -177,11 +260,41 @@ export async function POST(request: Request) { modelsUsed: [], status: "verified", cliVersion: data.meta.version, - submissionHash: generateSubmissionHash(hashData), }) + .onConflictDoNothing() .returning({ id: submissions.id }); - submissionId = newSubmission.id; + if (newSubmission) { + submissionId = newSubmission.id; + } else { + isNewSubmission = false; + const [conflictedSubmission] = await tx + .select({ id: submissions.id }) + .from(submissions) + .where( + sourceId + ? and( + eq(submissions.userId, tokenRecord.userId), + eq(submissions.sourceId, sourceId) + ) + : and( + eq(submissions.userId, tokenRecord.userId), + isNull(submissions.sourceId) + ) + ) + .for("update") + .limit(1); + + if (!conflictedSubmission) { + console.error( + "Submission row was not found after insert conflict", + { userId: tokenRecord.userId, sourceId } + ); + throw new Error("Submission row was not found after insert conflict"); + } + + submissionId = conflictedSubmission.id; + } } // ------------------------------------------ @@ -385,44 +498,56 @@ export async function POST(request: Request) { // ------------------------------------------ // STEP 3e: Update submission record // ------------------------------------------ + const submissionUpdate: Record = { + totalTokens: aggregates.totalTokens, + totalCost: aggregates.totalCost, + inputTokens: aggregates.inputTokens, + outputTokens: aggregates.outputTokens, + cacheReadTokens: totalCacheRead, + cacheCreationTokens: totalCacheCreation, + reasoningTokens: totalReasoning, + dateStart: aggregates.dateStart, + dateEnd: aggregates.dateEnd, + sourcesUsed: Array.from(allClients), + modelsUsed: Array.from(allModels), + cliVersion: data.meta.version, + submitCount: sql`COALESCE(submit_count, 0) + 1`, + schemaVersion: sql`GREATEST(COALESCE(${submissions.schemaVersion}, 0), ${data.contributions.some((c) => c.timestampMs != null) ? 1 : 0})`, + updatedAt: new Date(), + }; + + // Only write sourceName when the row is fresh this tx — either a + // brand-new insert (handled by the insert branch above) or a legacy + // unsourced row being promoted to source-scoped for the first time. + // For subsequent updates to an existing source-scoped row we must + // NOT overwrite sourceName, because the user may have renamed it via + // PATCH /api/settings/sources/:sourceId; otherwise every CLI submit + // would clobber the rename with the default "CLI on ". + if (upgradeLegacyRow) { + if (sourceId !== null) { + submissionUpdate.sourceId = sourceId; + } + if (sourceName !== null) { + submissionUpdate.sourceName = sourceName; + } + } + await tx .update(submissions) - .set({ - totalTokens: aggregates.totalTokens, - totalCost: aggregates.totalCost, - inputTokens: aggregates.inputTokens, - outputTokens: aggregates.outputTokens, - cacheReadTokens: totalCacheRead, - cacheCreationTokens: totalCacheCreation, - reasoningTokens: totalReasoning, - dateStart: aggregates.dateStart, - dateEnd: aggregates.dateEnd, - sourcesUsed: Array.from(allClients), - modelsUsed: Array.from(allModels), - cliVersion: data.meta.version, - submissionHash: generateSubmissionHash(hashData), - submitCount: sql`COALESCE(submit_count, 0) + 1`, - schemaVersion: sql`GREATEST(COALESCE(${submissions.schemaVersion}, 0), ${data.contributions.some((c) => c.timestampMs != null) ? 1 : 0})`, - updatedAt: new Date(), - }) + .set(submissionUpdate) .where(eq(submissions.id, submissionId)); + const metrics = await loadUserSubmitMetrics(tx, tokenRecord.userId); + return { submissionId, isNewSubmission, - metrics: { - totalTokens: aggregates.totalTokens, - totalCost: parseFloat(aggregates.totalCost), - dateRange: { - start: aggregates.dateStart, - end: aggregates.dateEnd, - }, - activeDays: aggregates.activeDays, - clients: Array.from(allClients), - }, + metrics, }; }); + const { metrics } = result; + try { const usernameCacheKey = normalizeUsernameCacheKey(tokenRecord.username); @@ -439,11 +564,20 @@ export async function POST(request: Request) { success: true, submissionId: result.submissionId, username: tokenRecord.username, - metrics: result.metrics, + metrics, mode: result.isNewSubmission ? "create" : "merge", warnings: validation.warnings.length > 0 ? validation.warnings : undefined, }); } catch (error) { + if (error instanceof SourceIdentityRequiredError) { + return NextResponse.json( + { + error: SOURCE_IDENTITY_REQUIRED_MESSAGE, + hint: SOURCE_IDENTITY_REQUIRED_HINT, + }, + { status: 409 } + ); + } console.error("Submit error:", error); return NextResponse.json( { error: "Internal server error" }, diff --git a/packages/frontend/src/app/api/users/[username]/route.ts b/packages/frontend/src/app/api/users/[username]/route.ts index 93508665b..bf5fbcd15 100644 --- a/packages/frontend/src/app/api/users/[username]/route.ts +++ b/packages/frontend/src/app/api/users/[username]/route.ts @@ -34,7 +34,7 @@ export async function GET(_request: Request, { params }: RouteParams) { createdAt: users.createdAt, }) .from(users) - .where(usernameEqualsIgnoreCase(username)) + .where(usernameEqualsIgnoreCase(users.username, username)) .limit(USERNAME_LOOKUP_LIMIT); const user = getSingleUsernameMatch(matchingUsers, username); @@ -59,7 +59,7 @@ export async function GET(_request: Request, { params }: RouteParams) { cacheReadTokens: sql`COALESCE(SUM(${submissions.cacheReadTokens}), 0)`, cacheCreationTokens: sql`COALESCE(SUM(${submissions.cacheCreationTokens}), 0)`, reasoningTokens: sql`COALESCE(SUM(${submissions.reasoningTokens}), 0)`, - submissionCount: sql`COALESCE(MAX(${submissions.submitCount}), 0)`, + submissionCount: sql`COALESCE(SUM(${submissions.submitCount}), 0)`, earliestDate: sql`MIN(${submissions.dateStart})`, latestDate: sql`MAX(${submissions.dateEnd})`, }) @@ -68,6 +68,7 @@ export async function GET(_request: Request, { params }: RouteParams) { db .select({ + id: submissions.id, sourcesUsed: submissions.sourcesUsed, modelsUsed: submissions.modelsUsed, updatedAt: submissions.updatedAt, @@ -76,8 +77,7 @@ export async function GET(_request: Request, { params }: RouteParams) { }) .from(submissions) .where(eq(submissions.userId, user.id)) - .orderBy(desc(submissions.updatedAt)) - .limit(1), + .orderBy(desc(submissions.updatedAt), desc(submissions.id)), db.execute<{ rank: number }>(sql` WITH user_totals AS ( @@ -121,6 +121,17 @@ export async function GET(_request: Request, { params }: RouteParams) { const [stats] = statsResult; const [latestSubmission] = latestSubmissionResult; const rank = (rankResult as unknown as { rank: number }[])[0]?.rank || null; + const clients = new Set(); + const models = new Set(); + + for (const submission of latestSubmissionResult) { + for (const client of submission.sourcesUsed || []) { + clients.add(normalizeClientId(client)); + } + for (const model of submission.modelsUsed || []) { + models.add(model); + } + } type ModelData = { tokens: number; @@ -454,8 +465,8 @@ export async function GET(_request: Request, { params }: RouteParams) { cliVersion: latestSubmission?.cliVersion, schemaVersion: latestSubmission?.schemaVersion, }), - clients: latestSubmission?.sourcesUsed || [], - models: latestSubmission?.modelsUsed || [], + clients: Array.from(clients).sort(), + models: Array.from(models).sort(), modelUsage, contributions: graphContributions, }); diff --git a/packages/frontend/src/app/api/users/[username]/sources/[sourceId]/route.ts b/packages/frontend/src/app/api/users/[username]/sources/[sourceId]/route.ts new file mode 100644 index 000000000..a983a8765 --- /dev/null +++ b/packages/frontend/src/app/api/users/[username]/sources/[sourceId]/route.ts @@ -0,0 +1,250 @@ +import { and, desc, eq, gte, isNull } from "drizzle-orm"; +import { NextResponse } from "next/server"; +import { db, dailyBreakdown, submissions, users } from "@/lib/db"; +import { + aggregateModelUsage, + createAccumulator, + decodeSourceParam, + InvalidSourceParamError, + mergeSourceContribution, + normalizeClientId, + sourceKey, + toIsoString, +} from "../shared"; + +export const revalidate = 60; + +interface RouteParams { + params: Promise<{ username: string; sourceId: string }>; +} + +export async function GET(_request: Request, { params }: RouteParams) { + try { + const { username, sourceId: sourceIdParam } = await params; + const resolvedSourceId = decodeSourceParam(sourceIdParam); + + const [user] = await db + .select({ + id: users.id, + username: users.username, + displayName: users.displayName, + avatarUrl: users.avatarUrl, + }) + .from(users) + .where(eq(users.username, username)) + .limit(1); + + if (!user) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + const oneYearAgo = new Date(); + oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1); + + const sourceWhere = resolvedSourceId === null + ? and(eq(submissions.userId, user.id), isNull(submissions.sourceId)) + : and(eq(submissions.userId, user.id), eq(submissions.sourceId, resolvedSourceId)); + + const [submissionRows, dailyRows] = await Promise.all([ + db + .select({ + id: submissions.id, + sourceId: submissions.sourceId, + sourceName: submissions.sourceName, + totalTokens: submissions.totalTokens, + totalCost: submissions.totalCost, + inputTokens: submissions.inputTokens, + outputTokens: submissions.outputTokens, + cacheReadTokens: submissions.cacheReadTokens, + cacheCreationTokens: submissions.cacheCreationTokens, + reasoningTokens: submissions.reasoningTokens, + submitCount: submissions.submitCount, + dateStart: submissions.dateStart, + dateEnd: submissions.dateEnd, + sourcesUsed: submissions.sourcesUsed, + modelsUsed: submissions.modelsUsed, + updatedAt: submissions.updatedAt, + }) + .from(submissions) + .where(sourceWhere) + .orderBy(desc(submissions.updatedAt), desc(submissions.id)), + + db + .select({ + date: dailyBreakdown.date, + timestampMs: dailyBreakdown.timestampMs, + tokens: dailyBreakdown.tokens, + cost: dailyBreakdown.cost, + inputTokens: dailyBreakdown.inputTokens, + outputTokens: dailyBreakdown.outputTokens, + sourceBreakdown: dailyBreakdown.sourceBreakdown, + }) + .from(dailyBreakdown) + .innerJoin(submissions, eq(dailyBreakdown.submissionId, submissions.id)) + .where( + and( + sourceWhere, + gte(dailyBreakdown.date, oneYearAgo.toISOString().split("T")[0]) + ) + ) + .orderBy(desc(dailyBreakdown.date)), + ]); + + if (submissionRows.length === 0) { + return NextResponse.json({ error: "Source not found" }, { status: 404 }); + } + + const source = createAccumulator( + submissionRows[0].sourceId, + submissionRows[0].sourceName + ); + + for (const row of submissionRows) { + const normalizedUpdatedAt = toIsoString(row.updatedAt); + + source.totalTokens += Number(row.totalTokens) || 0; + source.totalCost += Number(row.totalCost) || 0; + source.inputTokens += Number(row.inputTokens) || 0; + source.outputTokens += Number(row.outputTokens) || 0; + source.cacheReadTokens += Number(row.cacheReadTokens) || 0; + source.cacheWriteTokens += Number(row.cacheCreationTokens) || 0; + source.reasoningTokens += Number(row.reasoningTokens) || 0; + source.submissionCount += Number(row.submitCount) || 0; + + if (!source.updatedAt || (normalizedUpdatedAt && normalizedUpdatedAt > source.updatedAt)) { + source.updatedAt = normalizedUpdatedAt; + } + + if (row.dateStart && (!source.dateStart || row.dateStart < source.dateStart)) { + source.dateStart = row.dateStart; + } + if (row.dateEnd && (!source.dateEnd || row.dateEnd > source.dateEnd)) { + source.dateEnd = row.dateEnd; + } + + if (row.sourceName?.trim()) { + source.sourceName = row.sourceName.trim(); + } + + for (const client of row.sourcesUsed || []) { + source.clients.add(normalizeClientId(client)); + } + + for (const model of row.modelsUsed || []) { + source.models.add(model); + } + } + + for (const row of dailyRows) { + mergeSourceContribution(source, row); + } + + const contributions = Array.from(source.contributions.values()) + .sort((a, b) => a.date.localeCompare(b.date)) + .map((day) => { + let dayCacheRead = 0; + let dayCacheWrite = 0; + let dayReasoning = 0; + + for (const clientData of Object.values(day.clients)) { + dayCacheRead += clientData.cacheRead || 0; + dayCacheWrite += clientData.cacheWrite || 0; + dayReasoning += clientData.reasoning || 0; + } + + return { + date: day.date, + timestampMs: day.timestampMs, + totals: { + tokens: day.tokens, + cost: day.cost, + messages: 0, + }, + intensity: 0 as 0 | 1 | 2 | 3 | 4, + tokenBreakdown: { + input: day.inputTokens, + output: day.outputTokens, + cacheRead: dayCacheRead, + cacheWrite: dayCacheWrite, + reasoning: dayReasoning, + }, + clients: Object.entries(day.clients).map(([client, breakdown]) => ({ + client, + modelId: breakdown.modelId || "", + models: breakdown.models || {}, + tokens: { + input: breakdown.input || 0, + output: breakdown.output || 0, + cacheRead: breakdown.cacheRead || 0, + cacheWrite: breakdown.cacheWrite || 0, + reasoning: breakdown.reasoning || 0, + }, + cost: breakdown.cost || 0, + messages: breakdown.messages || 0, + })), + }; + }); + + const maxCost = Math.max(...contributions.map((c) => c.totals.cost), 0); + const normalizedContributions = contributions.map((day) => { + const cost = day.totals.cost; + const intensity = + maxCost === 0 + ? 0 + : cost === 0 + ? 0 + : cost <= maxCost * 0.25 + ? 1 + : cost <= maxCost * 0.5 + ? 2 + : cost <= maxCost * 0.75 + ? 3 + : 4; + return { + ...day, + intensity: intensity as 0 | 1 | 2 | 3 | 4, + }; + }); + + return NextResponse.json({ + user, + source: { + sourceId: source.sourceId, + sourceKey: sourceKey(source.sourceId), + sourceName: source.sourceName, + stats: { + totalTokens: source.totalTokens, + totalCost: source.totalCost, + inputTokens: source.inputTokens, + outputTokens: source.outputTokens, + cacheReadTokens: source.cacheReadTokens, + cacheWriteTokens: source.cacheWriteTokens, + reasoningTokens: source.reasoningTokens, + submissionCount: source.submissionCount, + activeDays: normalizedContributions.filter((day) => day.totals.tokens > 0).length, + }, + dateRange: { + start: source.dateStart, + end: source.dateEnd, + }, + updatedAt: source.updatedAt, + clients: Array.from(source.clients).sort(), + models: Array.from(source.models).sort(), + modelUsage: aggregateModelUsage(source), + contributions: normalizedContributions, + }, + }); + } catch (error) { + if (error instanceof InvalidSourceParamError) { + return NextResponse.json( + { error: "Invalid source id" }, + { status: 400 } + ); + } + console.error("User source detail error:", error); + return NextResponse.json( + { error: "Failed to fetch user source" }, + { status: 500 } + ); + } +} diff --git a/packages/frontend/src/app/api/users/[username]/sources/[sourceId]/summary/route.ts b/packages/frontend/src/app/api/users/[username]/sources/[sourceId]/summary/route.ts new file mode 100644 index 000000000..9348ab4f1 --- /dev/null +++ b/packages/frontend/src/app/api/users/[username]/sources/[sourceId]/summary/route.ts @@ -0,0 +1,195 @@ +import { and, eq, gte, isNull } from "drizzle-orm"; +import { NextResponse } from "next/server"; +import { db, dailyBreakdown, submissions, users } from "@/lib/db"; +import { + createAccumulator, + decodeSourceParam, + InvalidSourceParamError, + mergeSourceContribution, + sourceKey, + toIsoString, +} from "../../shared"; + +export const revalidate = 60; + +interface RouteParams { + params: Promise<{ username: string; sourceId: string }>; +} + +export async function GET(_request: Request, { params }: RouteParams) { + try { + const { username, sourceId: sourceIdParam } = await params; + const resolvedSourceId = decodeSourceParam(sourceIdParam); + + const [user] = await db + .select({ + id: users.id, + username: users.username, + displayName: users.displayName, + avatarUrl: users.avatarUrl, + }) + .from(users) + .where(eq(users.username, username)) + .limit(1); + + if (!user) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + const oneYearAgo = new Date(); + oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1); + + const sourceWhere = + resolvedSourceId === null + ? and(eq(submissions.userId, user.id), isNull(submissions.sourceId)) + : and(eq(submissions.userId, user.id), eq(submissions.sourceId, resolvedSourceId)); + + const [submissionRows, dailyRows] = await Promise.all([ + db + .select({ + id: submissions.id, + sourceId: submissions.sourceId, + sourceName: submissions.sourceName, + totalTokens: submissions.totalTokens, + totalCost: submissions.totalCost, + inputTokens: submissions.inputTokens, + outputTokens: submissions.outputTokens, + cacheReadTokens: submissions.cacheReadTokens, + cacheCreationTokens: submissions.cacheCreationTokens, + reasoningTokens: submissions.reasoningTokens, + submitCount: submissions.submitCount, + dateStart: submissions.dateStart, + dateEnd: submissions.dateEnd, + sourcesUsed: submissions.sourcesUsed, + modelsUsed: submissions.modelsUsed, + updatedAt: submissions.updatedAt, + }) + .from(submissions) + .where(sourceWhere), + db + .select({ + date: dailyBreakdown.date, + timestampMs: dailyBreakdown.timestampMs, + tokens: dailyBreakdown.tokens, + cost: dailyBreakdown.cost, + inputTokens: dailyBreakdown.inputTokens, + outputTokens: dailyBreakdown.outputTokens, + sourceBreakdown: dailyBreakdown.sourceBreakdown, + }) + .from(dailyBreakdown) + .innerJoin(submissions, eq(dailyBreakdown.submissionId, submissions.id)) + .where( + and( + sourceWhere, + gte(dailyBreakdown.date, oneYearAgo.toISOString().split("T")[0]) + ) + ), + ]); + + if (submissionRows.length === 0) { + return NextResponse.json({ error: "Source not found" }, { status: 404 }); + } + + const source = createAccumulator( + submissionRows[0].sourceId, + submissionRows[0].sourceName + ); + + for (const row of submissionRows) { + const normalizedUpdatedAt = toIsoString(row.updatedAt); + + source.totalTokens += Number(row.totalTokens) || 0; + source.totalCost += Number(row.totalCost) || 0; + source.inputTokens += Number(row.inputTokens) || 0; + source.outputTokens += Number(row.outputTokens) || 0; + source.cacheReadTokens += Number(row.cacheReadTokens) || 0; + source.cacheWriteTokens += Number(row.cacheCreationTokens) || 0; + source.reasoningTokens += Number(row.reasoningTokens) || 0; + source.submissionCount += Number(row.submitCount) || 0; + + if (!source.updatedAt || (normalizedUpdatedAt && normalizedUpdatedAt > source.updatedAt)) { + source.updatedAt = normalizedUpdatedAt; + } + + if (row.dateStart && (!source.dateStart || row.dateStart < source.dateStart)) { + source.dateStart = row.dateStart; + } + if (row.dateEnd && (!source.dateEnd || row.dateEnd > source.dateEnd)) { + source.dateEnd = row.dateEnd; + } + + if (row.sourceName?.trim()) { + source.sourceName = row.sourceName.trim(); + } + } + + for (const row of dailyRows) { + mergeSourceContribution(source, row); + } + + const activeDays = Array.from(source.contributions.values()).filter( + (day) => day.tokens > 0 + ).length; + + // Deterministic tie-break: when two clients/models have identical token + // counts, iteration order of Object.entries depends on insertion order, + // which in turn depends on DB row ordering — not stable across requests. + // Alphabetical fallback keeps the UI from flickering between equally-used + // entries on each render. + const topClient = Object.entries( + Array.from(source.contributions.values()).reduce>( + (acc, day) => { + for (const [client, breakdown] of Object.entries(day.clients)) { + acc[client] = (acc[client] || 0) + (breakdown.tokens || 0); + } + return acc; + }, + {} + ) + ).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0]?.[0] ?? null; + + const topModel = Object.entries( + Array.from(source.contributions.values()).reduce>( + (acc, day) => { + for (const [model, data] of Object.entries(day.models)) { + acc[model] = (acc[model] || 0) + (data.tokens || 0); + } + return acc; + }, + {} + ) + ).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0]?.[0] ?? null; + + return NextResponse.json({ + user, + source: { + sourceId: source.sourceId, + sourceKey: sourceKey(source.sourceId), + sourceName: source.sourceName, + totalTokens: source.totalTokens, + totalCost: source.totalCost, + submissionCount: source.submissionCount, + activeDays, + updatedAt: source.updatedAt, + dateRange: { + start: source.dateStart, + end: source.dateEnd, + }, + topClient, + topModel, + }, + }); + } catch (error) { + if (error instanceof InvalidSourceParamError) { + return NextResponse.json( + { error: "Invalid source id" }, + { status: 400 } + ); + } + console.error("User source summary error:", error); + return NextResponse.json( + { error: "Failed to fetch user source summary" }, + { status: 500 } + ); + } +} diff --git a/packages/frontend/src/app/api/users/[username]/sources/route.ts b/packages/frontend/src/app/api/users/[username]/sources/route.ts new file mode 100644 index 000000000..97b3229f4 --- /dev/null +++ b/packages/frontend/src/app/api/users/[username]/sources/route.ts @@ -0,0 +1,162 @@ +import { and, desc, eq, gte, sql } from "drizzle-orm"; +import { NextResponse } from "next/server"; +import { db, dailyBreakdown, submissions, users } from "@/lib/db"; +import { + createAccumulator, + normalizeClientId, + sourceKey, + toIsoString, +} from "./shared"; + +export const revalidate = 60; + +interface RouteParams { + params: Promise<{ username: string }>; +} + +export async function GET(_request: Request, { params }: RouteParams) { + try { + const { username } = await params; + + const [user] = await db + .select({ + id: users.id, + username: users.username, + displayName: users.displayName, + avatarUrl: users.avatarUrl, + }) + .from(users) + .where(eq(users.username, username)) + .limit(1); + + if (!user) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + const oneYearAgo = new Date(); + oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1); + + const [submissionRows, activeDayRows] = await Promise.all([ + db + .select({ + id: submissions.id, + sourceId: submissions.sourceId, + sourceName: submissions.sourceName, + totalTokens: submissions.totalTokens, + totalCost: submissions.totalCost, + inputTokens: submissions.inputTokens, + outputTokens: submissions.outputTokens, + cacheReadTokens: submissions.cacheReadTokens, + cacheCreationTokens: submissions.cacheCreationTokens, + reasoningTokens: submissions.reasoningTokens, + submitCount: submissions.submitCount, + dateStart: submissions.dateStart, + dateEnd: submissions.dateEnd, + sourcesUsed: submissions.sourcesUsed, + modelsUsed: submissions.modelsUsed, + updatedAt: submissions.updatedAt, + }) + .from(submissions) + .where(eq(submissions.userId, user.id)) + .orderBy(desc(submissions.updatedAt), desc(submissions.id)), + db + .select({ + sourceId: submissions.sourceId, + activeDays: sql`COUNT(DISTINCT CASE WHEN ${dailyBreakdown.tokens} > 0 THEN ${dailyBreakdown.date} END)::int`, + }) + .from(dailyBreakdown) + .innerJoin(submissions, eq(dailyBreakdown.submissionId, submissions.id)) + .where( + and( + eq(submissions.userId, user.id), + gte(dailyBreakdown.date, oneYearAgo.toISOString().split("T")[0]) + ) + ) + .groupBy(submissions.sourceId), + ]); + + const bySource = new Map>(); + const activeDaysBySource = new Map( + activeDayRows.map((row) => [sourceKey(row.sourceId), Number(row.activeDays) || 0]) + ); + + for (const row of submissionRows) { + const key = sourceKey(row.sourceId); + if (!bySource.has(key)) { + bySource.set(key, createAccumulator(row.sourceId, row.sourceName)); + } + + const source = bySource.get(key)!; + const normalizedUpdatedAt = toIsoString(row.updatedAt); + + source.totalTokens += Number(row.totalTokens) || 0; + source.totalCost += Number(row.totalCost) || 0; + source.inputTokens += Number(row.inputTokens) || 0; + source.outputTokens += Number(row.outputTokens) || 0; + source.cacheReadTokens += Number(row.cacheReadTokens) || 0; + source.cacheWriteTokens += Number(row.cacheCreationTokens) || 0; + source.reasoningTokens += Number(row.reasoningTokens) || 0; + source.submissionCount += Number(row.submitCount) || 0; + + if (!source.updatedAt || (normalizedUpdatedAt && normalizedUpdatedAt > source.updatedAt)) { + source.updatedAt = normalizedUpdatedAt; + } + + if (row.dateStart && (!source.dateStart || row.dateStart < source.dateStart)) { + source.dateStart = row.dateStart; + } + if (row.dateEnd && (!source.dateEnd || row.dateEnd > source.dateEnd)) { + source.dateEnd = row.dateEnd; + } + + if (row.sourceName?.trim()) { + source.sourceName = row.sourceName.trim(); + } + + for (const client of row.sourcesUsed || []) { + source.clients.add(normalizeClientId(client)); + } + + for (const model of row.modelsUsed || []) { + source.models.add(model); + } + } + + const sources = Array.from(bySource.values()) + .map((source) => ({ + sourceId: source.sourceId, + sourceKey: sourceKey(source.sourceId), + sourceName: source.sourceName, + stats: { + totalTokens: source.totalTokens, + totalCost: source.totalCost, + inputTokens: source.inputTokens, + outputTokens: source.outputTokens, + cacheReadTokens: source.cacheReadTokens, + cacheWriteTokens: source.cacheWriteTokens, + reasoningTokens: source.reasoningTokens, + submissionCount: source.submissionCount, + activeDays: activeDaysBySource.get(sourceKey(source.sourceId)) ?? 0, + }, + dateRange: { + start: source.dateStart, + end: source.dateEnd, + }, + updatedAt: source.updatedAt, + clients: Array.from(source.clients).sort(), + models: Array.from(source.models).sort(), + })) + .sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? "")); + + return NextResponse.json({ + user, + sources, + }); + } catch (error) { + console.error("User sources summary error:", error); + return NextResponse.json( + { error: "Failed to fetch user sources" }, + { status: 500 } + ); + } +} diff --git a/packages/frontend/src/app/api/users/[username]/sources/shared.ts b/packages/frontend/src/app/api/users/[username]/sources/shared.ts new file mode 100644 index 000000000..b8b866155 --- /dev/null +++ b/packages/frontend/src/app/api/users/[username]/sources/shared.ts @@ -0,0 +1,291 @@ +export const LEGACY_SOURCE_PARAM = "__legacy__"; +const SOURCE_KEY_PREFIX = "source:"; + +export type ModelData = { + tokens: number; + cost: number; + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + reasoning: number; + messages: number; +}; + +export type ClientBreakdown = { + tokens: number; + cost: number; + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + reasoning: number; + messages: number; + models?: Record; + modelId?: string; +}; + +export type SourceContributionAggregate = { + date: string; + timestampMs: number | null; + tokens: number; + cost: number; + inputTokens: number; + outputTokens: number; + clients: Record; + models: Record; +}; + +export type SourceSummaryAccumulator = { + sourceId: string | null; + sourceName: string; + totalTokens: number; + totalCost: number; + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheWriteTokens: number; + reasoningTokens: number; + submissionCount: number; + updatedAt: string | null; + dateStart: string | null; + dateEnd: string | null; + clients: Set; + models: Set; + contributions: Map; +}; + +const LEGACY_CLIENT_ALIASES: Record = { kilocode: "kilo" }; + +export function normalizeClientId(id: string): string { + return LEGACY_CLIENT_ALIASES[id] ?? id; +} + +export class InvalidSourceParamError extends Error { + constructor(message = "Invalid source id") { + super(message); + this.name = "InvalidSourceParamError"; + } +} + +export function sourceKey(sourceId: string | null): string { + return sourceId == null + ? LEGACY_SOURCE_PARAM + : `${SOURCE_KEY_PREFIX}${encodeURIComponent(sourceId)}`; +} + +function safeDecodeURIComponent(value: string): string { + try { + return decodeURIComponent(value); + } catch { + throw new InvalidSourceParamError(); + } +} + +export function decodeSourceParam(sourceIdOrLegacy: string): string | null { + if (sourceIdOrLegacy === LEGACY_SOURCE_PARAM) { + return null; + } + + if (!sourceIdOrLegacy.startsWith(SOURCE_KEY_PREFIX)) { + return safeDecodeURIComponent(sourceIdOrLegacy); + } + + return safeDecodeURIComponent(sourceIdOrLegacy.slice(SOURCE_KEY_PREFIX.length)); +} + +export function toIsoString(value: Date | string | null | undefined): string | null { + if (!value) return null; + if (value instanceof Date) return value.toISOString(); + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date.toISOString(); +} + +export function toSourceName( + sourceId: string | null, + sourceName: string | null | undefined +): string { + const trimmed = sourceName?.trim(); + if (trimmed) return trimmed; + return sourceId == null ? "Legacy / Unknown device" : "Unknown device"; +} + +export function createAccumulator( + sourceId: string | null, + sourceName: string | null | undefined +): SourceSummaryAccumulator { + return { + sourceId, + sourceName: toSourceName(sourceId, sourceName), + totalTokens: 0, + totalCost: 0, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + reasoningTokens: 0, + submissionCount: 0, + updatedAt: null, + dateStart: null, + dateEnd: null, + clients: new Set(), + models: new Set(), + contributions: new Map(), + }; +} + +export function mergeSourceContribution( + accumulator: SourceSummaryAccumulator, + row: { + date: string; + timestampMs: number | null; + tokens: number; + cost: string | number; + inputTokens: number; + outputTokens: number; + sourceBreakdown: unknown; + } +) { + const existing = accumulator.contributions.get(row.date); + const normalizedCost = Number(row.cost) || 0; + + const target: SourceContributionAggregate = existing ?? { + date: row.date, + timestampMs: row.timestampMs ?? null, + tokens: 0, + cost: 0, + inputTokens: 0, + outputTokens: 0, + clients: {}, + models: {}, + }; + + if (existing && row.timestampMs != null) { + target.timestampMs = + target.timestampMs != null + ? Math.min(target.timestampMs, row.timestampMs) + : row.timestampMs; + } + + target.tokens += Number(row.tokens) || 0; + target.cost += normalizedCost; + target.inputTokens += Number(row.inputTokens) || 0; + target.outputTokens += Number(row.outputTokens) || 0; + + if (row.sourceBreakdown && typeof row.sourceBreakdown === "object") { + for (const [rawClient, data] of Object.entries( + row.sourceBreakdown as Record + )) { + const client = normalizeClientId(rawClient); + const breakdown = data as ClientBreakdown; + + const existingClient = target.clients[client]; + if (existingClient) { + existingClient.tokens += breakdown.tokens || 0; + existingClient.cost += breakdown.cost || 0; + existingClient.input += breakdown.input || 0; + existingClient.output += breakdown.output || 0; + existingClient.cacheRead += breakdown.cacheRead || 0; + existingClient.cacheWrite += breakdown.cacheWrite || 0; + existingClient.reasoning += breakdown.reasoning || 0; + existingClient.messages += breakdown.messages || 0; + } else { + target.clients[client] = { + tokens: breakdown.tokens || 0, + cost: breakdown.cost || 0, + input: breakdown.input || 0, + output: breakdown.output || 0, + cacheRead: breakdown.cacheRead || 0, + cacheWrite: breakdown.cacheWrite || 0, + reasoning: breakdown.reasoning || 0, + messages: breakdown.messages || 0, + models: {}, + modelId: breakdown.modelId, + }; + } + + if (breakdown.models && Object.keys(breakdown.models).length > 0) { + target.clients[client].models = target.clients[client].models || {}; + for (const [modelId, modelData] of Object.entries(breakdown.models)) { + const existingModel = target.clients[client].models![modelId]; + if (existingModel) { + existingModel.tokens += modelData.tokens || 0; + existingModel.cost += modelData.cost || 0; + existingModel.input += modelData.input || 0; + existingModel.output += modelData.output || 0; + existingModel.cacheRead += modelData.cacheRead || 0; + existingModel.cacheWrite += modelData.cacheWrite || 0; + existingModel.reasoning += modelData.reasoning || 0; + existingModel.messages += modelData.messages || 0; + } else { + target.clients[client].models![modelId] = { + tokens: modelData.tokens || 0, + cost: modelData.cost || 0, + input: modelData.input || 0, + output: modelData.output || 0, + cacheRead: modelData.cacheRead || 0, + cacheWrite: modelData.cacheWrite || 0, + reasoning: modelData.reasoning || 0, + messages: modelData.messages || 0, + }; + } + + const existingSourceModel = target.models[modelId]; + if (existingSourceModel) { + existingSourceModel.tokens += modelData.tokens || 0; + existingSourceModel.cost += modelData.cost || 0; + } else { + target.models[modelId] = { + tokens: modelData.tokens || 0, + cost: modelData.cost || 0, + }; + } + } + } else if (breakdown.modelId) { + const existingSourceModel = target.models[breakdown.modelId]; + if (existingSourceModel) { + existingSourceModel.tokens += breakdown.tokens || 0; + existingSourceModel.cost += breakdown.cost || 0; + } else { + target.models[breakdown.modelId] = { + tokens: breakdown.tokens || 0, + cost: breakdown.cost || 0, + }; + } + } + } + } + + accumulator.contributions.set(row.date, target); +} + +export function aggregateModelUsage( + source: SourceSummaryAccumulator +): Array<{ model: string; tokens: number; cost: number; percentage: number }> { + const aggregatedModels = Array.from(source.contributions.values()).reduce< + Record + >((acc, day) => { + for (const [model, data] of Object.entries(day.models)) { + const existing = acc[model] || { tokens: 0, cost: 0 }; + existing.tokens += data.tokens; + existing.cost += data.cost; + acc[model] = existing; + } + return acc; + }, {}); + + const totalModelCost = Object.values(aggregatedModels).reduce( + (sum, model) => sum + model.cost, + 0 + ); + + return Object.entries(aggregatedModels) + .filter(([model]) => model !== "") + .map(([model, data]) => ({ + model, + tokens: data.tokens, + cost: data.cost, + percentage: totalModelCost > 0 ? (data.cost / totalModelCost) * 100 : 0, + })) + .sort((a, b) => b.cost - a.cost || b.tokens - a.tokens); +} diff --git a/packages/frontend/src/app/u/[username]/ProfilePageClient.tsx b/packages/frontend/src/app/u/[username]/ProfilePageClient.tsx index a2ec2bf9f..eb5ff3fda 100644 --- a/packages/frontend/src/app/u/[username]/ProfilePageClient.tsx +++ b/packages/frontend/src/app/u/[username]/ProfilePageClient.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useMemo } from "react"; +import { useEffect, useMemo, useState } from "react"; import styled from "styled-components"; import { Navigation } from "@/components/layout/Navigation"; import { Footer } from "@/components/layout/Footer"; @@ -18,6 +18,7 @@ import { type ModelUsage, } from "@/components/profile"; import type { TokenContributionData, DailyContribution, ClientType } from "@/lib/types"; +import { formatCurrency, formatNumber } from "@/lib/utils"; interface ProfileData { user: { @@ -51,72 +52,171 @@ interface ProfileData { interface ProfilePageClientProps { initialData: ProfileData; + initialSources: SourceSummaryData[]; + initialSelectedSource: SourceDetailData | null; + initialSelectedSourceSummary: SourcePreviewSummary | null; username: string; } -export default function ProfilePageClient({ initialData, username }: ProfilePageClientProps) { - const [activeTab, setActiveTab] = useState("activity"); - const data = initialData; +interface SourceSummaryData { + sourceId: string | null; + sourceKey: string; + sourceName: string; + stats: { + totalTokens: number; + totalCost: number; + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheWriteTokens: number; + reasoningTokens: number; + submissionCount: number; + activeDays: number; + }; + dateRange: { + start: string | null; + end: string | null; + }; + updatedAt: string | null; + clients: string[]; + models: string[]; +} - const graphData: TokenContributionData | null = useMemo(() => { - if (!data || data.contributions.length === 0) return null; - - const contributions = data.contributions; - const totalCost = data.stats.totalCost; - const totalTokens = data.stats.totalTokens; - const maxCost = Math.max(...contributions.map((c) => c.totals.cost), 0); - - const yearMap = new Map(); - for (const day of contributions) { - const year = day.date.split("-")[0]; - const existing = yearMap.get(year); - if (existing) { - existing.totalTokens += day.totals.tokens; - existing.totalCost += day.totals.cost; - if (day.date < existing.start) existing.start = day.date; - if (day.date > existing.end) existing.end = day.date; - } else { - yearMap.set(year, { - totalTokens: day.totals.tokens, - totalCost: day.totals.cost, - start: day.date, - end: day.date, - }); - } +interface SourceDetailData extends SourceSummaryData { + modelUsage?: ModelUsage[]; + contributions: DailyContribution[]; +} + +interface SourcePreviewSummary { + sourceId: string | null; + sourceKey: string; + sourceName: string; + totalTokens: number; + totalCost: number; + submissionCount: number; + activeDays: number; + updatedAt: string | null; + dateRange: { + start: string | null; + end: string | null; + }; + topClient: string | null; + topModel: string | null; +} + +function buildGraphData( + contributions: DailyContribution[], + stats: { + totalTokens: number; + totalCost: number; + activeDays: number; + }, + dateRange: { start: string | null; end: string | null }, + clients: string[], + models: string[] +): TokenContributionData | null { + if (contributions.length === 0) return null; + + const maxCost = Math.max(...contributions.map((c) => c.totals.cost), 0); + const yearMap = new Map(); + + for (const day of contributions) { + const year = day.date.split("-")[0]; + const existing = yearMap.get(year); + if (existing) { + existing.totalTokens += day.totals.tokens; + existing.totalCost += day.totals.cost; + if (day.date < existing.start) existing.start = day.date; + if (day.date > existing.end) existing.end = day.date; + } else { + yearMap.set(year, { + totalTokens: day.totals.tokens, + totalCost: day.totals.cost, + start: day.date, + end: day.date, + }); } + } - const years = Array.from(yearMap.entries()) - .sort((a, b) => a[0].localeCompare(b[0])) - .map(([year, stats]) => ({ - year, - totalTokens: stats.totalTokens, - totalCost: stats.totalCost, - range: { start: stats.start, end: stats.end }, - })); - - return { - meta: { - generatedAt: new Date().toISOString(), - version: "1.0.0", - dateRange: { - start: data.dateRange.start || contributions[0]?.date || "", - end: data.dateRange.end || contributions[contributions.length - 1]?.date || "", - }, - }, - summary: { - totalTokens, - totalCost, - totalDays: contributions.length, - activeDays: data.stats.activeDays, - averagePerDay: data.stats.activeDays > 0 ? totalCost / data.stats.activeDays : 0, - maxCostInSingleDay: maxCost, - clients: data.clients as ClientType[], - models: data.models, + const years = Array.from(yearMap.entries()) + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([year, yearStats]) => ({ + year, + totalTokens: yearStats.totalTokens, + totalCost: yearStats.totalCost, + range: { start: yearStats.start, end: yearStats.end }, + })); + + return { + meta: { + generatedAt: new Date().toISOString(), + version: "1.0.0", + dateRange: { + start: dateRange.start || contributions[0]?.date || "", + end: dateRange.end || contributions[contributions.length - 1]?.date || "", }, - years, - contributions: contributions as DailyContribution[], - }; - }, [data]); + }, + summary: { + totalTokens: stats.totalTokens, + totalCost: stats.totalCost, + totalDays: contributions.length, + activeDays: stats.activeDays, + averagePerDay: stats.activeDays > 0 ? stats.totalCost / stats.activeDays : 0, + maxCostInSingleDay: maxCost, + clients: clients as ClientType[], + models, + }, + years, + contributions, + }; +} + +export default function ProfilePageClient({ + initialData, + initialSources, + initialSelectedSource, + initialSelectedSourceSummary, + username, +}: ProfilePageClientProps) { + const [activeTab, setActiveTab] = useState("activity"); + const [selectedSourceKey, setSelectedSourceKey] = useState( + initialSources[0]?.sourceKey ?? null + ); + const [loadingSourceKey, setLoadingSourceKey] = useState( + initialSelectedSource ? null : (initialSources[0]?.sourceKey ?? null) + ); + const [sourceDetailCache, setSourceDetailCache] = useState>( + initialSelectedSource ? { [initialSelectedSource.sourceKey]: initialSelectedSource } : {} + ); + const [sourceSummaryCache, setSourceSummaryCache] = useState>( + initialSelectedSourceSummary + ? { [initialSelectedSourceSummary.sourceKey]: initialSelectedSourceSummary } + : {} + ); + // Keyed by sourceKey: records which fetches failed so the UI can show an + // inline retry message instead of an infinite spinner. Summary failures + // degrade silently (the preview card just hides), but detail failures + // need visible feedback because the tab would otherwise appear stuck. + const [sourceDetailErrorCache, setSourceDetailErrorCache] = useState< + Record + >({}); + const data = initialData; + + const graphData = useMemo( + () => + buildGraphData( + data.contributions, + { + totalTokens: data.stats.totalTokens, + totalCost: data.stats.totalCost, + activeDays: data.stats.activeDays, + }, + data.dateRange, + data.clients, + data.models + ), + [data] + ); const user: ProfileUser = useMemo(() => ({ username: data.user.username, @@ -136,9 +236,126 @@ export default function ProfilePageClient({ initialData, username }: ProfilePage submissionCount: data.stats.submissionCount, }), [data]); -const EARLY_ADOPTERS = ["code-yeongyu", "gtg7784", "qodot"]; + const EARLY_ADOPTERS = ["code-yeongyu", "gtg7784", "qodot"]; const showResubmitBanner = EARLY_ADOPTERS.includes(data.user.username) && data.stats.submissionCount === 1; + const selectedSourceSummary = useMemo(() => { + if (initialSources.length === 0) return null; + return ( + initialSources.find((source) => source.sourceKey === selectedSourceKey) + ?? initialSources[0] + ); + }, [initialSources, selectedSourceKey]); + + const selectedSource = useMemo( + () => (selectedSourceKey ? sourceDetailCache[selectedSourceKey] ?? null : null), + [selectedSourceKey, sourceDetailCache] + ); + + const selectedSourcePreview = useMemo( + () => (selectedSourceKey ? sourceSummaryCache[selectedSourceKey] ?? null : null), + [selectedSourceKey, sourceSummaryCache] + ); + + useEffect(() => { + if (!selectedSourceKey || sourceDetailCache[selectedSourceKey]) { + return; + } + + let cancelled = false; + + fetch(`/api/users/${username}/sources/${encodeURIComponent(selectedSourceKey)}`) + .then(async (response) => { + if (!response.ok) { + throw new Error(`Failed to fetch source detail: ${response.status}`); + } + return response.json(); + }) + .then((payload) => { + if (cancelled || !payload?.source) return; + setSourceDetailCache((current) => ({ + ...current, + [selectedSourceKey]: payload.source as SourceDetailData, + })); + // Clear any prior error for this key after a successful retry. + setSourceDetailErrorCache((current) => { + if (!(selectedSourceKey in current)) return current; + const next = { ...current }; + delete next[selectedSourceKey]; + return next; + }); + }) + .catch((error: unknown) => { + if (cancelled) return; + const message = + error instanceof Error ? error.message : "Failed to load source details"; + console.error(error); + setSourceDetailErrorCache((current) => ({ + ...current, + [selectedSourceKey]: message, + })); + }) + .finally(() => { + if (!cancelled) { + setLoadingSourceKey((current) => + current === selectedSourceKey ? null : current + ); + } + }); + + return () => { + cancelled = true; + }; + }, [selectedSourceKey, sourceDetailCache, username]); + + useEffect(() => { + if (!selectedSourceKey || sourceSummaryCache[selectedSourceKey]) { + return; + } + + let cancelled = false; + + fetch(`/api/users/${username}/sources/${encodeURIComponent(selectedSourceKey)}/summary`) + .then(async (response) => { + if (!response.ok) { + throw new Error(`Failed to fetch source summary: ${response.status}`); + } + return response.json(); + }) + .then((payload) => { + if (cancelled || !payload?.source) return; + setSourceSummaryCache((current) => ({ + ...current, + [selectedSourceKey]: payload.source as SourcePreviewSummary, + })); + }) + .catch((error) => { + console.error(error); + }); + + return () => { + cancelled = true; + }; + }, [selectedSourceKey, sourceSummaryCache, username]); + + const selectedSourceGraphData = useMemo( + () => + selectedSource + ? buildGraphData( + selectedSource.contributions, + { + totalTokens: selectedSource.stats.totalTokens, + totalCost: selectedSource.stats.totalCost, + activeDays: selectedSource.stats.activeDays, + }, + selectedSource.dateRange, + selectedSource.clients, + selectedSource.models + ) + : null, + [selectedSource] + ); + return ( @@ -203,6 +420,144 @@ const EARLY_ADOPTERS = ["code-yeongyu", "gtg7784", "qodot"]; )} + {activeTab === "sources" && ( +
+ {initialSources.length > 0 ? ( + + + {initialSources.map((source) => { + const isSelected = source.sourceKey === selectedSourceKey; + + return ( + { + setSelectedSourceKey(source.sourceKey); + setLoadingSourceKey( + sourceDetailCache[source.sourceKey] ? null : source.sourceKey + ); + }} + type="button" + > + + {source.sourceName} + + {source.updatedAt + ? new Date(source.updatedAt).toLocaleDateString() + : "No updates"} + + + {formatNumber(source.stats.totalTokens)} + {formatCurrency(source.stats.totalCost)} + + {source.stats.submissionCount} submits + {source.stats.activeDays} active days + + + ); + })} + + + {selectedSourceSummary && ( + + + {selectedSourceSummary.sourceName} + + {selectedSourceSummary.sourceId ?? "legacy"} ·{" "} + {selectedSourceSummary.updatedAt + ? `Updated ${new Date(selectedSourceSummary.updatedAt).toLocaleString()}` + : "No updates yet"} + + + + + {selectedSourceSummary.clients.map((client) => ( + {client} + ))} + {selectedSourceSummary.models.slice(0, 8).map((model) => ( + {model} + ))} + + + {selectedSourcePreview && ( + + + Quick Preview + + {selectedSourcePreview.updatedAt + ? new Date(selectedSourcePreview.updatedAt).toLocaleDateString() + : "No updates"} + + + + + Top client + + {selectedSourcePreview.topClient ?? "—"} + + + + Top model + + {selectedSourcePreview.topModel ?? "—"} + + + + Submissions + + {selectedSourcePreview.submissionCount} + + + + Active days + + {selectedSourcePreview.activeDays} + + + + + )} + + {selectedSourceKey && sourceDetailErrorCache[selectedSourceKey] ? ( + + Couldn’t load this device right now. Please try + again in a moment. + + ) : loadingSourceKey === selectedSourceKey && !selectedSource ? ( + + Loading source details… + + ) : selectedSourceGraphData && selectedSource ? ( + + + + current.cost > max.cost ? current : max, + selectedSource.modelUsage[0])?.model + } + /> + + + + ) : ( + + )} + + )} + + ) : } +
+ )} @@ -289,3 +644,186 @@ const ActivitySection = styled.div` flex-direction: column; gap: 24px; `; + +const SourcesSection = styled.div` + display: flex; + flex-direction: column; + gap: 24px; +`; + +const SourcesGrid = styled.div` + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 16px; +`; + +const SourceCard = styled.button<{ $selected: boolean }>` + display: flex; + flex-direction: column; + gap: 8px; + border-radius: 16px; + border: 1px solid; + padding: 16px; + text-align: left; + cursor: pointer; + background-color: ${({ $selected }) => + $selected ? "var(--color-bg-active)" : "var(--color-bg-elevated)"}; + border-color: ${({ $selected }) => + $selected ? "var(--color-fg-default)" : "var(--color-border-default)"}; + transition: transform 120ms ease, border-color 120ms ease; + + &:hover { + transform: translateY(-1px); + } +`; + +const SourceCardHeader = styled.div` + display: flex; + justify-content: space-between; + gap: 8px; + align-items: flex-start; +`; + +const SourceCardTitle = styled.span` + font-size: 1rem; + font-weight: 700; + color: var(--color-fg-default); +`; + +const SourceCardUpdated = styled.span` + font-size: 0.75rem; + color: var(--color-fg-muted); +`; + +const SourceCardValue = styled.span` + font-size: 1.375rem; + font-weight: 800; + color: var(--color-fg-default); +`; + +const SourceCardSubValue = styled.span` + font-size: 0.95rem; + font-weight: 600; + color: var(--color-fg-muted); +`; + +const SourceCardMeta = styled.div` + display: flex; + gap: 12px; + flex-wrap: wrap; + font-size: 0.8rem; + color: var(--color-fg-muted); +`; + +const SelectedSourceSection = styled.div` + display: flex; + flex-direction: column; + gap: 16px; +`; + +const SelectedSourceHeader = styled.div` + display: flex; + flex-direction: column; + gap: 6px; +`; + +const SelectedSourceTitle = styled.h2` + font-size: 1.25rem; + font-weight: 800; + color: var(--color-fg-default); +`; + +const SelectedSourceSubtitle = styled.p` + font-size: 0.875rem; + color: var(--color-fg-muted); + word-break: break-word; +`; + +const SourceTagRow = styled.div` + display: flex; + flex-wrap: wrap; + gap: 8px; +`; + +const SourceTag = styled.span` + padding: 6px 10px; + border-radius: 999px; + border: 1px solid var(--color-border-default); + background-color: var(--color-bg-elevated); + color: var(--color-fg-muted); + font-size: 0.8rem; + font-weight: 600; +`; + +const SourcePreviewCard = styled.div` + border-radius: 16px; + border: 1px solid var(--color-border-default); + background-color: var(--color-bg-elevated); + padding: 16px; + display: flex; + flex-direction: column; + gap: 12px; +`; + +const SourcePreviewHeader = styled.div` + display: flex; + justify-content: space-between; + gap: 8px; + align-items: center; +`; + +const SourcePreviewTitle = styled.h3` + font-size: 0.95rem; + font-weight: 800; + color: var(--color-fg-default); +`; + +const SourcePreviewUpdated = styled.span` + font-size: 0.75rem; + color: var(--color-fg-muted); +`; + +const SourcePreviewGrid = styled.div` + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 12px; +`; + +const SourcePreviewItem = styled.div` + display: flex; + flex-direction: column; + gap: 4px; +`; + +const SourcePreviewLabel = styled.span` + font-size: 0.75rem; + color: var(--color-fg-muted); + font-weight: 600; +`; + +const SourcePreviewValue = styled.span` + font-size: 0.95rem; + color: var(--color-fg-default); + font-weight: 700; + word-break: break-word; +`; + +const SourceLoadingCard = styled.div` + border-radius: 16px; + border: 1px solid var(--color-border-default); + background-color: var(--color-bg-elevated); + color: var(--color-fg-muted); + padding: 20px; + font-size: 0.95rem; + font-weight: 600; +`; + +const SourceErrorCard = styled.div` + border-radius: 16px; + border: 1px solid var(--color-border-default); + background-color: var(--color-bg-elevated); + color: var(--color-fg-default); + padding: 20px; + font-size: 0.95rem; + font-weight: 600; +`; diff --git a/packages/frontend/src/app/u/[username]/page.tsx b/packages/frontend/src/app/u/[username]/page.tsx index 8f09299a3..7c106bba1 100644 --- a/packages/frontend/src/app/u/[username]/page.tsx +++ b/packages/frontend/src/app/u/[username]/page.tsx @@ -22,6 +22,69 @@ async function getProfileData(username: string) { return res.json(); } +async function getSourceSummaries(username: string) { + try { + const baseUrl = process.env.NEXT_PUBLIC_URL + || (process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : null) + || 'http://127.0.0.1:3000'; + + const res = await fetch(`${baseUrl}/api/users/${username}/sources`, { + next: { revalidate: 60 }, + }); + + if (!res.ok) { + return { sources: [] }; + } + + return res.json(); + } catch { + return { sources: [] }; + } +} + +async function getSourceDetail(username: string, sourceKey: string) { + try { + const baseUrl = process.env.NEXT_PUBLIC_URL + || (process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : null) + || 'http://127.0.0.1:3000'; + + const res = await fetch(`${baseUrl}/api/users/${username}/sources/${encodeURIComponent(sourceKey)}`, { + next: { revalidate: 60 }, + }); + + if (!res.ok) { + return { source: null }; + } + + return res.json(); + } catch { + return { source: null }; + } +} + +async function getSourceSummary(username: string, sourceKey: string) { + try { + const baseUrl = process.env.NEXT_PUBLIC_URL + || (process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : null) + || 'http://127.0.0.1:3000'; + + const res = await fetch( + `${baseUrl}/api/users/${username}/sources/${encodeURIComponent(sourceKey)}/summary`, + { + next: { revalidate: 60 }, + } + ); + + if (!res.ok) { + return { source: null }; + } + + return res.json(); + } catch { + return { source: null }; + } +} + export async function generateMetadata({ params }: { params: Promise<{ username: string }> }): Promise { const { username } = await params; return { @@ -52,7 +115,10 @@ export async function generateMetadata({ params }: { params: Promise<{ username: export default async function ProfilePage({ params }: { params: Promise<{ username: string }> }) { const { username } = await params; - const data = await getProfileData(username); + const [data, sourceData] = await Promise.all([ + getProfileData(username), + getSourceSummaries(username), + ]); if (!data) { notFound(); @@ -61,6 +127,22 @@ export default async function ProfilePage({ params }: { params: Promise<{ userna if (data.user?.username && data.user.username !== username) { permanentRedirect(`/u/${data.user.username}`); } + + const initialSourceKey = sourceData?.sources?.[0]?.sourceKey; + const [sourceDetailData, sourceSummaryData] = initialSourceKey + ? await Promise.all([ + getSourceDetail(username, initialSourceKey), + getSourceSummary(username, initialSourceKey), + ]) + : [{ source: null }, { source: null }]; - return ; + return ( + + ); } diff --git a/packages/frontend/src/components/profile/index.tsx b/packages/frontend/src/components/profile/index.tsx index 983afd293..42dc9ed9e 100644 --- a/packages/frontend/src/components/profile/index.tsx +++ b/packages/frontend/src/components/profile/index.tsx @@ -533,7 +533,7 @@ const EmbedIcon: React.FC> = (props) => ( ); -export type ProfileTab = "activity" | "breakdown" | "models"; +export type ProfileTab = "activity" | "breakdown" | "models" | "sources"; export interface ProfileTabBarProps { activeTab: ProfileTab; @@ -618,6 +618,7 @@ export function ProfileTabBar({ activeTab, onTabChange }: ProfileTabBarProps) { { id: "activity", label: "Activity" }, { id: "breakdown", label: "Token Breakdown" }, { id: "models", label: "Models Used" }, + { id: "sources", label: "Devices" }, ]; const handleKeyDown = (e: React.KeyboardEvent, currentIndex: number) => { diff --git a/packages/frontend/src/lib/db/helpers.ts b/packages/frontend/src/lib/db/helpers.ts index 1103053e0..e96345b72 100644 --- a/packages/frontend/src/lib/db/helpers.ts +++ b/packages/frontend/src/lib/db/helpers.ts @@ -37,6 +37,70 @@ export interface DayTotals { reasoningTokens: number; } +export interface SubmissionScopeRow { + id: string; + sourceId: string | null; +} + +export type SubmissionScopeResolution = + | { + kind: "existing"; + submissionId: string; + upgradeLegacyRow: boolean; + } + | { + kind: "create"; + } + | { + kind: "rejectMissingSourceIdentity"; + }; + +export function resolveSubmissionScope( + existingRows: SubmissionScopeRow[], + incomingSourceId: string | null +): SubmissionScopeResolution { + const hasScopedRows = existingRows.some((row) => row.sourceId != null); + const exactMatch = incomingSourceId + ? existingRows.find((row) => row.sourceId === incomingSourceId) + : undefined; + if (exactMatch) { + return { + kind: "existing", + submissionId: exactMatch.id, + upgradeLegacyRow: false, + }; + } + + const unsourcedRow = + existingRows.find((row) => row.sourceId == null) ?? null; + + if (incomingSourceId) { + if (unsourcedRow && !hasScopedRows) { + return { + kind: "existing", + submissionId: unsourcedRow.id, + upgradeLegacyRow: true, + }; + } + + return { kind: "create" }; + } + + if (hasScopedRows) { + return { kind: "rejectMissingSourceIdentity" }; + } + + if (unsourcedRow) { + return { + kind: "existing", + submissionId: unsourcedRow.id, + upgradeLegacyRow: false, + }; + } + + return { kind: "create" }; +} + export function recalculateDayTotals( clientBreakdown: Record ): DayTotals { diff --git a/packages/frontend/src/lib/db/migrations/0007_complete_ted_forrester.sql b/packages/frontend/src/lib/db/migrations/0007_complete_ted_forrester.sql new file mode 100644 index 000000000..868aa7b7c --- /dev/null +++ b/packages/frontend/src/lib/db/migrations/0007_complete_ted_forrester.sql @@ -0,0 +1,17 @@ +-- Must run atomically as one transaction. We intentionally do NOT use +-- CREATE INDEX CONCURRENTLY here: while the old "one row per user" +-- uniqueness is dropped and before the new partial unique index + +-- (user_id, source_id) composite constraint are in place, concurrent +-- submits from the same user could insert duplicate rows that the new +-- invariants would then reject. +ALTER TABLE "submissions" DROP CONSTRAINT "submissions_user_id_unique";--> statement-breakpoint +ALTER TABLE "submissions" DROP CONSTRAINT "submissions_user_hash_unique";--> statement-breakpoint +ALTER TABLE "submissions" ADD COLUMN "source_id" varchar(255);--> statement-breakpoint +ALTER TABLE "submissions" ADD COLUMN "source_name" varchar(255);--> statement-breakpoint +CREATE UNIQUE INDEX "submissions_user_unsourced_unique" ON "submissions" USING btree ("user_id") WHERE "submissions"."source_id" is null;--> statement-breakpoint +ALTER TABLE "submissions" ADD CONSTRAINT "submissions_user_source_unique" UNIQUE("user_id","source_id");--> statement-breakpoint +-- submission_hash was originally a dedup key via submissions_user_hash_unique +-- (dropped above). Source-scoped submissions can legitimately share the same +-- client/date fingerprint across machines, so the hash no longer has a role +-- and nothing reads it. Drop the column to stop writing dead data. +ALTER TABLE "submissions" DROP COLUMN "submission_hash"; diff --git a/packages/frontend/src/lib/db/migrations/0008_cleanup_submissions_indexes_and_add_submit_count.sql b/packages/frontend/src/lib/db/migrations/0008_cleanup_submissions_indexes_and_add_submit_count.sql new file mode 100644 index 000000000..4935df057 --- /dev/null +++ b/packages/frontend/src/lib/db/migrations/0008_cleanup_submissions_indexes_and_add_submit_count.sql @@ -0,0 +1,31 @@ +-- Schema cleanup surfaced by the prod-DB audit during PR #389 review. +-- +-- Index churn: at the time of this migration, pg_stat_user_indexes showed +-- these as having essentially zero scans in production: +-- idx_submissions_user_id 214 (redundant — idx_submissions_leaderboard +-- starts with user_id so it serves every +-- plain user_id lookup as a left-prefix) +-- idx_submissions_status 1 +-- idx_submissions_total_tokens 0 +-- idx_submissions_date_range 0 +-- vs idx_submissions_leaderboard 3_270_000 (by far the hottest) +-- Dropping the four unused / redundant ones reduces INSERT/UPDATE overhead +-- on every submit without losing any query path. +-- +-- FK coverage: device_codes.user_id was the only FK column in the schema +-- without a covering index, so cascade-delete on a user does a seq scan +-- of device_codes. Tiny today, free to fix once. +-- +-- submit_count safety net: this column exists in prod (added some time ago +-- via `drizzle-kit push` which writes directly from schema.ts) but has NO +-- corresponding ALTER TABLE statement in any earlier .sql migration file, +-- so a fresh `drizzle-kit migrate` replay from 0000..0005 would end up +-- without the column. Adding it here with IF NOT EXISTS is a no-op on +-- prod and a correctness fix on any fresh restore. + +DROP INDEX IF EXISTS "idx_submissions_user_id";--> statement-breakpoint +DROP INDEX IF EXISTS "idx_submissions_status";--> statement-breakpoint +DROP INDEX IF EXISTS "idx_submissions_total_tokens";--> statement-breakpoint +DROP INDEX IF EXISTS "idx_submissions_date_range";--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "idx_device_codes_user_id" ON "device_codes" USING btree ("user_id");--> statement-breakpoint +ALTER TABLE "submissions" ADD COLUMN IF NOT EXISTS "submit_count" integer DEFAULT 1 NOT NULL; diff --git a/packages/frontend/src/lib/db/migrations/meta/0007_snapshot.json b/packages/frontend/src/lib/db/migrations/meta/0007_snapshot.json new file mode 100644 index 000000000..8fb13b0da --- /dev/null +++ b/packages/frontend/src/lib/db/migrations/meta/0007_snapshot.json @@ -0,0 +1,948 @@ +{ + "id": "c6a1cc6e-5949-4fd0-a3ec-ad23e35f48a8", + "prevId": "4342d7b5-5562-431f-afd1-0dd773563dff", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.api_tokens": { + "name": "api_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_api_tokens_token": { + "name": "idx_api_tokens_token", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_api_tokens_user_id": { + "name": "idx_api_tokens_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_tokens_user_id_users_id_fk": { + "name": "api_tokens_user_id_users_id_fk", + "tableFrom": "api_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_tokens_token_unique": { + "name": "api_tokens_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + }, + "api_tokens_user_name_unique": { + "name": "api_tokens_user_name_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.daily_breakdown": { + "name": "daily_breakdown", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "submission_id": { + "name": "submission_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "tokens": { + "name": "tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "cost": { + "name": "cost", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": true + }, + "input_tokens": { + "name": "input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "output_tokens": { + "name": "output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "timestamp_ms": { + "name": "timestamp_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "provider_breakdown": { + "name": "provider_breakdown", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "source_breakdown": { + "name": "source_breakdown", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "model_breakdown": { + "name": "model_breakdown", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_daily_breakdown_submission_id": { + "name": "idx_daily_breakdown_submission_id", + "columns": [ + { + "expression": "submission_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_daily_breakdown_date": { + "name": "idx_daily_breakdown_date", + "columns": [ + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "daily_breakdown_submission_id_submissions_id_fk": { + "name": "daily_breakdown_submission_id_submissions_id_fk", + "tableFrom": "daily_breakdown", + "tableTo": "submissions", + "columnsFrom": [ + "submission_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "daily_breakdown_submission_date_unique": { + "name": "daily_breakdown_submission_date_unique", + "nullsNotDistinct": false, + "columns": [ + "submission_id", + "date" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_codes": { + "name": "device_codes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "device_code": { + "name": "device_code", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "user_code": { + "name": "user_code", + "type": "varchar(9)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "device_name": { + "name": "device_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_device_codes_device_code": { + "name": "idx_device_codes_device_code", + "columns": [ + { + "expression": "device_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_device_codes_user_code": { + "name": "idx_device_codes_user_code", + "columns": [ + { + "expression": "user_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_device_codes_expires_at": { + "name": "idx_device_codes_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "device_codes_user_id_users_id_fk": { + "name": "device_codes_user_id_users_id_fk", + "tableFrom": "device_codes", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "device_codes_device_code_unique": { + "name": "device_codes_device_code_unique", + "nullsNotDistinct": false, + "columns": [ + "device_code" + ] + }, + "device_codes_user_code_unique": { + "name": "device_codes_user_code_unique", + "nullsNotDistinct": false, + "columns": [ + "user_code" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'web'" + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_sessions_token": { + "name": "idx_sessions_token", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_sessions_user_id": { + "name": "idx_sessions_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_sessions_expires_at": { + "name": "idx_sessions_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.submissions": { + "name": "submissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "source_name": { + "name": "source_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "total_tokens": { + "name": "total_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "total_cost": { + "name": "total_cost", + "type": "numeric(12, 4)", + "primaryKey": false, + "notNull": true + }, + "input_tokens": { + "name": "input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "output_tokens": { + "name": "output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "cache_creation_tokens": { + "name": "cache_creation_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cache_read_tokens": { + "name": "cache_read_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reasoning_tokens": { + "name": "reasoning_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "date_start": { + "name": "date_start", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "date_end": { + "name": "date_end", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "sources_used": { + "name": "sources_used", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "models_used": { + "name": "models_used", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'verified'" + }, + "cli_version": { + "name": "cli_version", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "submit_count": { + "name": "submit_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_submissions_user_id": { + "name": "idx_submissions_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_submissions_status": { + "name": "idx_submissions_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_submissions_total_tokens": { + "name": "idx_submissions_total_tokens", + "columns": [ + { + "expression": "total_tokens", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_submissions_created_at": { + "name": "idx_submissions_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_submissions_date_range": { + "name": "idx_submissions_date_range", + "columns": [ + { + "expression": "date_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_end", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_submissions_leaderboard": { + "name": "idx_submissions_leaderboard", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "total_tokens", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "total_cost", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "submissions_user_unsourced_unique": { + "name": "submissions_user_unsourced_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"submissions\".\"source_id\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "submissions_user_id_users_id_fk": { + "name": "submissions_user_id_users_id_fk", + "tableFrom": "submissions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "submissions_user_source_unique": { + "name": "submissions_user_source_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "source_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "github_id": { + "name": "github_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(39)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_users_username": { + "name": "idx_users_username", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_github_id": { + "name": "idx_users_github_id", + "columns": [ + { + "expression": "github_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_github_id_unique": { + "name": "users_github_id_unique", + "nullsNotDistinct": false, + "columns": [ + "github_id" + ] + }, + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/frontend/src/lib/db/migrations/meta/0008_snapshot.json b/packages/frontend/src/lib/db/migrations/meta/0008_snapshot.json new file mode 100644 index 000000000..13239bf78 --- /dev/null +++ b/packages/frontend/src/lib/db/migrations/meta/0008_snapshot.json @@ -0,0 +1,912 @@ +{ + "id": "4b22b49c-d3cc-4e39-bd8e-2070fe26facc", + "prevId": "c6a1cc6e-5949-4fd0-a3ec-ad23e35f48a8", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.api_tokens": { + "name": "api_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_api_tokens_token": { + "name": "idx_api_tokens_token", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_api_tokens_user_id": { + "name": "idx_api_tokens_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_tokens_user_id_users_id_fk": { + "name": "api_tokens_user_id_users_id_fk", + "tableFrom": "api_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_tokens_token_unique": { + "name": "api_tokens_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + }, + "api_tokens_user_name_unique": { + "name": "api_tokens_user_name_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.daily_breakdown": { + "name": "daily_breakdown", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "submission_id": { + "name": "submission_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "tokens": { + "name": "tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "cost": { + "name": "cost", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": true + }, + "input_tokens": { + "name": "input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "output_tokens": { + "name": "output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "timestamp_ms": { + "name": "timestamp_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "provider_breakdown": { + "name": "provider_breakdown", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "source_breakdown": { + "name": "source_breakdown", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "model_breakdown": { + "name": "model_breakdown", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_daily_breakdown_submission_id": { + "name": "idx_daily_breakdown_submission_id", + "columns": [ + { + "expression": "submission_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_daily_breakdown_date": { + "name": "idx_daily_breakdown_date", + "columns": [ + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "daily_breakdown_submission_id_submissions_id_fk": { + "name": "daily_breakdown_submission_id_submissions_id_fk", + "tableFrom": "daily_breakdown", + "tableTo": "submissions", + "columnsFrom": [ + "submission_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "daily_breakdown_submission_date_unique": { + "name": "daily_breakdown_submission_date_unique", + "nullsNotDistinct": false, + "columns": [ + "submission_id", + "date" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_codes": { + "name": "device_codes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "device_code": { + "name": "device_code", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "user_code": { + "name": "user_code", + "type": "varchar(9)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "device_name": { + "name": "device_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_device_codes_device_code": { + "name": "idx_device_codes_device_code", + "columns": [ + { + "expression": "device_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_device_codes_user_code": { + "name": "idx_device_codes_user_code", + "columns": [ + { + "expression": "user_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_device_codes_expires_at": { + "name": "idx_device_codes_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_device_codes_user_id": { + "name": "idx_device_codes_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "device_codes_user_id_users_id_fk": { + "name": "device_codes_user_id_users_id_fk", + "tableFrom": "device_codes", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "device_codes_device_code_unique": { + "name": "device_codes_device_code_unique", + "nullsNotDistinct": false, + "columns": [ + "device_code" + ] + }, + "device_codes_user_code_unique": { + "name": "device_codes_user_code_unique", + "nullsNotDistinct": false, + "columns": [ + "user_code" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'web'" + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_sessions_token": { + "name": "idx_sessions_token", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_sessions_user_id": { + "name": "idx_sessions_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_sessions_expires_at": { + "name": "idx_sessions_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.submissions": { + "name": "submissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "source_name": { + "name": "source_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "total_tokens": { + "name": "total_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "total_cost": { + "name": "total_cost", + "type": "numeric(12, 4)", + "primaryKey": false, + "notNull": true + }, + "input_tokens": { + "name": "input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "output_tokens": { + "name": "output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "cache_creation_tokens": { + "name": "cache_creation_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cache_read_tokens": { + "name": "cache_read_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reasoning_tokens": { + "name": "reasoning_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "date_start": { + "name": "date_start", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "date_end": { + "name": "date_end", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "sources_used": { + "name": "sources_used", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "models_used": { + "name": "models_used", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'verified'" + }, + "cli_version": { + "name": "cli_version", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "submit_count": { + "name": "submit_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_submissions_created_at": { + "name": "idx_submissions_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_submissions_leaderboard": { + "name": "idx_submissions_leaderboard", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "total_tokens", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "total_cost", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "submissions_user_unsourced_unique": { + "name": "submissions_user_unsourced_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"submissions\".\"source_id\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "submissions_user_id_users_id_fk": { + "name": "submissions_user_id_users_id_fk", + "tableFrom": "submissions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "submissions_user_source_unique": { + "name": "submissions_user_source_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "source_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "github_id": { + "name": "github_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(39)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_users_username": { + "name": "idx_users_username", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_username_lower_unique": { + "name": "users_username_lower_unique", + "columns": [ + { + "expression": "lower(\"username\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_github_id": { + "name": "idx_users_github_id", + "columns": [ + { + "expression": "github_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_github_id_unique": { + "name": "users_github_id_unique", + "nullsNotDistinct": false, + "columns": [ + "github_id" + ] + }, + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/frontend/src/lib/db/migrations/meta/_journal.json b/packages/frontend/src/lib/db/migrations/meta/_journal.json index f510daa18..154fb94c3 100644 --- a/packages/frontend/src/lib/db/migrations/meta/_journal.json +++ b/packages/frontend/src/lib/db/migrations/meta/_journal.json @@ -50,6 +50,20 @@ "when": 1778025600000, "tag": "0006_rehash_plaintext_personal_tokens", "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1775081591892, + "tag": "0007_complete_ted_forrester", + "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1776706090702, + "tag": "0008_cleanup_submissions_indexes_and_add_submit_count", + "breakpoints": true } ] } diff --git a/packages/frontend/src/lib/db/schema.ts b/packages/frontend/src/lib/db/schema.ts index 591996756..857463352 100644 --- a/packages/frontend/src/lib/db/schema.ts +++ b/packages/frontend/src/lib/db/schema.ts @@ -14,7 +14,7 @@ import { unique, uniqueIndex, } from "drizzle-orm/pg-core"; -import { relations } from "drizzle-orm"; +import { relations, sql } from "drizzle-orm"; import { USERS_USERNAME_LOWER_UNIQUE_INDEX, usernameLowerExpression, @@ -139,6 +139,10 @@ export const deviceCodes = pgTable( index("idx_device_codes_device_code").on(table.deviceCode), index("idx_device_codes_user_code").on(table.userCode), index("idx_device_codes_expires_at").on(table.expiresAt), + // Supports cascade-delete path (DELETE FROM users → device_codes). + // The table is small today so seq-scans are cheap, but without this + // an index the FK check on every user delete gets worse linearly. + index("idx_device_codes_user_id").on(table.userId), ] ); @@ -152,6 +156,8 @@ export const submissions = pgTable( userId: uuid("user_id") .notNull() .references(() => users.id, { onDelete: "cascade" }), + sourceId: varchar("source_id", { length: 255 }), + sourceName: varchar("source_name", { length: 255 }), totalTokens: bigint("total_tokens", { mode: "number" }).notNull(), totalCost: decimal("total_cost", { precision: 12, scale: 4 }).notNull(), @@ -176,7 +182,6 @@ export const submissions = pgTable( status: varchar("status", { length: 20 }).notNull().default("verified"), cliVersion: varchar("cli_version", { length: 20 }), - submissionHash: varchar("submission_hash", { length: 64 }), submitCount: integer("submit_count").notNull().default(1), /** 0=legacy (no timestamps), 1=timestamp-aware CLI */ schemaVersion: integer("schema_version").notNull().default(0), @@ -189,14 +194,22 @@ export const submissions = pgTable( .defaultNow(), }, (table) => [ - index("idx_submissions_user_id").on(table.userId), - index("idx_submissions_status").on(table.status), - index("idx_submissions_total_tokens").on(table.totalTokens), index("idx_submissions_created_at").on(table.createdAt), - index("idx_submissions_date_range").on(table.dateStart, table.dateEnd), - index("idx_submissions_leaderboard").on(table.userId, table.totalTokens, table.totalCost, table.createdAt), - unique("submissions_user_id_unique").on(table.userId), - unique("submissions_user_hash_unique").on(table.userId, table.submissionHash), + // Covers the leaderboard ORDER BY and the (user_id, ...) left-prefix + // lookups that replaced the dropped idx_submissions_user_id. In prod + // this is the hottest index on the table (see the 0006 migration + // comment for scan counts at the time of cleanup). + index("idx_submissions_leaderboard").on( + table.userId, + table.totalTokens, + table.totalCost, + table.createdAt + ), + unique("submissions_user_source_unique") + .on(table.userId, table.sourceId), + uniqueIndex("submissions_user_unsourced_unique") + .on(table.userId) + .where(sql`${table.sourceId} is null`), ] ); diff --git a/packages/frontend/src/lib/db/usernameLookup.ts b/packages/frontend/src/lib/db/usernameLookup.ts index f2d3f6dd6..3cefe1ddb 100644 --- a/packages/frontend/src/lib/db/usernameLookup.ts +++ b/packages/frontend/src/lib/db/usernameLookup.ts @@ -1,6 +1,6 @@ import { sql } from "drizzle-orm"; +import type { PgColumn } from "drizzle-orm/pg-core"; import { revalidatePath } from "next/cache"; -import { users } from "./schema"; import { usernameLowerExpression } from "./usernameIndex"; export const USERNAME_LOOKUP_LIMIT = 2; @@ -12,8 +12,8 @@ export class AmbiguousUsernameError extends Error { } } -export function usernameEqualsIgnoreCase(username: string) { - return sql`${usernameLowerExpression(users.username)} = ${normalizeUsernameCacheKey(username)}`; +export function usernameEqualsIgnoreCase(column: PgColumn, username: string) { + return sql`${usernameLowerExpression(column)} = ${normalizeUsernameCacheKey(username)}`; } export function normalizeUsernameCacheKey(username: string): string { diff --git a/packages/frontend/src/lib/embed/getUserEmbedStats.ts b/packages/frontend/src/lib/embed/getUserEmbedStats.ts index 7076e0630..95b51aa36 100644 --- a/packages/frontend/src/lib/embed/getUserEmbedStats.ts +++ b/packages/frontend/src/lib/embed/getUserEmbedStats.ts @@ -40,14 +40,15 @@ async function fetchUserEmbedStats(username: string, sortBy: EmbedSortBy): Promi username: users.username, displayName: users.displayName, avatarUrl: users.avatarUrl, - totalTokens: sql`COALESCE(${submissions.totalTokens}, 0)`, - totalCost: sql`COALESCE(CAST(${submissions.totalCost} AS DECIMAL(12,4)), 0)`, - submissionCount: sql`COALESCE(${submissions.submitCount}, 0)`, - updatedAt: submissions.updatedAt, + totalTokens: sql`COALESCE(SUM(${submissions.totalTokens}), 0)`, + totalCost: sql`COALESCE(SUM(CAST(${submissions.totalCost} AS DECIMAL(12,4))), 0)`, + submissionCount: sql`COALESCE(SUM(${submissions.submitCount}), 0)`, + updatedAt: sql`MAX(${submissions.updatedAt})`, }) .from(users) .leftJoin(submissions, eq(submissions.userId, users.id)) - .where(usernameEqualsIgnoreCase(username)) + .where(usernameEqualsIgnoreCase(users.username, username)) + .groupBy(users.id, users.username, users.displayName, users.avatarUrl) .limit(USERNAME_LOOKUP_LIMIT); const result = getSingleUsernameMatch(matchingUsers, username); @@ -61,21 +62,31 @@ async function fetchUserEmbedStats(username: string, sortBy: EmbedSortBy): Promi if (rankingValue > 0) { const rankResult = await db.execute<{ rank: number }>(sql` - WITH ranked AS ( + WITH user_totals AS ( + SELECT + user_id, + SUM(total_tokens) AS total_tokens, + SUM(CAST(total_cost AS DECIMAL(12,4))) AS total_cost + FROM submissions + GROUP BY user_id + ), + ranked AS ( SELECT user_id, RANK() OVER ( ORDER BY ${sortBy === "cost" - ? sql`CAST(total_cost AS DECIMAL(12,4)) DESC, total_tokens DESC` - : sql`total_tokens DESC, CAST(total_cost AS DECIMAL(12,4)) DESC`} + ? sql`total_cost DESC, total_tokens DESC` + : sql`total_tokens DESC, total_cost DESC`} ) AS rank - FROM submissions + FROM user_totals ) SELECT rank FROM ranked WHERE user_id = ${result.id} `); - rank = (rankResult as unknown as { rank: number }[])[0]?.rank || null; + const rawRank = (rankResult as unknown as Array<{ rank: number | string | null }>)[0]?.rank; + const normalizedRank = rawRank == null ? null : Number(rawRank); + rank = normalizedRank !== null && Number.isFinite(normalizedRank) ? normalizedRank : null; } return { @@ -90,7 +101,11 @@ async function fetchUserEmbedStats(username: string, sortBy: EmbedSortBy): Promi totalCost: Number(result.totalCost) || 0, submissionCount: Number(result.submissionCount) || 0, rank, - updatedAt: result.updatedAt?.toISOString() || null, + updatedAt: result.updatedAt instanceof Date + ? result.updatedAt.toISOString() + : result.updatedAt + ? new Date(result.updatedAt).toISOString() + : null, }, }; } @@ -116,7 +131,7 @@ async function fetchUserEmbedContributions(username: string): Promise`SUM(${submissions.totalTokens})`, totalCost: sql`SUM(CAST(${submissions.totalCost} AS DECIMAL(12,4)))`, - totalSubmissions: sql`COUNT(${submissions.id})`, + totalSubmissions: sql`COALESCE(SUM(${submissions.submitCount}), 0)`, uniqueUsers: sql`COUNT(DISTINCT ${submissions.userId})`, }) .from(submissions); @@ -428,7 +428,7 @@ async function fetchLeaderboardData( .select({ totalTokens: sql`SUM(${submissions.totalTokens})`, totalCost: sql`SUM(CAST(${submissions.totalCost} AS DECIMAL(12,4)))`, - totalSubmissions: sql`COUNT(${submissions.id})`, + totalSubmissions: sql`COALESCE(SUM(${submissions.submitCount}), 0)`, uniqueUsers: sql`COUNT(DISTINCT ${submissions.userId})`, }) .from(submissions), @@ -507,7 +507,7 @@ async function fetchUserRank( const userResult = await db .select({ id: users.id, username: users.username, displayName: users.displayName, avatarUrl: users.avatarUrl }) .from(users) - .where(usernameEqualsIgnoreCase(username)) + .where(usernameEqualsIgnoreCase(users.username, username)) .limit(USERNAME_LOOKUP_LIMIT); const user = getSingleUsernameMatch(userResult, username); diff --git a/packages/frontend/src/lib/types.ts b/packages/frontend/src/lib/types.ts index 69c0312cb..839efbb03 100644 --- a/packages/frontend/src/lib/types.ts +++ b/packages/frontend/src/lib/types.ts @@ -78,6 +78,8 @@ export interface DataSummary { export interface ExportMeta { generatedAt: string; version: string; + sourceId?: string; + sourceName?: string; dateRange: { start: string; end: string; diff --git a/packages/frontend/src/lib/validation/submission.ts b/packages/frontend/src/lib/validation/submission.ts index 38d59e4fc..4d9166815 100644 --- a/packages/frontend/src/lib/validation/submission.ts +++ b/packages/frontend/src/lib/validation/submission.ts @@ -89,9 +89,35 @@ const DataSummarySchema = z.object({ models: z.array(z.string()), }); +// Reject \p{C} (control + format + surrogate + private-use + unassigned). +// A sourceName/sourceId carrying zero-width joiners, BIDI overrides, or +// terminal-bomb escape sequences would render badly in the profile UI and +// could confuse operators reading logs. The rename endpoint already +// enforces this; the submit path must too, otherwise a malicious CLI can +// plant strings that rename cannot un-plant without a round trip. +const OptionalSourceMetadataSchema = z.preprocess( + (value) => { + if (typeof value === "string" && value.trim() === "") { + return undefined; + } + return value; + }, + z + .string() + .trim() + .min(1) + .max(255) + .refine((value) => !/\p{C}/u.test(value), { + message: "must not contain control characters", + }) + .optional() +); + const ExportMetaSchema = z.object({ generatedAt: z.string(), version: z.string(), + sourceId: OptionalSourceMetadataSchema, + sourceName: OptionalSourceMetadataSchema, dateRange: z.object({ start: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), end: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), @@ -333,39 +359,3 @@ export function validateSubmission(data: unknown): ValidationResult { }; } -/** - * Generate a hash for the submission data (for deduplication) - * - * CHANGED for client-level merge: - * - Hash is now based on clients + date range (not totals) - * - Totals change after merge, so they can't be in the hash - * - This hash identifies "what clients and dates are being submitted" - */ -export function generateSubmissionHash(data: SubmissionData): string { - // Sort contributions by date to ensure deterministic hash - const sortedDates = data.contributions - .map(c => c.date) - .sort(); - - const content = JSON.stringify({ - // What clients are being submitted - clients: data.summary.clients.slice().sort(), - // Date range of this submission - dateRange: data.meta.dateRange, - // Number of days with data (for basic fingerprinting) - daysCount: data.contributions.length, - // First and last dates FROM SORTED LIST - firstDay: sortedDates[0], - lastDay: sortedDates[sortedDates.length - 1], - }); - - // Simple synchronous hash (djb2 algorithm) - let hash = 5381; - for (let i = 0; i < content.length; i++) { - const char = content.charCodeAt(i); - hash = ((hash << 5) + hash) + char; // hash * 33 + char - hash = hash & hash; // Convert to 32-bit integer - } - - return Math.abs(hash).toString(16).padStart(16, "0"); -}