From 344c05d0cc2b477620a511851e94a0f36e845046 Mon Sep 17 00:00:00 2001 From: Junho Yeo Date: Thu, 2 Apr 2026 07:35:22 +0900 Subject: [PATCH 01/25] fix(submit): preserve multi-machine submissions without reshaping daily breakdown Mainline stored a single submission row per user, so a later submit from another machine could overwrite flat per-client breakdowns for overlapping days. This scopes submissions by stable source identity, upgrades a lone legacy unsourced row in place on first source-aware submit, and aggregates profile/embed/leaderboard reads across rows while keeping the existing daily_breakdown source_breakdown shape flat. Constraint: Must keep the current database and existing daily_breakdown.source_breakdown shape Constraint: Must remain compatible with legacy unsourced rows during cutover Rejected: Nested per-device JSON in daily_breakdown | broader read-path churn and token-identity pitfalls Rejected: Bundle /api/me/stats and remote TUI sync | unrelated scope increase for the overwrite fix Confidence: high Scope-risk: moderate Reversibility: messy Directive: Keep embed/profile/leaderboard reads source-row aware; do not reintroduce single-row-per-user assumptions Tested: cargo fmt --all --check; cargo clippy -p tokscale-cli --all-features -- -D warnings; cargo test -p tokscale-cli; bunx vitest run packages/frontend/__tests__/api/submit.test.ts packages/frontend/__tests__/api/submitAuth.test.ts packages/frontend/__tests__/api/usersProfile.test.ts packages/frontend/__tests__/lib/dbHelpers.test.ts packages/frontend/__tests__/lib/getUserEmbedStats.test.ts; bunx vitest run packages/frontend/__tests__/lib/getLeaderboard.test.ts packages/frontend/__tests__/lib/getLeaderboardAllTime.test.ts; targeted frontend eslint on changed files Not-tested: Full frontend typecheck remains blocked by the pre-existing packages/frontend/src/components/BlackholeHero.tsx asset import typing error; live database migration rehearsal against production-like Postgres --- crates/tokscale-cli/src/auth.rs | 315 +++++- crates/tokscale-cli/src/main.rs | 59 +- .../frontend/__tests__/api/submit.test.ts | 118 +++ .../frontend/__tests__/api/submitAuth.test.ts | 402 +++++++- .../__tests__/api/usersProfile.test.ts | 70 +- .../frontend/__tests__/lib/dbHelpers.test.ts | 76 ++ .../__tests__/lib/getUserEmbedStats.test.ts | 143 +++ packages/frontend/src/app/api/submit/route.ts | 189 +++- .../src/app/api/users/[username]/route.ts | 21 +- packages/frontend/src/lib/db/helpers.ts | 64 ++ .../0005_complete_ted_forrester.sql | 6 + .../lib/db/migrations/meta/0005_snapshot.json | 954 ++++++++++++++++++ .../src/lib/db/migrations/meta/_journal.json | 7 + packages/frontend/src/lib/db/schema.ts | 19 +- .../src/lib/embed/getUserEmbedStats.ts | 35 +- .../src/lib/leaderboard/getLeaderboard.ts | 4 +- packages/frontend/src/lib/types.ts | 2 + .../frontend/src/lib/validation/submission.ts | 12 + 18 files changed, 2418 insertions(+), 78 deletions(-) create mode 100644 packages/frontend/__tests__/lib/dbHelpers.test.ts create mode 100644 packages/frontend/__tests__/lib/getUserEmbedStats.test.ts create mode 100644 packages/frontend/src/lib/db/migrations/0005_complete_ted_forrester.sql create mode 100644 packages/frontend/src/lib/db/migrations/meta/0005_snapshot.json diff --git a/crates/tokscale-cli/src/auth.rs b/crates/tokscale-cli/src/auth.rs index 24be5e5e0..be8d4d2f1 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}; fn home_dir() -> Result { dirs::home_dir().context("Could not determine home directory") @@ -53,6 +55,18 @@ 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); + fn ensure_config_dir() -> Result<()> { let config_dir = home_dir()?.join(".config/tokscale"); @@ -125,6 +139,249 @@ 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() { + let (key, value) = line.split_once('=')?; + 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); + } + + fs::metadata(path) + .and_then(|metadata| metadata.modified()) + .ok() + .and_then(|modified| modified.elapsed().ok()) + .unwrap_or_default() +} + +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)]) + .output(); + + match output { + Ok(output) if output.status.success() => { + let stdout = String::from_utf8_lossy(&output.stdout); + Some(stdout.contains(&pid.to_string()) && !stdout.contains("No tasks are running")) + } + Ok(_) => None, + Err(_) => None, + } + } + + #[cfg(not(any(unix, windows)))] + { + None + } +} + +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_dead = match state { + Some(lock_state) => match lock_owner_is_alive(lock_state.pid) { + Some(is_alive) => !is_alive, + None => age >= SOURCE_ID_LOCK_STALE_AFTER, + }, + None => true, + }; + + if owner_is_dead && age >= SOURCE_ID_LOCK_STALE_AFTER { + 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()) @@ -508,6 +765,62 @@ 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] #[serial] fn test_save_credentials() { diff --git a/crates/tokscale-cli/src/main.rs b/crates/tokscale-cli/src/main.rs index 1f185efbd..f51240d9e 100644 --- a/crates/tokscale-cli/src/main.rs +++ b/crates/tokscale-cli/src/main.rs @@ -2827,6 +2827,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, } @@ -2839,11 +2843,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(), @@ -3255,7 +3265,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 { @@ -3502,7 +3512,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() @@ -3904,6 +3936,27 @@ mod tests { .unwrap() } + #[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 { diff --git a/packages/frontend/__tests__/api/submit.test.ts b/packages/frontend/__tests__/api/submit.test.ts index 2629c3fb8..a7c8024f3 100644 --- a/packages/frontend/__tests__/api/submit.test.ts +++ b/packages/frontend/__tests__/api/submit.test.ts @@ -176,6 +176,124 @@ describe('POST /api/submit - Client-Level Merge', () => { expect(result.valid).toBe(true); expect(result.errors).toHaveLength(0); }); + + it("should accept source metadata for machine-scoped submissions", () => { + const payload = { + 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", + 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.data?.meta.sourceId).toBe("machine-123"); + expect(result.data?.meta.sourceName).toBe("Workstation"); + }); + + 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.data?.meta.sourceId).toBeUndefined(); + expect(result.data?.meta.sourceName).toBeUndefined(); + }); }); describe('Client-Level Merge Logic', () => { diff --git a/packages/frontend/__tests__/api/submitAuth.test.ts b/packages/frontend/__tests__/api/submitAuth.test.ts index c71870cb7..365b60171 100644 --- a/packages/frontend/__tests__/api/submitAuth.test.ts +++ b/packages/frontend/__tests__/api/submitAuth.test.ts @@ -5,9 +5,75 @@ const mockState = vi.hoisted(() => { const validateSubmission = vi.fn(); const generateSubmissionHash = vi.fn(() => "submission-hash"); const revalidateTag = 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 { @@ -15,13 +81,41 @@ const mockState = vi.hoisted(() => { validateSubmission, generateSubmissionHash, revalidateTag, + mergeClientBreakdowns, + recalculateDayTotals, + buildModelBreakdown, + clientContributionToBreakdownData, + mergeTimestampMs, + resolveSubmissionScope, + apiTokens, + submissions, + dailyBreakdown, + eq, + and, + isNull, + sql, db, reset() { authenticatePersonalToken.mockReset(); validateSubmission.mockReset(); generateSubmissionHash.mockClear(); revalidateTag.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); }, }; }); @@ -36,14 +130,9 @@ vi.mock("@/lib/auth/personalTokens", () => ({ vi.mock("@/lib/db", () => ({ db: mockState.db, - submissions: { - id: "submissions.id", - userId: "submissions.userId", - }, - dailyBreakdown: { - id: "dailyBreakdown.id", - submissionId: "dailyBreakdown.submissionId", - }, + apiTokens: mockState.apiTokens, + submissions: mockState.submissions, + dailyBreakdown: mockState.dailyBreakdown, })); vi.mock("@/lib/validation/submission", () => ({ @@ -52,11 +141,19 @@ vi.mock("@/lib/validation/submission", () => ({ })); vi.mock("@/lib/db/helpers", () => ({ - mergeClientBreakdowns: vi.fn(), - recalculateDayTotals: vi.fn(), - buildModelBreakdown: vi.fn(), - clientContributionToBreakdownData: vi.fn(), - mergeTimestampMs: vi.fn(), + mergeClientBreakdowns: mockState.mergeClientBreakdowns, + recalculateDayTotals: mockState.recalculateDayTotals, + buildModelBreakdown: mockState.buildModelBreakdown, + clientContributionToBreakdownData: mockState.clientContributionToBreakdownData, + mergeTimestampMs: mockState.mergeTimestampMs, + resolveSubmissionScope: mockState.resolveSubmissionScope, +})); + +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"); @@ -153,4 +250,283 @@ describe("POST /api/submit auth path", () => { details: ["bad payload"], }); }); + + it("returns 409 when source identity is required after scoped mode begins", 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: [], + }); + mockState.db.transaction.mockRejectedValue( + new Error("Source identity is required for accounts with source-scoped submissions") + ); + + 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", + }); + }); + + 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: 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: 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.pushSelectResult([ + { + totalTokens: 1500, + totalCost: "1.5000", + dateStart: "2024-12-01", + dateEnd: "2024-12-01", + }, + ]); + mockState.pushSelectResult([{ activeDays: 1 }]); + mockState.pushSelectResult([{ sourcesUsed: ["claude"] }]); + mockState.db.transaction.mockImplementation(async (callback) => { + const selectResults = [ + [], + [{ id: "submission-1" }], + [], + [ + { + totalTokens: 1500, + totalCost: "1.5000", + inputTokens: 1000, + outputTokens: 500, + dateStart: "2024-12-01", + dateEnd: "2024-12-01", + activeDays: 1, + rowCount: 1, + }, + ], + [{ sourceBreakdown: mockSourceBreakdown }], + ]; + 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), + where: vi.fn(() => builder), + limit: vi.fn(async () => selectResults.shift() ?? []), + for: vi.fn(async () => selectResults.shift() ?? []), + 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(); + + 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"], + }); + }); }); diff --git a/packages/frontend/__tests__/api/usersProfile.test.ts b/packages/frontend/__tests__/api/usersProfile.test.ts index d6e664461..fbb67c193 100644 --- a/packages/frontend/__tests__/api/usersProfile.test.ts +++ b/packages/frontend/__tests__/api/usersProfile.test.ts @@ -13,6 +13,7 @@ const mockState = vi.hoisted(() => { createdAt: "users.createdAt", }, submissions: { + id: "submissions.id", userId: "submissions.userId", totalTokens: "submissions.totalTokens", totalCost: "submissions.totalCost", @@ -163,7 +164,7 @@ describe("GET /api/users/[username]", () => { cacheReadTokens: 100, cacheCreationTokens: 50, reasoningTokens: 25, - submissionCount: 2, + submissionCount: 5, earliestDate: "2026-01-01", latestDate: "2026-03-10", }, @@ -176,6 +177,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 }]); @@ -194,8 +202,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 () => { @@ -238,4 +247,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 new file mode 100644 index 000000000..3988faabd --- /dev/null +++ b/packages/frontend/__tests__/lib/getUserEmbedStats.test.ts @@ -0,0 +1,143 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +const mockState = vi.hoisted(() => { + const selectResults: Array>> = []; + const executeResults: Array>> = []; + + const tables = { + users: { + id: "users.id", + username: "users.username", + displayName: "users.displayName", + avatarUrl: "users.avatarUrl", + }, + submissions: { + userId: "submissions.userId", + totalTokens: "submissions.totalTokens", + totalCost: "submissions.totalCost", + submitCount: "submissions.submitCount", + updatedAt: "submissions.updatedAt", + }, + }; + + const db = { + select: vi.fn(() => { + const builder = { + from: vi.fn(() => builder), + leftJoin: vi.fn(() => builder), + where: vi.fn(() => builder), + groupBy: vi.fn(() => builder), + limit: vi.fn(() => builder), + then: (resolve: (value: unknown) => unknown) => + resolve(selectResults.shift() ?? []), + }; + + return builder; + }), + execute: vi.fn(async () => executeResults.shift() ?? []), + }; + + const eq = vi.fn(() => "eq"); + const sql = Object.assign( + () => ({ + as: () => ({}), + }), + { + raw: vi.fn(), + } + ); + + return { + db, + eq, + sql, + tables, + reset() { + selectResults.length = 0; + executeResults.length = 0; + db.select.mockClear(); + db.execute.mockClear(); + eq.mockClear(); + sql.raw.mockClear(); + }, + pushSelectResult(rows: Array>) { + selectResults.push(rows); + }, + pushExecuteResult(rows: Array>) { + executeResults.push(rows); + }, + }; +}); + +vi.mock("next/cache", () => ({ + unstable_cache: (fn: () => unknown) => fn, +})); + +vi.mock("@/lib/db", () => ({ + db: mockState.db, + users: mockState.tables.users, + submissions: mockState.tables.submissions, +})); + +vi.mock("drizzle-orm", () => ({ + eq: mockState.eq, + sql: mockState.sql, +})); + +type ModuleExports = typeof import("../../src/lib/embed/getUserEmbedStats"); + +let getUserEmbedStats: ModuleExports["getUserEmbedStats"]; + +beforeAll(async () => { + const embedStatsLib = await import("../../src/lib/embed/getUserEmbedStats"); + getUserEmbedStats = embedStatsLib.getUserEmbedStats; +}); + +beforeEach(() => { + mockState.reset(); +}); + +describe("getUserEmbedStats", () => { + it("aggregates totals and submission count across multiple submission rows", async () => { + mockState.pushSelectResult([ + { + id: "user-1", + username: "alice", + displayName: "Alice", + avatarUrl: null, + totalTokens: 3100, + totalCost: 17.75, + submissionCount: 5, + updatedAt: new Date("2026-04-01T08:00:00.000Z"), + }, + ]); + mockState.pushExecuteResult([{ rank: "2" }]); + + const result = await getUserEmbedStats("alice", "tokens"); + + expect(result).toEqual({ + user: { + id: "user-1", + username: "alice", + displayName: "Alice", + avatarUrl: null, + }, + 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"); + + expect(result).toBeNull(); + expect(mockState.db.execute).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/frontend/src/app/api/submit/route.ts b/packages/frontend/src/app/api/submit/route.ts index 21ff23f5c..52c8d8378 100644 --- a/packages/frontend/src/app/api/submit/route.ts +++ b/packages/frontend/src/app/api/submit/route.ts @@ -1,7 +1,7 @@ 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, @@ -14,9 +14,13 @@ import { buildModelBreakdown, clientContributionToBreakdownData, mergeTimestampMs, + resolveSubmissionScope, type ClientBreakdownData, } from "@/lib/db/helpers"; +const SOURCE_IDENTITY_REQUIRED_ERROR = + "Source identity is required for accounts with source-scoped submissions"; + function normalizeSubmissionData(data: unknown): void { if (!data || typeof data !== "object") return; const obj = data as Record; @@ -45,6 +49,62 @@ 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; +} + +async function loadUserSubmitMetrics(userId: string) { + const [userAggregatesRows, userDayAggregatesRows, userSubmissionsRows] = + await Promise.all([ + db + .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)), + db + .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)), + db + .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 @@ -133,6 +193,10 @@ export async function POST(request: Request) { 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 @@ -146,24 +210,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 Error(SOURCE_IDENTITY_REQUIRED_ERROR); + } - 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, @@ -178,9 +257,35 @@ export async function POST(request: Request) { 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) + ) + ) + .limit(1); + + if (!conflictedSubmission) { + throw new Error("Submission row was not found after insert conflict"); + } + + submissionId = conflictedSubmission.id; + } } // ------------------------------------------ @@ -384,44 +489,45 @@ 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, + 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(), + }; + + if (sourceName !== null) { + submissionUpdate.sourceName = sourceName; + } + if (sourceId !== null && upgradeLegacyRow) { + submissionUpdate.sourceId = sourceId; + } + 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)); 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), - }, }; }); + const metrics = await loadUserSubmitMetrics(tokenRecord.userId); + try { revalidateTag("leaderboard", "max"); revalidateTag(`user:${tokenRecord.username}`, "max"); @@ -435,11 +541,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 Error && + error.message === SOURCE_IDENTITY_REQUIRED_ERROR + ) { + return NextResponse.json( + { error: SOURCE_IDENTITY_REQUIRED_ERROR }, + { 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 d033d6535..bbac51ae3 100644 --- a/packages/frontend/src/app/api/users/[username]/route.ts +++ b/packages/frontend/src/app/api/users/[username]/route.ts @@ -48,7 +48,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})`, }) @@ -57,6 +57,7 @@ export async function GET(_request: Request, { params }: RouteParams) { db .select({ + id: submissions.id, sourcesUsed: submissions.sourcesUsed, modelsUsed: submissions.modelsUsed, updatedAt: submissions.updatedAt, @@ -65,8 +66,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 ( @@ -110,6 +110,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; @@ -443,8 +454,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/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/0005_complete_ted_forrester.sql b/packages/frontend/src/lib/db/migrations/0005_complete_ted_forrester.sql new file mode 100644 index 000000000..4ea9ee147 --- /dev/null +++ b/packages/frontend/src/lib/db/migrations/0005_complete_ted_forrester.sql @@ -0,0 +1,6 @@ +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"); diff --git a/packages/frontend/src/lib/db/migrations/meta/0005_snapshot.json b/packages/frontend/src/lib/db/migrations/meta/0005_snapshot.json new file mode 100644 index 000000000..9144335eb --- /dev/null +++ b/packages/frontend/src/lib/db/migrations/meta/0005_snapshot.json @@ -0,0 +1,954 @@ +{ + "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 + }, + "submission_hash": { + "name": "submission_hash", + "type": "varchar(64)", + "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/_journal.json b/packages/frontend/src/lib/db/migrations/meta/_journal.json index f2d23cd96..e6dfb3eff 100644 --- a/packages/frontend/src/lib/db/migrations/meta/_journal.json +++ b/packages/frontend/src/lib/db/migrations/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1771322400000, "tag": "0004_add_timestamp_and_schema_version", "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1775081591892, + "tag": "0005_complete_ted_forrester", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/frontend/src/lib/db/schema.ts b/packages/frontend/src/lib/db/schema.ts index 247c85929..615c3fb65 100644 --- a/packages/frontend/src/lib/db/schema.ts +++ b/packages/frontend/src/lib/db/schema.ts @@ -12,8 +12,9 @@ import { integer, index, unique, + uniqueIndex, } from "drizzle-orm/pg-core"; -import { relations } from "drizzle-orm"; +import { relations, sql } from "drizzle-orm"; // ============================================================================ // USERS @@ -144,6 +145,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(), @@ -186,9 +189,17 @@ export const submissions = pgTable( 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), + 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/embed/getUserEmbedStats.ts b/packages/frontend/src/lib/embed/getUserEmbedStats.ts index 0678af8e4..8c477b2ce 100644 --- a/packages/frontend/src/lib/embed/getUserEmbedStats.ts +++ b/packages/frontend/src/lib/embed/getUserEmbedStats.ts @@ -27,14 +27,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(eq(users.username, username)) + .groupBy(users.id, users.username, users.displayName, users.avatarUrl) .limit(1); if (!result) { @@ -47,21 +48,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 { @@ -76,7 +87,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, }, }; } diff --git a/packages/frontend/src/lib/leaderboard/getLeaderboard.ts b/packages/frontend/src/lib/leaderboard/getLeaderboard.ts index 139479b0d..fc551f951 100644 --- a/packages/frontend/src/lib/leaderboard/getLeaderboard.ts +++ b/packages/frontend/src/lib/leaderboard/getLeaderboard.ts @@ -341,7 +341,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); @@ -418,7 +418,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), diff --git a/packages/frontend/src/lib/types.ts b/packages/frontend/src/lib/types.ts index 2db8d0cf6..4b9a1efd0 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 23cc2f922..c97f2afa4 100644 --- a/packages/frontend/src/lib/validation/submission.ts +++ b/packages/frontend/src/lib/validation/submission.ts @@ -81,9 +81,21 @@ const DataSummarySchema = z.object({ models: z.array(z.string()), }); +const OptionalSourceMetadataSchema = z.preprocess( + (value) => { + if (typeof value === "string" && value.trim() === "") { + return undefined; + } + return value; + }, + z.string().trim().min(1).max(255).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}$/), From 6c6ddc936e86daff05c6495f82fc07be4387f57c Mon Sep 17 00:00:00 2001 From: Junho Yeo Date: Thu, 2 Apr 2026 07:40:02 +0900 Subject: [PATCH 02/25] fix(submit): restore mainline until multi-machine changes land through PR review This reverts commit 344c05d0cc2b477620a511851e94a0f36e845046 from main so the multi-machine submission change can land through the normal PR path instead of a direct push. Constraint: User requested reverting the direct push and reopening the change as a PR Constraint: Must restore main without losing the already-validated patch Rejected: Force-reset main | destructive history rewrite on a published branch Rejected: Leave change on main and open a follow-up PR | does not satisfy the requested rollback Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep the replacement PR branch aligned with commit 344c05d content; do not sneak in extra scope during re-land Tested: git revert --no-commit 344c05d0cc2b477620a511851e94a0f36e845046; git status review Not-tested: Re-running the full verification matrix after reverting main (revert only removes the already-tested patch) --- crates/tokscale-cli/src/auth.rs | 315 +----- crates/tokscale-cli/src/main.rs | 59 +- .../frontend/__tests__/api/submit.test.ts | 118 --- .../frontend/__tests__/api/submitAuth.test.ts | 402 +------- .../__tests__/api/usersProfile.test.ts | 70 +- .../frontend/__tests__/lib/dbHelpers.test.ts | 76 -- .../__tests__/lib/getUserEmbedStats.test.ts | 143 --- packages/frontend/src/app/api/submit/route.ts | 189 +--- .../src/app/api/users/[username]/route.ts | 21 +- packages/frontend/src/lib/db/helpers.ts | 64 -- .../0005_complete_ted_forrester.sql | 6 - .../lib/db/migrations/meta/0005_snapshot.json | 954 ------------------ .../src/lib/db/migrations/meta/_journal.json | 7 - packages/frontend/src/lib/db/schema.ts | 19 +- .../src/lib/embed/getUserEmbedStats.ts | 35 +- .../src/lib/leaderboard/getLeaderboard.ts | 4 +- packages/frontend/src/lib/types.ts | 2 - .../frontend/src/lib/validation/submission.ts | 12 - 18 files changed, 78 insertions(+), 2418 deletions(-) delete mode 100644 packages/frontend/__tests__/lib/dbHelpers.test.ts delete mode 100644 packages/frontend/__tests__/lib/getUserEmbedStats.test.ts delete mode 100644 packages/frontend/src/lib/db/migrations/0005_complete_ted_forrester.sql delete mode 100644 packages/frontend/src/lib/db/migrations/meta/0005_snapshot.json diff --git a/crates/tokscale-cli/src/auth.rs b/crates/tokscale-cli/src/auth.rs index be8d4d2f1..24be5e5e0 100644 --- a/crates/tokscale-cli/src/auth.rs +++ b/crates/tokscale-cli/src/auth.rs @@ -3,9 +3,7 @@ use serde::{Deserialize, Serialize}; use std::fs; use std::io::IsTerminal; use std::io::Write; -use std::path::{Path, PathBuf}; -use std::thread; -use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use std::path::PathBuf; fn home_dir() -> Result { dirs::home_dir().context("Could not determine home directory") @@ -55,18 +53,6 @@ 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); - fn ensure_config_dir() -> Result<()> { let config_dir = home_dir()?.join(".config/tokscale"); @@ -139,249 +125,6 @@ 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() { - let (key, value) = line.split_once('=')?; - 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); - } - - fs::metadata(path) - .and_then(|metadata| metadata.modified()) - .ok() - .and_then(|modified| modified.elapsed().ok()) - .unwrap_or_default() -} - -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)]) - .output(); - - match output { - Ok(output) if output.status.success() => { - let stdout = String::from_utf8_lossy(&output.stdout); - Some(stdout.contains(&pid.to_string()) && !stdout.contains("No tasks are running")) - } - Ok(_) => None, - Err(_) => None, - } - } - - #[cfg(not(any(unix, windows)))] - { - None - } -} - -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_dead = match state { - Some(lock_state) => match lock_owner_is_alive(lock_state.pid) { - Some(is_alive) => !is_alive, - None => age >= SOURCE_ID_LOCK_STALE_AFTER, - }, - None => true, - }; - - if owner_is_dead && age >= SOURCE_ID_LOCK_STALE_AFTER { - 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()) @@ -765,62 +508,6 @@ 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] #[serial] fn test_save_credentials() { diff --git a/crates/tokscale-cli/src/main.rs b/crates/tokscale-cli/src/main.rs index f51240d9e..1f185efbd 100644 --- a/crates/tokscale-cli/src/main.rs +++ b/crates/tokscale-cli/src/main.rs @@ -2827,10 +2827,6 @@ 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, } @@ -2843,17 +2839,11 @@ struct TsTokenContributionData { contributions: Vec, } -fn to_ts_token_contribution_data( - graph: &tokscale_core::GraphResult, - source_id: Option, - source_name: Option, -) -> TsTokenContributionData { +fn to_ts_token_contribution_data(graph: &tokscale_core::GraphResult) -> 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(), @@ -3265,7 +3255,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, None, None); + let output_data = to_ts_token_contribution_data(&graph_result); let json_output = serde_json::to_string_pretty(&output_data)?; if let Some(output_path) = output { @@ -3512,29 +3502,7 @@ fn run_submit_command( let api_url = auth::get_api_base_url(); - 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 submit_payload = to_ts_token_contribution_data(&graph_result); let response = rt.block_on(async { reqwest::Client::new() @@ -3936,27 +3904,6 @@ mod tests { .unwrap() } - #[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 { diff --git a/packages/frontend/__tests__/api/submit.test.ts b/packages/frontend/__tests__/api/submit.test.ts index a7c8024f3..2629c3fb8 100644 --- a/packages/frontend/__tests__/api/submit.test.ts +++ b/packages/frontend/__tests__/api/submit.test.ts @@ -176,124 +176,6 @@ describe('POST /api/submit - Client-Level Merge', () => { expect(result.valid).toBe(true); expect(result.errors).toHaveLength(0); }); - - it("should accept source metadata for machine-scoped submissions", () => { - const payload = { - 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", - 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.data?.meta.sourceId).toBe("machine-123"); - expect(result.data?.meta.sourceName).toBe("Workstation"); - }); - - 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.data?.meta.sourceId).toBeUndefined(); - expect(result.data?.meta.sourceName).toBeUndefined(); - }); }); describe('Client-Level Merge Logic', () => { diff --git a/packages/frontend/__tests__/api/submitAuth.test.ts b/packages/frontend/__tests__/api/submitAuth.test.ts index 365b60171..c71870cb7 100644 --- a/packages/frontend/__tests__/api/submitAuth.test.ts +++ b/packages/frontend/__tests__/api/submitAuth.test.ts @@ -5,75 +5,9 @@ const mockState = vi.hoisted(() => { const validateSubmission = vi.fn(); const generateSubmissionHash = vi.fn(() => "submission-hash"); const revalidateTag = 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 { @@ -81,41 +15,13 @@ const mockState = vi.hoisted(() => { validateSubmission, generateSubmissionHash, revalidateTag, - mergeClientBreakdowns, - recalculateDayTotals, - buildModelBreakdown, - clientContributionToBreakdownData, - mergeTimestampMs, - resolveSubmissionScope, - apiTokens, - submissions, - dailyBreakdown, - eq, - and, - isNull, - sql, db, reset() { authenticatePersonalToken.mockReset(); validateSubmission.mockReset(); generateSubmissionHash.mockClear(); revalidateTag.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); }, }; }); @@ -130,9 +36,14 @@ vi.mock("@/lib/auth/personalTokens", () => ({ vi.mock("@/lib/db", () => ({ db: mockState.db, - apiTokens: mockState.apiTokens, - submissions: mockState.submissions, - dailyBreakdown: mockState.dailyBreakdown, + submissions: { + id: "submissions.id", + userId: "submissions.userId", + }, + dailyBreakdown: { + id: "dailyBreakdown.id", + submissionId: "dailyBreakdown.submissionId", + }, })); vi.mock("@/lib/validation/submission", () => ({ @@ -141,19 +52,11 @@ vi.mock("@/lib/validation/submission", () => ({ })); vi.mock("@/lib/db/helpers", () => ({ - mergeClientBreakdowns: mockState.mergeClientBreakdowns, - recalculateDayTotals: mockState.recalculateDayTotals, - buildModelBreakdown: mockState.buildModelBreakdown, - clientContributionToBreakdownData: mockState.clientContributionToBreakdownData, - mergeTimestampMs: mockState.mergeTimestampMs, - resolveSubmissionScope: mockState.resolveSubmissionScope, -})); - -vi.mock("drizzle-orm", () => ({ - eq: mockState.eq, - and: mockState.and, - isNull: mockState.isNull, - sql: mockState.sql, + mergeClientBreakdowns: vi.fn(), + recalculateDayTotals: vi.fn(), + buildModelBreakdown: vi.fn(), + clientContributionToBreakdownData: vi.fn(), + mergeTimestampMs: vi.fn(), })); type ModuleExports = typeof import("../../src/app/api/submit/route"); @@ -250,283 +153,4 @@ describe("POST /api/submit auth path", () => { details: ["bad payload"], }); }); - - it("returns 409 when source identity is required after scoped mode begins", 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: [], - }); - mockState.db.transaction.mockRejectedValue( - new Error("Source identity is required for accounts with source-scoped submissions") - ); - - 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", - }); - }); - - 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: 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: 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.pushSelectResult([ - { - totalTokens: 1500, - totalCost: "1.5000", - dateStart: "2024-12-01", - dateEnd: "2024-12-01", - }, - ]); - mockState.pushSelectResult([{ activeDays: 1 }]); - mockState.pushSelectResult([{ sourcesUsed: ["claude"] }]); - mockState.db.transaction.mockImplementation(async (callback) => { - const selectResults = [ - [], - [{ id: "submission-1" }], - [], - [ - { - totalTokens: 1500, - totalCost: "1.5000", - inputTokens: 1000, - outputTokens: 500, - dateStart: "2024-12-01", - dateEnd: "2024-12-01", - activeDays: 1, - rowCount: 1, - }, - ], - [{ sourceBreakdown: mockSourceBreakdown }], - ]; - 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), - where: vi.fn(() => builder), - limit: vi.fn(async () => selectResults.shift() ?? []), - for: vi.fn(async () => selectResults.shift() ?? []), - 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(); - - 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"], - }); - }); }); diff --git a/packages/frontend/__tests__/api/usersProfile.test.ts b/packages/frontend/__tests__/api/usersProfile.test.ts index fbb67c193..d6e664461 100644 --- a/packages/frontend/__tests__/api/usersProfile.test.ts +++ b/packages/frontend/__tests__/api/usersProfile.test.ts @@ -13,7 +13,6 @@ const mockState = vi.hoisted(() => { createdAt: "users.createdAt", }, submissions: { - id: "submissions.id", userId: "submissions.userId", totalTokens: "submissions.totalTokens", totalCost: "submissions.totalCost", @@ -164,7 +163,7 @@ describe("GET /api/users/[username]", () => { cacheReadTokens: 100, cacheCreationTokens: 50, reasoningTokens: 25, - submissionCount: 5, + submissionCount: 2, earliestDate: "2026-01-01", latestDate: "2026-03-10", }, @@ -177,13 +176,6 @@ 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 }]); @@ -202,9 +194,8 @@ describe("GET /api/users/[username]", () => { isStale: true, }); expect(body.updatedAt).toBe("2026-01-10T10:00:00.000Z"); - expect(body.stats.submissionCount).toBe(5); - expect(body.clients).toEqual(["claude", "cursor"]); - expect(body.models).toEqual(["claude-3-7-sonnet", "gpt-4.1"]); + expect(body.clients).toEqual(["cursor"]); + expect(body.models).toEqual(["claude-3-7-sonnet"]); }); it("returns null freshness metadata when the user has no submission yet", async () => { @@ -247,59 +238,4 @@ 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 deleted file mode 100644 index bccaa8606..000000000 --- a/packages/frontend/__tests__/lib/dbHelpers.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -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 deleted file mode 100644 index 3988faabd..000000000 --- a/packages/frontend/__tests__/lib/getUserEmbedStats.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; - -const mockState = vi.hoisted(() => { - const selectResults: Array>> = []; - const executeResults: Array>> = []; - - const tables = { - users: { - id: "users.id", - username: "users.username", - displayName: "users.displayName", - avatarUrl: "users.avatarUrl", - }, - submissions: { - userId: "submissions.userId", - totalTokens: "submissions.totalTokens", - totalCost: "submissions.totalCost", - submitCount: "submissions.submitCount", - updatedAt: "submissions.updatedAt", - }, - }; - - const db = { - select: vi.fn(() => { - const builder = { - from: vi.fn(() => builder), - leftJoin: vi.fn(() => builder), - where: vi.fn(() => builder), - groupBy: vi.fn(() => builder), - limit: vi.fn(() => builder), - then: (resolve: (value: unknown) => unknown) => - resolve(selectResults.shift() ?? []), - }; - - return builder; - }), - execute: vi.fn(async () => executeResults.shift() ?? []), - }; - - const eq = vi.fn(() => "eq"); - const sql = Object.assign( - () => ({ - as: () => ({}), - }), - { - raw: vi.fn(), - } - ); - - return { - db, - eq, - sql, - tables, - reset() { - selectResults.length = 0; - executeResults.length = 0; - db.select.mockClear(); - db.execute.mockClear(); - eq.mockClear(); - sql.raw.mockClear(); - }, - pushSelectResult(rows: Array>) { - selectResults.push(rows); - }, - pushExecuteResult(rows: Array>) { - executeResults.push(rows); - }, - }; -}); - -vi.mock("next/cache", () => ({ - unstable_cache: (fn: () => unknown) => fn, -})); - -vi.mock("@/lib/db", () => ({ - db: mockState.db, - users: mockState.tables.users, - submissions: mockState.tables.submissions, -})); - -vi.mock("drizzle-orm", () => ({ - eq: mockState.eq, - sql: mockState.sql, -})); - -type ModuleExports = typeof import("../../src/lib/embed/getUserEmbedStats"); - -let getUserEmbedStats: ModuleExports["getUserEmbedStats"]; - -beforeAll(async () => { - const embedStatsLib = await import("../../src/lib/embed/getUserEmbedStats"); - getUserEmbedStats = embedStatsLib.getUserEmbedStats; -}); - -beforeEach(() => { - mockState.reset(); -}); - -describe("getUserEmbedStats", () => { - it("aggregates totals and submission count across multiple submission rows", async () => { - mockState.pushSelectResult([ - { - id: "user-1", - username: "alice", - displayName: "Alice", - avatarUrl: null, - totalTokens: 3100, - totalCost: 17.75, - submissionCount: 5, - updatedAt: new Date("2026-04-01T08:00:00.000Z"), - }, - ]); - mockState.pushExecuteResult([{ rank: "2" }]); - - const result = await getUserEmbedStats("alice", "tokens"); - - expect(result).toEqual({ - user: { - id: "user-1", - username: "alice", - displayName: "Alice", - avatarUrl: null, - }, - 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"); - - expect(result).toBeNull(); - expect(mockState.db.execute).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/frontend/src/app/api/submit/route.ts b/packages/frontend/src/app/api/submit/route.ts index 52c8d8378..21ff23f5c 100644 --- a/packages/frontend/src/app/api/submit/route.ts +++ b/packages/frontend/src/app/api/submit/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from "next/server"; import { revalidateTag } from "next/cache"; import { db, apiTokens, submissions, dailyBreakdown } from "@/lib/db"; -import { and, eq, isNull, sql } from "drizzle-orm"; +import { eq, sql } from "drizzle-orm"; import { validateSubmission, generateSubmissionHash, @@ -14,13 +14,9 @@ import { buildModelBreakdown, clientContributionToBreakdownData, mergeTimestampMs, - resolveSubmissionScope, type ClientBreakdownData, } from "@/lib/db/helpers"; -const SOURCE_IDENTITY_REQUIRED_ERROR = - "Source identity is required for accounts with source-scoped submissions"; - function normalizeSubmissionData(data: unknown): void { if (!data || typeof data !== "object") return; const obj = data as Record; @@ -49,62 +45,6 @@ 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; -} - -async function loadUserSubmitMetrics(userId: string) { - const [userAggregatesRows, userDayAggregatesRows, userSubmissionsRows] = - await Promise.all([ - db - .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)), - db - .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)), - db - .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 @@ -193,10 +133,6 @@ export async function POST(request: Request) { 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 @@ -210,39 +146,24 @@ export async function POST(request: Request) { // ------------------------------------------ // STEP 3a: Get or create user's submission // ------------------------------------------ - const existingSubmissionRows = await tx - .select({ - id: submissions.id, - sourceId: submissions.sourceId, - }) + const [existingSubmission] = await tx + .select({ id: submissions.id }) .from(submissions) .where(eq(submissions.userId, tokenRecord.userId)) - .for("update"); + .for('update') + .limit(1); let submissionId: string; let isNewSubmission = false; - let upgradeLegacyRow = false; - - const scopeResolution = resolveSubmissionScope( - existingSubmissionRows, - sourceId - ); - - if (scopeResolution.kind === "rejectMissingSourceIdentity") { - throw new Error(SOURCE_IDENTITY_REQUIRED_ERROR); - } - if (scopeResolution.kind === "existing") { - submissionId = scopeResolution.submissionId; - upgradeLegacyRow = scopeResolution.upgradeLegacyRow; + if (existingSubmission) { + submissionId = existingSubmission.id; } else { isNewSubmission = true; const [newSubmission] = await tx .insert(submissions) .values({ userId: tokenRecord.userId, - sourceId, - sourceName, totalTokens: 0, totalCost: "0", inputTokens: 0, @@ -257,35 +178,9 @@ export async function POST(request: Request) { cliVersion: data.meta.version, submissionHash: generateSubmissionHash(hashData), }) - .onConflictDoNothing() .returning({ id: submissions.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) - ) - ) - .limit(1); - - if (!conflictedSubmission) { - throw new Error("Submission row was not found after insert conflict"); - } - - submissionId = conflictedSubmission.id; - } + submissionId = newSubmission.id; } // ------------------------------------------ @@ -489,45 +384,44 @@ 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, - 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(), - }; - - if (sourceName !== null) { - submissionUpdate.sourceName = sourceName; - } - if (sourceId !== null && upgradeLegacyRow) { - submissionUpdate.sourceId = sourceId; - } - await tx .update(submissions) - .set(submissionUpdate) + .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(), + }) .where(eq(submissions.id, submissionId)); 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), + }, }; }); - const metrics = await loadUserSubmitMetrics(tokenRecord.userId); - try { revalidateTag("leaderboard", "max"); revalidateTag(`user:${tokenRecord.username}`, "max"); @@ -541,20 +435,11 @@ export async function POST(request: Request) { success: true, submissionId: result.submissionId, username: tokenRecord.username, - metrics, + metrics: result.metrics, mode: result.isNewSubmission ? "create" : "merge", warnings: validation.warnings.length > 0 ? validation.warnings : undefined, }); } catch (error) { - if ( - error instanceof Error && - error.message === SOURCE_IDENTITY_REQUIRED_ERROR - ) { - return NextResponse.json( - { error: SOURCE_IDENTITY_REQUIRED_ERROR }, - { 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 bbac51ae3..d033d6535 100644 --- a/packages/frontend/src/app/api/users/[username]/route.ts +++ b/packages/frontend/src/app/api/users/[username]/route.ts @@ -48,7 +48,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(SUM(${submissions.submitCount}), 0)`, + submissionCount: sql`COALESCE(MAX(${submissions.submitCount}), 0)`, earliestDate: sql`MIN(${submissions.dateStart})`, latestDate: sql`MAX(${submissions.dateEnd})`, }) @@ -57,7 +57,6 @@ export async function GET(_request: Request, { params }: RouteParams) { db .select({ - id: submissions.id, sourcesUsed: submissions.sourcesUsed, modelsUsed: submissions.modelsUsed, updatedAt: submissions.updatedAt, @@ -66,7 +65,8 @@ export async function GET(_request: Request, { params }: RouteParams) { }) .from(submissions) .where(eq(submissions.userId, user.id)) - .orderBy(desc(submissions.updatedAt), desc(submissions.id)), + .orderBy(desc(submissions.updatedAt)) + .limit(1), db.execute<{ rank: number }>(sql` WITH user_totals AS ( @@ -110,17 +110,6 @@ 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 +443,8 @@ export async function GET(_request: Request, { params }: RouteParams) { cliVersion: latestSubmission?.cliVersion, schemaVersion: latestSubmission?.schemaVersion, }), - clients: Array.from(clients).sort(), - models: Array.from(models).sort(), + clients: latestSubmission?.sourcesUsed || [], + models: latestSubmission?.modelsUsed || [], modelUsage, contributions: graphContributions, }); diff --git a/packages/frontend/src/lib/db/helpers.ts b/packages/frontend/src/lib/db/helpers.ts index e96345b72..1103053e0 100644 --- a/packages/frontend/src/lib/db/helpers.ts +++ b/packages/frontend/src/lib/db/helpers.ts @@ -37,70 +37,6 @@ 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/0005_complete_ted_forrester.sql b/packages/frontend/src/lib/db/migrations/0005_complete_ted_forrester.sql deleted file mode 100644 index 4ea9ee147..000000000 --- a/packages/frontend/src/lib/db/migrations/0005_complete_ted_forrester.sql +++ /dev/null @@ -1,6 +0,0 @@ -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"); diff --git a/packages/frontend/src/lib/db/migrations/meta/0005_snapshot.json b/packages/frontend/src/lib/db/migrations/meta/0005_snapshot.json deleted file mode 100644 index 9144335eb..000000000 --- a/packages/frontend/src/lib/db/migrations/meta/0005_snapshot.json +++ /dev/null @@ -1,954 +0,0 @@ -{ - "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 - }, - "submission_hash": { - "name": "submission_hash", - "type": "varchar(64)", - "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/_journal.json b/packages/frontend/src/lib/db/migrations/meta/_journal.json index e6dfb3eff..f2d23cd96 100644 --- a/packages/frontend/src/lib/db/migrations/meta/_journal.json +++ b/packages/frontend/src/lib/db/migrations/meta/_journal.json @@ -36,13 +36,6 @@ "when": 1771322400000, "tag": "0004_add_timestamp_and_schema_version", "breakpoints": true - }, - { - "idx": 5, - "version": "7", - "when": 1775081591892, - "tag": "0005_complete_ted_forrester", - "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/frontend/src/lib/db/schema.ts b/packages/frontend/src/lib/db/schema.ts index 615c3fb65..247c85929 100644 --- a/packages/frontend/src/lib/db/schema.ts +++ b/packages/frontend/src/lib/db/schema.ts @@ -12,9 +12,8 @@ import { integer, index, unique, - uniqueIndex, } from "drizzle-orm/pg-core"; -import { relations, sql } from "drizzle-orm"; +import { relations } from "drizzle-orm"; // ============================================================================ // USERS @@ -145,8 +144,6 @@ 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(), @@ -189,17 +186,9 @@ export const submissions = pgTable( 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_source_unique") - .on(table.userId, table.sourceId), - uniqueIndex("submissions_user_unsourced_unique") - .on(table.userId) - .where(sql`${table.sourceId} is null`), + 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), ] ); diff --git a/packages/frontend/src/lib/embed/getUserEmbedStats.ts b/packages/frontend/src/lib/embed/getUserEmbedStats.ts index 8c477b2ce..0678af8e4 100644 --- a/packages/frontend/src/lib/embed/getUserEmbedStats.ts +++ b/packages/frontend/src/lib/embed/getUserEmbedStats.ts @@ -27,15 +27,14 @@ async function fetchUserEmbedStats(username: string, sortBy: EmbedSortBy): Promi username: users.username, displayName: users.displayName, avatarUrl: users.avatarUrl, - 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})`, + 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, }) .from(users) .leftJoin(submissions, eq(submissions.userId, users.id)) .where(eq(users.username, username)) - .groupBy(users.id, users.username, users.displayName, users.avatarUrl) .limit(1); if (!result) { @@ -48,31 +47,21 @@ async function fetchUserEmbedStats(username: string, sortBy: EmbedSortBy): Promi if (rankingValue > 0) { const rankResult = await db.execute<{ rank: number }>(sql` - 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 ( + WITH ranked AS ( SELECT user_id, RANK() OVER ( ORDER BY ${sortBy === "cost" - ? sql`total_cost DESC, total_tokens DESC` - : sql`total_tokens DESC, total_cost DESC`} + ? sql`CAST(total_cost AS DECIMAL(12,4)) DESC, total_tokens DESC` + : sql`total_tokens DESC, CAST(total_cost AS DECIMAL(12,4)) DESC`} ) AS rank - FROM user_totals + FROM submissions ) SELECT rank FROM ranked WHERE user_id = ${result.id} `); - 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; + rank = (rankResult as unknown as { rank: number }[])[0]?.rank || null; } return { @@ -87,11 +76,7 @@ async function fetchUserEmbedStats(username: string, sortBy: EmbedSortBy): Promi totalCost: Number(result.totalCost) || 0, submissionCount: Number(result.submissionCount) || 0, rank, - updatedAt: result.updatedAt instanceof Date - ? result.updatedAt.toISOString() - : result.updatedAt - ? new Date(result.updatedAt).toISOString() - : null, + updatedAt: result.updatedAt?.toISOString() || null, }, }; } diff --git a/packages/frontend/src/lib/leaderboard/getLeaderboard.ts b/packages/frontend/src/lib/leaderboard/getLeaderboard.ts index fc551f951..139479b0d 100644 --- a/packages/frontend/src/lib/leaderboard/getLeaderboard.ts +++ b/packages/frontend/src/lib/leaderboard/getLeaderboard.ts @@ -341,7 +341,7 @@ async function fetchLeaderboardData( .select({ totalTokens: sql`SUM(${submissions.totalTokens})`, totalCost: sql`SUM(CAST(${submissions.totalCost} AS DECIMAL(12,4)))`, - totalSubmissions: sql`COALESCE(SUM(${submissions.submitCount}), 0)`, + totalSubmissions: sql`COUNT(${submissions.id})`, uniqueUsers: sql`COUNT(DISTINCT ${submissions.userId})`, }) .from(submissions); @@ -418,7 +418,7 @@ async function fetchLeaderboardData( .select({ totalTokens: sql`SUM(${submissions.totalTokens})`, totalCost: sql`SUM(CAST(${submissions.totalCost} AS DECIMAL(12,4)))`, - totalSubmissions: sql`COALESCE(SUM(${submissions.submitCount}), 0)`, + totalSubmissions: sql`COUNT(${submissions.id})`, uniqueUsers: sql`COUNT(DISTINCT ${submissions.userId})`, }) .from(submissions), diff --git a/packages/frontend/src/lib/types.ts b/packages/frontend/src/lib/types.ts index 4b9a1efd0..2db8d0cf6 100644 --- a/packages/frontend/src/lib/types.ts +++ b/packages/frontend/src/lib/types.ts @@ -78,8 +78,6 @@ 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 c97f2afa4..23cc2f922 100644 --- a/packages/frontend/src/lib/validation/submission.ts +++ b/packages/frontend/src/lib/validation/submission.ts @@ -81,21 +81,9 @@ const DataSummarySchema = z.object({ models: z.array(z.string()), }); -const OptionalSourceMetadataSchema = z.preprocess( - (value) => { - if (typeof value === "string" && value.trim() === "") { - return undefined; - } - return value; - }, - z.string().trim().min(1).max(255).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}$/), From e4d7999ef2d54214d3aaa2bf20446c87c5a3eb86 Mon Sep 17 00:00:00 2001 From: IvGolovach Date: Wed, 1 Apr 2026 14:34:25 -0700 Subject: [PATCH 03/25] fix(submit): preserve source-scoped multi-machine submissions (cherry picked from commit 7a035aca2f502a5b14a73799a965929979bcaa9d) --- crates/tokscale-cli/src/auth.rs | 315 ++++- crates/tokscale-cli/src/main.rs | 59 +- .../frontend/__tests__/api/submit.test.ts | 118 ++ .../frontend/__tests__/api/submitAuth.test.ts | 83 ++ .../__tests__/api/usersProfile.test.ts | 14 +- .../frontend/__tests__/lib/dbHelpers.test.ts | 64 + .../__tests__/lib/getUserEmbedStats.test.ts | 143 +++ packages/frontend/src/app/api/submit/route.ts | 188 ++- .../src/app/api/users/[username]/route.ts | 20 +- packages/frontend/src/lib/db/helpers.ts | 64 + .../migrations/0005_tan_rumiko_fujikawa.sql | 9 + .../lib/db/migrations/meta/0005_snapshot.json | 1038 +++++++++++++++++ .../src/lib/db/migrations/meta/_journal.json | 7 + packages/frontend/src/lib/db/schema.ts | 31 +- .../src/lib/embed/getUserEmbedStats.ts | 35 +- .../src/lib/leaderboard/getLeaderboard.ts | 4 +- packages/frontend/src/lib/types.ts | 2 + .../frontend/src/lib/validation/submission.ts | 12 + 18 files changed, 2141 insertions(+), 65 deletions(-) create mode 100644 packages/frontend/__tests__/lib/dbHelpers.test.ts create mode 100644 packages/frontend/__tests__/lib/getUserEmbedStats.test.ts create mode 100644 packages/frontend/src/lib/db/migrations/0005_tan_rumiko_fujikawa.sql create mode 100644 packages/frontend/src/lib/db/migrations/meta/0005_snapshot.json diff --git a/crates/tokscale-cli/src/auth.rs b/crates/tokscale-cli/src/auth.rs index 24be5e5e0..be8d4d2f1 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}; fn home_dir() -> Result { dirs::home_dir().context("Could not determine home directory") @@ -53,6 +55,18 @@ 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); + fn ensure_config_dir() -> Result<()> { let config_dir = home_dir()?.join(".config/tokscale"); @@ -125,6 +139,249 @@ 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() { + let (key, value) = line.split_once('=')?; + 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); + } + + fs::metadata(path) + .and_then(|metadata| metadata.modified()) + .ok() + .and_then(|modified| modified.elapsed().ok()) + .unwrap_or_default() +} + +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)]) + .output(); + + match output { + Ok(output) if output.status.success() => { + let stdout = String::from_utf8_lossy(&output.stdout); + Some(stdout.contains(&pid.to_string()) && !stdout.contains("No tasks are running")) + } + Ok(_) => None, + Err(_) => None, + } + } + + #[cfg(not(any(unix, windows)))] + { + None + } +} + +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_dead = match state { + Some(lock_state) => match lock_owner_is_alive(lock_state.pid) { + Some(is_alive) => !is_alive, + None => age >= SOURCE_ID_LOCK_STALE_AFTER, + }, + None => true, + }; + + if owner_is_dead && age >= SOURCE_ID_LOCK_STALE_AFTER { + 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()) @@ -508,6 +765,62 @@ 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] #[serial] fn test_save_credentials() { diff --git a/crates/tokscale-cli/src/main.rs b/crates/tokscale-cli/src/main.rs index 1f185efbd..f51240d9e 100644 --- a/crates/tokscale-cli/src/main.rs +++ b/crates/tokscale-cli/src/main.rs @@ -2827,6 +2827,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, } @@ -2839,11 +2843,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(), @@ -3255,7 +3265,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 { @@ -3502,7 +3512,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() @@ -3904,6 +3936,27 @@ mod tests { .unwrap() } + #[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 { diff --git a/packages/frontend/__tests__/api/submit.test.ts b/packages/frontend/__tests__/api/submit.test.ts index 2629c3fb8..a7c8024f3 100644 --- a/packages/frontend/__tests__/api/submit.test.ts +++ b/packages/frontend/__tests__/api/submit.test.ts @@ -176,6 +176,124 @@ describe('POST /api/submit - Client-Level Merge', () => { expect(result.valid).toBe(true); expect(result.errors).toHaveLength(0); }); + + it("should accept source metadata for machine-scoped submissions", () => { + const payload = { + 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", + 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.data?.meta.sourceId).toBe("machine-123"); + expect(result.data?.meta.sourceName).toBe("Workstation"); + }); + + 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.data?.meta.sourceId).toBeUndefined(); + expect(result.data?.meta.sourceName).toBeUndefined(); + }); }); describe('Client-Level Merge Logic', () => { diff --git a/packages/frontend/__tests__/api/submitAuth.test.ts b/packages/frontend/__tests__/api/submitAuth.test.ts index c71870cb7..60defd84a 100644 --- a/packages/frontend/__tests__/api/submitAuth.test.ts +++ b/packages/frontend/__tests__/api/submitAuth.test.ts @@ -153,4 +153,87 @@ describe("POST /api/submit auth path", () => { details: ["bad payload"], }); }); + + it("returns 409 when source identity is required after scoped mode begins", 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: [], + }); + mockState.db.transaction.mockRejectedValue( + new Error("Source identity is required for accounts with source-scoped submissions") + ); + + 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", + }); + }); }); diff --git a/packages/frontend/__tests__/api/usersProfile.test.ts b/packages/frontend/__tests__/api/usersProfile.test.ts index d6e664461..cf7bd42bd 100644 --- a/packages/frontend/__tests__/api/usersProfile.test.ts +++ b/packages/frontend/__tests__/api/usersProfile.test.ts @@ -163,7 +163,7 @@ describe("GET /api/users/[username]", () => { cacheReadTokens: 100, cacheCreationTokens: 50, reasoningTokens: 25, - submissionCount: 2, + submissionCount: 5, earliestDate: "2026-01-01", latestDate: "2026-03-10", }, @@ -176,6 +176,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 }]); @@ -194,8 +201,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 () => { diff --git a/packages/frontend/__tests__/lib/dbHelpers.test.ts b/packages/frontend/__tests__/lib/dbHelpers.test.ts new file mode 100644 index 000000000..12fa71f40 --- /dev/null +++ b/packages/frontend/__tests__/lib/dbHelpers.test.ts @@ -0,0 +1,64 @@ +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, + }); + }); +}); diff --git a/packages/frontend/__tests__/lib/getUserEmbedStats.test.ts b/packages/frontend/__tests__/lib/getUserEmbedStats.test.ts new file mode 100644 index 000000000..d5af814fb --- /dev/null +++ b/packages/frontend/__tests__/lib/getUserEmbedStats.test.ts @@ -0,0 +1,143 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +const mockState = vi.hoisted(() => { + const selectResults: Array>> = []; + const executeResults: Array>> = []; + + const tables = { + users: { + id: "users.id", + username: "users.username", + displayName: "users.displayName", + avatarUrl: "users.avatarUrl", + }, + submissions: { + userId: "submissions.userId", + totalTokens: "submissions.totalTokens", + totalCost: "submissions.totalCost", + submitCount: "submissions.submitCount", + updatedAt: "submissions.updatedAt", + }, + }; + + const db = { + select: vi.fn(() => { + const builder = { + from: vi.fn(() => builder), + leftJoin: vi.fn(() => builder), + where: vi.fn(() => builder), + groupBy: vi.fn(() => builder), + limit: vi.fn(() => builder), + then: (resolve: (value: unknown) => unknown) => + resolve(selectResults.shift() ?? []), + }; + + return builder; + }), + execute: vi.fn(async () => executeResults.shift() ?? []), + }; + + const eq = vi.fn(() => "eq"); + const sql = Object.assign( + () => ({ + as: () => ({}), + }), + { + raw: vi.fn(), + } + ); + + return { + db, + eq, + sql, + tables, + reset() { + selectResults.length = 0; + executeResults.length = 0; + db.select.mockClear(); + db.execute.mockClear(); + eq.mockClear(); + sql.raw.mockClear(); + }, + pushSelectResult(rows: Array>) { + selectResults.push(rows); + }, + pushExecuteResult(rows: Array>) { + executeResults.push(rows); + }, + }; +}); + +vi.mock("next/cache", () => ({ + unstable_cache: (fn: () => unknown) => fn, +})); + +vi.mock("@/lib/db", () => ({ + db: mockState.db, + users: mockState.tables.users, + submissions: mockState.tables.submissions, +})); + +vi.mock("drizzle-orm", () => ({ + eq: mockState.eq, + sql: mockState.sql, +})); + +type ModuleExports = typeof import("../../src/lib/embed/getUserEmbedStats"); + +let getUserEmbedStats: ModuleExports["getUserEmbedStats"]; + +beforeAll(async () => { + const module = await import("../../src/lib/embed/getUserEmbedStats"); + getUserEmbedStats = module.getUserEmbedStats; +}); + +beforeEach(() => { + mockState.reset(); +}); + +describe("getUserEmbedStats", () => { + it("aggregates totals and submission count across multiple submission rows", async () => { + mockState.pushSelectResult([ + { + id: "user-1", + username: "alice", + displayName: "Alice", + avatarUrl: null, + totalTokens: 3100, + totalCost: 17.75, + submissionCount: 5, + updatedAt: new Date("2026-04-01T08:00:00.000Z"), + }, + ]); + mockState.pushExecuteResult([{ rank: "2" }]); + + const result = await getUserEmbedStats("alice", "tokens"); + + expect(result).toEqual({ + user: { + id: "user-1", + username: "alice", + displayName: "Alice", + avatarUrl: null, + }, + 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"); + + expect(result).toBeNull(); + expect(mockState.db.execute).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/frontend/src/app/api/submit/route.ts b/packages/frontend/src/app/api/submit/route.ts index 21ff23f5c..48a863d4e 100644 --- a/packages/frontend/src/app/api/submit/route.ts +++ b/packages/frontend/src/app/api/submit/route.ts @@ -1,7 +1,7 @@ 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, @@ -14,9 +14,13 @@ import { buildModelBreakdown, clientContributionToBreakdownData, mergeTimestampMs, + resolveSubmissionScope, type ClientBreakdownData, } from "@/lib/db/helpers"; +const SOURCE_IDENTITY_REQUIRED_ERROR = + "Source identity is required for accounts with source-scoped submissions"; + function normalizeSubmissionData(data: unknown): void { if (!data || typeof data !== "object") return; const obj = data as Record; @@ -45,6 +49,62 @@ 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; +} + +async function loadUserSubmitMetrics(userId: string) { + const [userAggregatesRows, userDayAggregatesRows, userSubmissionsRows] = + await Promise.all([ + db + .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)), + db + .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)), + db + .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 @@ -133,6 +193,10 @@ export async function POST(request: Request) { 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 @@ -146,24 +210,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 Error(SOURCE_IDENTITY_REQUIRED_ERROR); + } - 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, @@ -178,9 +257,34 @@ export async function POST(request: Request) { cliVersion: data.meta.version, submissionHash: generateSubmissionHash(hashData), }) + .onConflictDoNothing() .returning({ id: submissions.id }); - submissionId = newSubmission.id; + if (newSubmission) { + submissionId = newSubmission.id; + } else { + 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) + ) + ) + .limit(1); + + if (!conflictedSubmission) { + throw new Error("Submission row was not found after insert conflict"); + } + + submissionId = conflictedSubmission.id; + } } // ------------------------------------------ @@ -384,44 +488,45 @@ 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, + 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(), + }; + + if (sourceName !== null) { + submissionUpdate.sourceName = sourceName; + } + if (sourceId !== null && upgradeLegacyRow) { + submissionUpdate.sourceId = sourceId; + } + 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)); 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), - }, }; }); + const metrics = await loadUserSubmitMetrics(tokenRecord.userId); + try { revalidateTag("leaderboard", "max"); revalidateTag(`user:${tokenRecord.username}`, "max"); @@ -435,11 +540,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 Error && + error.message === SOURCE_IDENTITY_REQUIRED_ERROR + ) { + return NextResponse.json( + { error: SOURCE_IDENTITY_REQUIRED_ERROR }, + { 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 d033d6535..d70409766 100644 --- a/packages/frontend/src/app/api/users/[username]/route.ts +++ b/packages/frontend/src/app/api/users/[username]/route.ts @@ -48,7 +48,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})`, }) @@ -65,8 +65,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)), db.execute<{ rank: number }>(sql` WITH user_totals AS ( @@ -110,6 +109,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; @@ -443,8 +453,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/lib/db/helpers.ts b/packages/frontend/src/lib/db/helpers.ts index 1103053e0..539d728fd 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 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; + const hasScopedRows = existingRows.some((row) => row.sourceId != null); + + if (incomingSourceId) { + if (unsourcedRow && !hasScopedRows) { + return { + kind: "existing", + submissionId: unsourcedRow.id, + upgradeLegacyRow: true, + }; + } + + return { kind: "create" }; + } + + if (unsourcedRow) { + return { + kind: "existing", + submissionId: unsourcedRow.id, + upgradeLegacyRow: false, + }; + } + + if (hasScopedRows) { + return { kind: "rejectMissingSourceIdentity" }; + } + + return { kind: "create" }; +} + export function recalculateDayTotals( clientBreakdown: Record ): DayTotals { diff --git a/packages/frontend/src/lib/db/migrations/0005_tan_rumiko_fujikawa.sql b/packages/frontend/src/lib/db/migrations/0005_tan_rumiko_fujikawa.sql new file mode 100644 index 000000000..71a472f18 --- /dev/null +++ b/packages/frontend/src/lib/db/migrations/0005_tan_rumiko_fujikawa.sql @@ -0,0 +1,9 @@ +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 INDEX "idx_submissions_user_source" ON "submissions" USING btree ("user_id","source_id");--> statement-breakpoint +CREATE UNIQUE INDEX "submissions_user_unsourced_unique" ON "submissions" USING btree ("user_id") WHERE "submissions"."source_id" is null;--> statement-breakpoint +CREATE UNIQUE INDEX "submissions_user_source_unique" ON "submissions" USING btree ("user_id","source_id") WHERE "submissions"."source_id" is not null;--> statement-breakpoint +CREATE UNIQUE INDEX "submissions_user_unsourced_hash_unique" ON "submissions" USING btree ("user_id","submission_hash") WHERE "submissions"."submission_hash" is not null and "submissions"."source_id" is null;--> statement-breakpoint +CREATE UNIQUE INDEX "submissions_user_source_hash_unique" ON "submissions" USING btree ("user_id","source_id","submission_hash") WHERE "submissions"."submission_hash" is not null and "submissions"."source_id" is not null; diff --git a/packages/frontend/src/lib/db/migrations/meta/0005_snapshot.json b/packages/frontend/src/lib/db/migrations/meta/0005_snapshot.json new file mode 100644 index 000000000..e4c9a1456 --- /dev/null +++ b/packages/frontend/src/lib/db/migrations/meta/0005_snapshot.json @@ -0,0 +1,1038 @@ +{ + "id": "addf32e8-bc11-4942-b777-1e82b08a4ac6", + "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 + }, + "submission_hash": { + "name": "submission_hash", + "type": "varchar(64)", + "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_user_source": { + "name": "idx_submissions_user_source", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_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": {} + }, + "submissions_user_source_unique": { + "name": "submissions_user_source_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"submissions\".\"source_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "submissions_user_unsourced_hash_unique": { + "name": "submissions_user_unsourced_hash_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "submission_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"submissions\".\"submission_hash\" is not null and \"submissions\".\"source_id\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "submissions_user_source_hash_unique": { + "name": "submissions_user_source_hash_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "submission_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"submissions\".\"submission_hash\" is not null and \"submissions\".\"source_id\" is not 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": {}, + "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/_journal.json b/packages/frontend/src/lib/db/migrations/meta/_journal.json index f2d23cd96..3b5b90eff 100644 --- a/packages/frontend/src/lib/db/migrations/meta/_journal.json +++ b/packages/frontend/src/lib/db/migrations/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1771322400000, "tag": "0004_add_timestamp_and_schema_version", "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1775078633014, + "tag": "0005_tan_rumiko_fujikawa", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/frontend/src/lib/db/schema.ts b/packages/frontend/src/lib/db/schema.ts index 247c85929..010a4ffa3 100644 --- a/packages/frontend/src/lib/db/schema.ts +++ b/packages/frontend/src/lib/db/schema.ts @@ -12,8 +12,9 @@ import { integer, index, unique, + uniqueIndex, } from "drizzle-orm/pg-core"; -import { relations } from "drizzle-orm"; +import { relations, sql } from "drizzle-orm"; // ============================================================================ // USERS @@ -144,6 +145,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(), @@ -182,13 +185,33 @@ export const submissions = pgTable( }, (table) => [ index("idx_submissions_user_id").on(table.userId), + index("idx_submissions_user_source").on(table.userId, table.sourceId), 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), + index("idx_submissions_leaderboard").on( + table.userId, + table.totalTokens, + table.totalCost, + table.createdAt + ), + uniqueIndex("submissions_user_unsourced_unique") + .on(table.userId) + .where(sql`${table.sourceId} is null`), + uniqueIndex("submissions_user_source_unique") + .on(table.userId, table.sourceId) + .where(sql`${table.sourceId} is not null`), + uniqueIndex("submissions_user_unsourced_hash_unique") + .on(table.userId, table.submissionHash) + .where( + sql`${table.submissionHash} is not null and ${table.sourceId} is null` + ), + uniqueIndex("submissions_user_source_hash_unique") + .on(table.userId, table.sourceId, table.submissionHash) + .where( + sql`${table.submissionHash} is not null and ${table.sourceId} is not null` + ), ] ); diff --git a/packages/frontend/src/lib/embed/getUserEmbedStats.ts b/packages/frontend/src/lib/embed/getUserEmbedStats.ts index 0678af8e4..8c477b2ce 100644 --- a/packages/frontend/src/lib/embed/getUserEmbedStats.ts +++ b/packages/frontend/src/lib/embed/getUserEmbedStats.ts @@ -27,14 +27,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(eq(users.username, username)) + .groupBy(users.id, users.username, users.displayName, users.avatarUrl) .limit(1); if (!result) { @@ -47,21 +48,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 { @@ -76,7 +87,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, }, }; } diff --git a/packages/frontend/src/lib/leaderboard/getLeaderboard.ts b/packages/frontend/src/lib/leaderboard/getLeaderboard.ts index 139479b0d..fc551f951 100644 --- a/packages/frontend/src/lib/leaderboard/getLeaderboard.ts +++ b/packages/frontend/src/lib/leaderboard/getLeaderboard.ts @@ -341,7 +341,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); @@ -418,7 +418,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), diff --git a/packages/frontend/src/lib/types.ts b/packages/frontend/src/lib/types.ts index 2db8d0cf6..4b9a1efd0 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 23cc2f922..c97f2afa4 100644 --- a/packages/frontend/src/lib/validation/submission.ts +++ b/packages/frontend/src/lib/validation/submission.ts @@ -81,9 +81,21 @@ const DataSummarySchema = z.object({ models: z.array(z.string()), }); +const OptionalSourceMetadataSchema = z.preprocess( + (value) => { + if (typeof value === "string" && value.trim() === "") { + return undefined; + } + return value; + }, + z.string().trim().min(1).max(255).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}$/), From 79f7d997dba2953c96adc02cea3328227080f071 Mon Sep 17 00:00:00 2001 From: IvGolovach Date: Wed, 1 Apr 2026 15:01:13 -0700 Subject: [PATCH 04/25] fix(submit): harden source-scoped submission flow (cherry picked from commit d09ab0a552c969b99bd1d062896222d152b50fb8) --- .../frontend/__tests__/api/submitAuth.test.ts | 178 +++++++++++++++++- .../__tests__/api/usersProfile.test.ts | 56 ++++++ .../frontend/__tests__/lib/dbHelpers.test.ts | 12 ++ packages/frontend/src/app/api/submit/route.ts | 1 + .../src/app/api/users/[username]/route.ts | 3 +- packages/frontend/src/lib/db/helpers.ts | 10 +- .../db/migrations/0005_conscious_cargill.sql | 5 + .../migrations/0005_tan_rumiko_fujikawa.sql | 9 - .../lib/db/migrations/meta/0005_snapshot.json | 122 ++---------- .../src/lib/db/migrations/meta/_journal.json | 4 +- packages/frontend/src/lib/db/schema.ts | 19 +- 11 files changed, 266 insertions(+), 153 deletions(-) create mode 100644 packages/frontend/src/lib/db/migrations/0005_conscious_cargill.sql delete mode 100644 packages/frontend/src/lib/db/migrations/0005_tan_rumiko_fujikawa.sql diff --git a/packages/frontend/__tests__/api/submitAuth.test.ts b/packages/frontend/__tests__/api/submitAuth.test.ts index 60defd84a..3761156a8 100644 --- a/packages/frontend/__tests__/api/submitAuth.test.ts +++ b/packages/frontend/__tests__/api/submitAuth.test.ts @@ -5,9 +5,50 @@ const mockState = vi.hoisted(() => { const validateSubmission = vi.fn(); const generateSubmissionHash = vi.fn(() => "submission-hash"); const revalidateTag = vi.fn(); + const selectResults: Array>> = []; + + const submissions = { + id: "submissions.id", + userId: "submissions.userId", + totalTokens: "submissions.totalTokens", + totalCost: "submissions.totalCost", + dateStart: "submissions.dateStart", + dateEnd: "submissions.dateEnd", + sourcesUsed: "submissions.sourcesUsed", + }; + + const dailyBreakdown = { + id: "dailyBreakdown.id", + submissionId: "dailyBreakdown.submissionId", + tokens: "dailyBreakdown.tokens", + date: "dailyBreakdown.date", + }; + + 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 { @@ -15,6 +56,12 @@ const mockState = vi.hoisted(() => { validateSubmission, generateSubmissionHash, revalidateTag, + submissions, + dailyBreakdown, + eq, + and, + isNull, + sql, db, reset() { authenticatePersonalToken.mockReset(); @@ -22,6 +69,15 @@ const mockState = vi.hoisted(() => { generateSubmissionHash.mockClear(); revalidateTag.mockClear(); db.transaction.mockReset(); + db.select.mockClear(); + selectResults.length = 0; + eq.mockClear(); + and.mockClear(); + isNull.mockClear(); + sql.raw.mockClear(); + }, + pushSelectResult(rows: Array>) { + selectResults.push(rows); }, }; }); @@ -36,14 +92,8 @@ vi.mock("@/lib/auth/personalTokens", () => ({ vi.mock("@/lib/db", () => ({ db: mockState.db, - submissions: { - id: "submissions.id", - userId: "submissions.userId", - }, - dailyBreakdown: { - id: "dailyBreakdown.id", - submissionId: "dailyBreakdown.submissionId", - }, + submissions: mockState.submissions, + dailyBreakdown: mockState.dailyBreakdown, })); vi.mock("@/lib/validation/submission", () => ({ @@ -59,6 +109,13 @@ vi.mock("@/lib/db/helpers", () => ({ mergeTimestampMs: vi.fn(), })); +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"]; @@ -215,6 +272,7 @@ describe("POST /api/submit auth path", () => { ], }, errors: [], + warnings: [], }); mockState.db.transaction.mockRejectedValue( new Error("Source identity is required for accounts with source-scoped submissions") @@ -236,4 +294,108 @@ describe("POST /api/submit auth path", () => { error: "Source identity is required for accounts with source-scoped submissions", }); }); + + 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: [], + }); + mockState.db.transaction.mockResolvedValue({ + submissionId: "submission-1", + isNewSubmission: false, + }); + mockState.pushSelectResult([ + { + totalTokens: 1500, + totalCost: "1.5000", + dateStart: "2024-12-01", + dateEnd: "2024-12-01", + }, + ]); + mockState.pushSelectResult([{ activeDays: 1 }]); + mockState.pushSelectResult([{ sourcesUsed: ["claude"] }]); + + 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(); + + 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"], + }); + }); }); diff --git a/packages/frontend/__tests__/api/usersProfile.test.ts b/packages/frontend/__tests__/api/usersProfile.test.ts index cf7bd42bd..fbb67c193 100644 --- a/packages/frontend/__tests__/api/usersProfile.test.ts +++ b/packages/frontend/__tests__/api/usersProfile.test.ts @@ -13,6 +13,7 @@ const mockState = vi.hoisted(() => { createdAt: "users.createdAt", }, submissions: { + id: "submissions.id", userId: "submissions.userId", totalTokens: "submissions.totalTokens", totalCost: "submissions.totalCost", @@ -246,4 +247,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 index 12fa71f40..bccaa8606 100644 --- a/packages/frontend/__tests__/lib/dbHelpers.test.ts +++ b/packages/frontend/__tests__/lib/dbHelpers.test.ts @@ -61,4 +61,16 @@ describe("resolveSubmissionScope", () => { 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/src/app/api/submit/route.ts b/packages/frontend/src/app/api/submit/route.ts index 48a863d4e..52c8d8378 100644 --- a/packages/frontend/src/app/api/submit/route.ts +++ b/packages/frontend/src/app/api/submit/route.ts @@ -263,6 +263,7 @@ export async function POST(request: Request) { if (newSubmission) { submissionId = newSubmission.id; } else { + isNewSubmission = false; const [conflictedSubmission] = await tx .select({ id: submissions.id }) .from(submissions) diff --git a/packages/frontend/src/app/api/users/[username]/route.ts b/packages/frontend/src/app/api/users/[username]/route.ts index d70409766..bbac51ae3 100644 --- a/packages/frontend/src/app/api/users/[username]/route.ts +++ b/packages/frontend/src/app/api/users/[username]/route.ts @@ -57,6 +57,7 @@ export async function GET(_request: Request, { params }: RouteParams) { db .select({ + id: submissions.id, sourcesUsed: submissions.sourcesUsed, modelsUsed: submissions.modelsUsed, updatedAt: submissions.updatedAt, @@ -65,7 +66,7 @@ export async function GET(_request: Request, { params }: RouteParams) { }) .from(submissions) .where(eq(submissions.userId, user.id)) - .orderBy(desc(submissions.updatedAt)), + .orderBy(desc(submissions.updatedAt), desc(submissions.id)), db.execute<{ rank: number }>(sql` WITH user_totals AS ( diff --git a/packages/frontend/src/lib/db/helpers.ts b/packages/frontend/src/lib/db/helpers.ts index 539d728fd..e96345b72 100644 --- a/packages/frontend/src/lib/db/helpers.ts +++ b/packages/frontend/src/lib/db/helpers.ts @@ -59,6 +59,7 @@ 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; @@ -72,7 +73,6 @@ export function resolveSubmissionScope( const unsourcedRow = existingRows.find((row) => row.sourceId == null) ?? null; - const hasScopedRows = existingRows.some((row) => row.sourceId != null); if (incomingSourceId) { if (unsourcedRow && !hasScopedRows) { @@ -86,6 +86,10 @@ export function resolveSubmissionScope( return { kind: "create" }; } + if (hasScopedRows) { + return { kind: "rejectMissingSourceIdentity" }; + } + if (unsourcedRow) { return { kind: "existing", @@ -94,10 +98,6 @@ export function resolveSubmissionScope( }; } - if (hasScopedRows) { - return { kind: "rejectMissingSourceIdentity" }; - } - return { kind: "create" }; } diff --git a/packages/frontend/src/lib/db/migrations/0005_conscious_cargill.sql b/packages/frontend/src/lib/db/migrations/0005_conscious_cargill.sql new file mode 100644 index 000000000..74d626de8 --- /dev/null +++ b/packages/frontend/src/lib/db/migrations/0005_conscious_cargill.sql @@ -0,0 +1,5 @@ +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 +ALTER TABLE "submissions" ADD CONSTRAINT "submissions_user_source_unique" UNIQUE NULLS NOT DISTINCT("user_id","source_id"); diff --git a/packages/frontend/src/lib/db/migrations/0005_tan_rumiko_fujikawa.sql b/packages/frontend/src/lib/db/migrations/0005_tan_rumiko_fujikawa.sql deleted file mode 100644 index 71a472f18..000000000 --- a/packages/frontend/src/lib/db/migrations/0005_tan_rumiko_fujikawa.sql +++ /dev/null @@ -1,9 +0,0 @@ -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 INDEX "idx_submissions_user_source" ON "submissions" USING btree ("user_id","source_id");--> statement-breakpoint -CREATE UNIQUE INDEX "submissions_user_unsourced_unique" ON "submissions" USING btree ("user_id") WHERE "submissions"."source_id" is null;--> statement-breakpoint -CREATE UNIQUE INDEX "submissions_user_source_unique" ON "submissions" USING btree ("user_id","source_id") WHERE "submissions"."source_id" is not null;--> statement-breakpoint -CREATE UNIQUE INDEX "submissions_user_unsourced_hash_unique" ON "submissions" USING btree ("user_id","submission_hash") WHERE "submissions"."submission_hash" is not null and "submissions"."source_id" is null;--> statement-breakpoint -CREATE UNIQUE INDEX "submissions_user_source_hash_unique" ON "submissions" USING btree ("user_id","source_id","submission_hash") WHERE "submissions"."submission_hash" is not null and "submissions"."source_id" is not null; diff --git a/packages/frontend/src/lib/db/migrations/meta/0005_snapshot.json b/packages/frontend/src/lib/db/migrations/meta/0005_snapshot.json index e4c9a1456..22ecef492 100644 --- a/packages/frontend/src/lib/db/migrations/meta/0005_snapshot.json +++ b/packages/frontend/src/lib/db/migrations/meta/0005_snapshot.json @@ -1,5 +1,5 @@ { - "id": "addf32e8-bc11-4942-b777-1e82b08a4ac6", + "id": "2c0e7540-105f-48f9-9103-41cbd0d2d0a1", "prevId": "4342d7b5-5562-431f-afd1-0dd773563dff", "version": "7", "dialect": "postgresql", @@ -676,27 +676,6 @@ "method": "btree", "with": {} }, - "idx_submissions_user_source": { - "name": "idx_submissions_user_source", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "source_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, "idx_submissions_status": { "name": "idx_submissions_status", "columns": [ @@ -795,94 +774,6 @@ "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": {} - }, - "submissions_user_source_unique": { - "name": "submissions_user_source_unique", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "source_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "where": "\"submissions\".\"source_id\" is not null", - "concurrently": false, - "method": "btree", - "with": {} - }, - "submissions_user_unsourced_hash_unique": { - "name": "submissions_user_unsourced_hash_unique", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "submission_hash", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "where": "\"submissions\".\"submission_hash\" is not null and \"submissions\".\"source_id\" is null", - "concurrently": false, - "method": "btree", - "with": {} - }, - "submissions_user_source_hash_unique": { - "name": "submissions_user_source_hash_unique", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "source_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "submission_hash", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "where": "\"submissions\".\"submission_hash\" is not null and \"submissions\".\"source_id\" is not null", - "concurrently": false, - "method": "btree", - "with": {} } }, "foreignKeys": { @@ -901,7 +792,16 @@ } }, "compositePrimaryKeys": {}, - "uniqueConstraints": {}, + "uniqueConstraints": { + "submissions_user_source_unique": { + "name": "submissions_user_source_unique", + "nullsNotDistinct": true, + "columns": [ + "user_id", + "source_id" + ] + } + }, "policies": {}, "checkConstraints": {}, "isRLSEnabled": false diff --git a/packages/frontend/src/lib/db/migrations/meta/_journal.json b/packages/frontend/src/lib/db/migrations/meta/_journal.json index 3b5b90eff..9868f350c 100644 --- a/packages/frontend/src/lib/db/migrations/meta/_journal.json +++ b/packages/frontend/src/lib/db/migrations/meta/_journal.json @@ -40,8 +40,8 @@ { "idx": 5, "version": "7", - "when": 1775078633014, - "tag": "0005_tan_rumiko_fujikawa", + "when": 1775080710231, + "tag": "0005_conscious_cargill", "breakpoints": true } ] diff --git a/packages/frontend/src/lib/db/schema.ts b/packages/frontend/src/lib/db/schema.ts index 010a4ffa3..c8dc25dcb 100644 --- a/packages/frontend/src/lib/db/schema.ts +++ b/packages/frontend/src/lib/db/schema.ts @@ -12,7 +12,6 @@ import { integer, index, unique, - uniqueIndex, } from "drizzle-orm/pg-core"; import { relations, sql } from "drizzle-orm"; @@ -185,7 +184,6 @@ export const submissions = pgTable( }, (table) => [ index("idx_submissions_user_id").on(table.userId), - index("idx_submissions_user_source").on(table.userId, table.sourceId), index("idx_submissions_status").on(table.status), index("idx_submissions_total_tokens").on(table.totalTokens), index("idx_submissions_created_at").on(table.createdAt), @@ -196,22 +194,9 @@ export const submissions = pgTable( table.totalCost, table.createdAt ), - uniqueIndex("submissions_user_unsourced_unique") - .on(table.userId) - .where(sql`${table.sourceId} is null`), - uniqueIndex("submissions_user_source_unique") + unique("submissions_user_source_unique") .on(table.userId, table.sourceId) - .where(sql`${table.sourceId} is not null`), - uniqueIndex("submissions_user_unsourced_hash_unique") - .on(table.userId, table.submissionHash) - .where( - sql`${table.submissionHash} is not null and ${table.sourceId} is null` - ), - uniqueIndex("submissions_user_source_hash_unique") - .on(table.userId, table.sourceId, table.submissionHash) - .where( - sql`${table.submissionHash} is not null and ${table.sourceId} is not null` - ), + .nullsNotDistinct(), ] ); From ea8520fd8d0ff25342da766970f48ce620acce74 Mon Sep 17 00:00:00 2001 From: IvGolovach Date: Wed, 1 Apr 2026 15:15:30 -0700 Subject: [PATCH 05/25] fix(submit): use portable source-scoped submission constraints (cherry picked from commit 6126e021ef347ffa50ad4a6e7417a1235fc49314) --- .../frontend/__tests__/api/submitAuth.test.ts | 149 ++++++++++++++++-- ...ll.sql => 0005_complete_ted_forrester.sql} | 3 +- .../lib/db/migrations/meta/0005_snapshot.json | 20 ++- .../src/lib/db/migrations/meta/_journal.json | 4 +- packages/frontend/src/lib/db/schema.ts | 7 +- 5 files changed, 167 insertions(+), 16 deletions(-) rename packages/frontend/src/lib/db/migrations/{0005_conscious_cargill.sql => 0005_complete_ted_forrester.sql} (68%) diff --git a/packages/frontend/__tests__/api/submitAuth.test.ts b/packages/frontend/__tests__/api/submitAuth.test.ts index 3761156a8..365b60171 100644 --- a/packages/frontend/__tests__/api/submitAuth.test.ts +++ b/packages/frontend/__tests__/api/submitAuth.test.ts @@ -5,23 +5,48 @@ const mockState = vi.hoisted(() => { const validateSubmission = vi.fn(); const generateSubmissionHash = vi.fn(() => "submission-hash"); const revalidateTag = 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", - tokens: "dailyBreakdown.tokens", 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"); @@ -56,6 +81,13 @@ const mockState = vi.hoisted(() => { validateSubmission, generateSubmissionHash, revalidateTag, + mergeClientBreakdowns, + recalculateDayTotals, + buildModelBreakdown, + clientContributionToBreakdownData, + mergeTimestampMs, + resolveSubmissionScope, + apiTokens, submissions, dailyBreakdown, eq, @@ -68,6 +100,12 @@ const mockState = vi.hoisted(() => { validateSubmission.mockReset(); generateSubmissionHash.mockClear(); revalidateTag.mockClear(); + mergeClientBreakdowns.mockReset(); + recalculateDayTotals.mockReset(); + buildModelBreakdown.mockReset(); + clientContributionToBreakdownData.mockReset(); + mergeTimestampMs.mockReset(); + resolveSubmissionScope.mockReset(); db.transaction.mockReset(); db.select.mockClear(); selectResults.length = 0; @@ -92,6 +130,7 @@ vi.mock("@/lib/auth/personalTokens", () => ({ vi.mock("@/lib/db", () => ({ db: mockState.db, + apiTokens: mockState.apiTokens, submissions: mockState.submissions, dailyBreakdown: mockState.dailyBreakdown, })); @@ -102,11 +141,12 @@ vi.mock("@/lib/validation/submission", () => ({ })); vi.mock("@/lib/db/helpers", () => ({ - mergeClientBreakdowns: vi.fn(), - recalculateDayTotals: vi.fn(), - buildModelBreakdown: vi.fn(), - clientContributionToBreakdownData: vi.fn(), - mergeTimestampMs: vi.fn(), + mergeClientBreakdowns: mockState.mergeClientBreakdowns, + recalculateDayTotals: mockState.recalculateDayTotals, + buildModelBreakdown: mockState.buildModelBreakdown, + clientContributionToBreakdownData: mockState.clientContributionToBreakdownData, + mergeTimestampMs: mockState.mergeTimestampMs, + resolveSubmissionScope: mockState.resolveSubmissionScope, })); vi.mock("drizzle-orm", () => ({ @@ -358,10 +398,42 @@ describe("POST /api/submit auth path", () => { errors: [], warnings: [], }); - mockState.db.transaction.mockResolvedValue({ - submissionId: "submission-1", - isNewSubmission: false, + const mockModelData = { + tokens: 1500, + cost: 1.5, + input: 1000, + output: 500, + cacheRead: 0, + cacheWrite: 0, + reasoning: 0, + 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: 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.pushSelectResult([ { totalTokens: 1500, @@ -372,6 +444,65 @@ describe("POST /api/submit auth path", () => { ]); mockState.pushSelectResult([{ activeDays: 1 }]); mockState.pushSelectResult([{ sourcesUsed: ["claude"] }]); + mockState.db.transaction.mockImplementation(async (callback) => { + const selectResults = [ + [], + [{ id: "submission-1" }], + [], + [ + { + totalTokens: 1500, + totalCost: "1.5000", + inputTokens: 1000, + outputTokens: 500, + dateStart: "2024-12-01", + dateEnd: "2024-12-01", + activeDays: 1, + rowCount: 1, + }, + ], + [{ sourceBreakdown: mockSourceBreakdown }], + ]; + 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), + where: vi.fn(() => builder), + limit: vi.fn(async () => selectResults.shift() ?? []), + for: vi.fn(async () => selectResults.shift() ?? []), + 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", { diff --git a/packages/frontend/src/lib/db/migrations/0005_conscious_cargill.sql b/packages/frontend/src/lib/db/migrations/0005_complete_ted_forrester.sql similarity index 68% rename from packages/frontend/src/lib/db/migrations/0005_conscious_cargill.sql rename to packages/frontend/src/lib/db/migrations/0005_complete_ted_forrester.sql index 74d626de8..4ea9ee147 100644 --- a/packages/frontend/src/lib/db/migrations/0005_conscious_cargill.sql +++ b/packages/frontend/src/lib/db/migrations/0005_complete_ted_forrester.sql @@ -2,4 +2,5 @@ ALTER TABLE "submissions" DROP CONSTRAINT "submissions_user_id_unique";--> state 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 -ALTER TABLE "submissions" ADD CONSTRAINT "submissions_user_source_unique" UNIQUE NULLS NOT DISTINCT("user_id","source_id"); +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"); diff --git a/packages/frontend/src/lib/db/migrations/meta/0005_snapshot.json b/packages/frontend/src/lib/db/migrations/meta/0005_snapshot.json index 22ecef492..9144335eb 100644 --- a/packages/frontend/src/lib/db/migrations/meta/0005_snapshot.json +++ b/packages/frontend/src/lib/db/migrations/meta/0005_snapshot.json @@ -1,5 +1,5 @@ { - "id": "2c0e7540-105f-48f9-9103-41cbd0d2d0a1", + "id": "c6a1cc6e-5949-4fd0-a3ec-ad23e35f48a8", "prevId": "4342d7b5-5562-431f-afd1-0dd773563dff", "version": "7", "dialect": "postgresql", @@ -774,6 +774,22 @@ "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": { @@ -795,7 +811,7 @@ "uniqueConstraints": { "submissions_user_source_unique": { "name": "submissions_user_source_unique", - "nullsNotDistinct": true, + "nullsNotDistinct": false, "columns": [ "user_id", "source_id" diff --git a/packages/frontend/src/lib/db/migrations/meta/_journal.json b/packages/frontend/src/lib/db/migrations/meta/_journal.json index 9868f350c..e6dfb3eff 100644 --- a/packages/frontend/src/lib/db/migrations/meta/_journal.json +++ b/packages/frontend/src/lib/db/migrations/meta/_journal.json @@ -40,8 +40,8 @@ { "idx": 5, "version": "7", - "when": 1775080710231, - "tag": "0005_conscious_cargill", + "when": 1775081591892, + "tag": "0005_complete_ted_forrester", "breakpoints": true } ] diff --git a/packages/frontend/src/lib/db/schema.ts b/packages/frontend/src/lib/db/schema.ts index c8dc25dcb..615c3fb65 100644 --- a/packages/frontend/src/lib/db/schema.ts +++ b/packages/frontend/src/lib/db/schema.ts @@ -12,6 +12,7 @@ import { integer, index, unique, + uniqueIndex, } from "drizzle-orm/pg-core"; import { relations, sql } from "drizzle-orm"; @@ -195,8 +196,10 @@ export const submissions = pgTable( table.createdAt ), unique("submissions_user_source_unique") - .on(table.userId, table.sourceId) - .nullsNotDistinct(), + .on(table.userId, table.sourceId), + uniqueIndex("submissions_user_unsourced_unique") + .on(table.userId) + .where(sql`${table.sourceId} is null`), ] ); From a3394957b6ae52af9cf1edcc50546203deee5f8c Mon Sep 17 00:00:00 2001 From: Junho Yeo Date: Thu, 2 Apr 2026 07:57:12 +0900 Subject: [PATCH 06/25] test(embed): avoid reserved module variable in source-scoped submit tests Constraint: Keep the cherry-picked PR #388 snapshot lint-clean under this repo's frontend ESLint rules Rejected: Leave the original variable name | fails @next/next/no-assign-module-variable in local lint Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep this as a tiny follow-up on top of the original authored commits; do not fold broader changes into the credit-preserving rewrite Tested: packages/frontend/node_modules/.bin/eslint --config packages/frontend/eslint.config.mjs packages/frontend/__tests__/lib/getUserEmbedStats.test.ts Not-tested: Full frontend verification matrix (history rewrite only; behavior unchanged from prior verified branch) --- packages/frontend/__tests__/lib/getUserEmbedStats.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/frontend/__tests__/lib/getUserEmbedStats.test.ts b/packages/frontend/__tests__/lib/getUserEmbedStats.test.ts index d5af814fb..3988faabd 100644 --- a/packages/frontend/__tests__/lib/getUserEmbedStats.test.ts +++ b/packages/frontend/__tests__/lib/getUserEmbedStats.test.ts @@ -89,8 +89,8 @@ type ModuleExports = typeof import("../../src/lib/embed/getUserEmbedStats"); let getUserEmbedStats: ModuleExports["getUserEmbedStats"]; beforeAll(async () => { - const module = await import("../../src/lib/embed/getUserEmbedStats"); - getUserEmbedStats = module.getUserEmbedStats; + const embedStatsLib = await import("../../src/lib/embed/getUserEmbedStats"); + getUserEmbedStats = embedStatsLib.getUserEmbedStats; }); beforeEach(() => { From fb890e6a0b18ba9c3f126ed9de076418e4d0cee0 Mon Sep 17 00:00:00 2001 From: Junho Yeo Date: Thu, 2 Apr 2026 08:01:00 +0900 Subject: [PATCH 07/25] fix(auth): expire stale source-id locks after a hard timeout The source-id lock previously trusted a matching live PID indefinitely. If the PID had been recycled by an unrelated process, an old lock file could block first-time source ID generation until submit fell back to unsourced payloads. Constraint: Source-id initialization should stay resilient without introducing a broader lock format migration Rejected: Trust PID liveness forever | stale lock can survive PID reuse and block initialization Rejected: Remove any lock older than the short stale threshold | risks breaking a legitimately active locker on a slow filesystem Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep a hard age-based escape hatch even when PID probes report alive; PID alone is not a stable lock owner identity Tested: cargo fmt --all --check; cargo clippy -p tokscale-cli --all-features -- -D warnings; cargo test -p tokscale-cli test_should_remove_stale_source_id_lock -- --nocapture Not-tested: Full tokscale-cli test suite rerun after this narrow auth-lock change --- crates/tokscale-cli/src/auth.rs | 56 ++++++++++++++++++++++++++++----- 1 file changed, 49 insertions(+), 7 deletions(-) diff --git a/crates/tokscale-cli/src/auth.rs b/crates/tokscale-cli/src/auth.rs index be8d4d2f1..0fa9245ea 100644 --- a/crates/tokscale-cli/src/auth.rs +++ b/crates/tokscale-cli/src/auth.rs @@ -66,6 +66,7 @@ fn get_source_id_lock_path() -> Result { 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"); @@ -235,6 +236,17 @@ fn lock_owner_is_alive(pid: u32) -> Option { } } +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())?; @@ -296,15 +308,12 @@ fn acquire_source_id_lock() -> Result { 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_dead = match state { - Some(lock_state) => match lock_owner_is_alive(lock_state.pid) { - Some(is_alive) => !is_alive, - None => age >= SOURCE_ID_LOCK_STALE_AFTER, - }, - None => true, + let owner_is_alive = match state { + Some(lock_state) => lock_owner_is_alive(lock_state.pid), + None => None, }; - if owner_is_dead && age >= SOURCE_ID_LOCK_STALE_AFTER { + if should_remove_stale_source_id_lock(age, owner_is_alive) { let _ = remove_source_id_lock_if_matches(&lock_path, state); continue; } @@ -821,6 +830,39 @@ mod tests { } } + #[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() { From b684056b63c722e3f8392b7d7ba7614e3ebe772f Mon Sep 17 00:00:00 2001 From: Junho Yeo Date: Thu, 2 Apr 2026 09:10:05 +0900 Subject: [PATCH 08/25] feat(profile): add source-scoped device views to user profiles Source-scoped submissions make it possible to inspect usage by machine, so this adds a dedicated sources/devices view on profile pages and a matching API that aggregates per-source totals and recent contribution history. Constraint: Must build on the source-scoped submission model without reshaping daily_breakdown.source_breakdown Constraint: Must fit the existing profile page flow with minimal extra round-trips Rejected: Force all source detail into the existing /api/users/[username] payload | keeps the core profile response leaner and separates concerns Rejected: Wait for a separate per-source detail API before shipping UI | unnecessary delay for a useful first device view Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep device/source viewing aligned with source_id as the stable identity and source_name as display-only metadata Tested: bunx vitest run packages/frontend/__tests__/api/userSources.test.ts packages/frontend/__tests__/api/usersProfile.test.ts; packages/frontend/node_modules/.bin/eslint --config packages/frontend/eslint.config.mjs src/app/u/[username]/page.tsx src/app/u/[username]/ProfilePageClient.tsx src/app/api/users/[username]/sources/route.ts src/components/profile/index.tsx __tests__/api/userSources.test.ts Not-tested: Full frontend typecheck still blocked by the pre-existing packages/frontend/src/components/BlackholeHero.tsx asset import typing error --- .../__tests__/api/userSources.test.ts | 271 ++++++++++ .../app/api/users/[username]/sources/route.ts | 509 ++++++++++++++++++ .../app/u/[username]/ProfilePageClient.tsx | 398 +++++++++++--- .../frontend/src/app/u/[username]/page.tsx | 28 +- .../frontend/src/components/profile/index.tsx | 3 +- 5 files changed, 1145 insertions(+), 64 deletions(-) create mode 100644 packages/frontend/__tests__/api/userSources.test.ts create mode 100644 packages/frontend/src/app/api/users/[username]/sources/route.ts diff --git a/packages/frontend/__tests__/api/userSources.test.ts b/packages/frontend/__tests__/api/userSources.test.ts new file mode 100644 index 000000000..35e16a7b2 --- /dev/null +++ b/packages/frontend/__tests__/api/userSources.test.ts @@ -0,0 +1,271 @@ +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"); + + return { + db, + tables, + eq, + and, + gte, + desc, + reset() { + selectResults.length = 0; + db.select.mockClear(); + eq.mockClear(); + and.mockClear(); + gte.mockClear(); + desc.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, +})); + +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", + sourceName: "Work MacBook", + date: "2026-03-01", + timestampMs: 1700000000000, + tokens: 500, + cost: "5.2500", + inputTokens: 300, + outputTokens: 200, + sourceBreakdown: { + claude: { + tokens: 500, + cost: 5.25, + input: 300, + output: 200, + cacheRead: 50, + cacheWrite: 10, + reasoning: 5, + messages: 2, + models: { + "claude-sonnet-4": { + tokens: 500, + cost: 5.25, + input: 300, + output: 200, + cacheRead: 50, + cacheWrite: 10, + reasoning: 5, + messages: 2, + }, + }, + }, + }, + }, + { + sourceId: null, + sourceName: null, + date: "2026-03-01", + timestampMs: 1700000001000, + tokens: 300, + cost: "3.2500", + inputTokens: 200, + outputTokens: 100, + sourceBreakdown: { + cursor: { + tokens: 300, + cost: 3.25, + input: 200, + output: 100, + cacheRead: 0, + cacheWrite: 0, + reasoning: 0, + messages: 1, + modelId: "gpt-4.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", + 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, + 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/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..883fe1488 --- /dev/null +++ b/packages/frontend/src/app/api/users/[username]/sources/route.ts @@ -0,0 +1,509 @@ +import { NextResponse } from "next/server"; +import { and, desc, eq, gte } from "drizzle-orm"; +import { db, dailyBreakdown, submissions, users } from "@/lib/db"; + +export const revalidate = 60; + +interface RouteParams { + params: Promise<{ username: string }>; +} + +type ModelData = { + tokens: number; + cost: number; + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + reasoning: number; + messages: number; +}; + +type ClientBreakdown = { + tokens: number; + cost: number; + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + reasoning: number; + messages: number; + models?: Record; + modelId?: string; +}; + +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< + string, + { + date: string; + timestampMs: number | null; + tokens: number; + cost: number; + inputTokens: number; + outputTokens: number; + clients: Record; + models: Record; + } + >; +}; + +const LEGACY_CLIENT_ALIASES: Record = { kilocode: "kilo" }; + +function normalizeClientId(id: string): string { + return LEGACY_CLIENT_ALIASES[id] ?? id; +} + +function sourceKey(sourceId: string | null): string { + return sourceId ?? "__legacy__"; +} + +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(); +} + +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"; +} + +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(), + }; +} + +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 = + existing ?? + ({ + date: row.date, + timestampMs: row.timestampMs ?? null, + tokens: 0, + cost: 0, + inputTokens: 0, + outputTokens: 0, + clients: {}, + models: {}, + } satisfies SourceSummaryAccumulator["contributions"] extends Map + ? T + : never); + + if (existing) { + if (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); +} + +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); +} + +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, 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(eq(submissions.userId, user.id)) + .orderBy(desc(submissions.updatedAt), desc(submissions.id)), + + db + .select({ + sourceId: submissions.sourceId, + sourceName: submissions.sourceName, + 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( + eq(submissions.userId, user.id), + gte(dailyBreakdown.date, oneYearAgo.toISOString().split("T")[0]) + ) + ) + .orderBy(desc(dailyBreakdown.date)), + ]); + + const bySource = new Map(); + + 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 === toSourceName(row.sourceId, null)) { + 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) { + const key = sourceKey(row.sourceId); + if (!bySource.has(key)) { + bySource.set(key, createAccumulator(row.sourceId, row.sourceName)); + } + mergeSourceContribution(bySource.get(key)!, row); + } + + const sources = Array.from(bySource.values()) + .map((source) => { + 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; + } + + const maxCostPlaceholder = 0; + const intensity = maxCostPlaceholder; // replaced below + + return { + date: day.date, + timestampMs: day.timestampMs, + totals: { + tokens: day.tokens, + cost: day.cost, + messages: 0, + }, + intensity: intensity 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, + }; + }); + + const activeDays = normalizedContributions.filter( + (contribution) => contribution.totals.tokens > 0 + ).length; + + return { + sourceId: 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, + }, + 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, + }; + }) + .sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? "")); + + return NextResponse.json({ + user, + sources, + }); + } catch (error) { + console.error("User sources error:", error); + return NextResponse.json( + { error: "Failed to fetch user sources" }, + { status: 500 } + ); + } +} diff --git a/packages/frontend/src/app/u/[username]/ProfilePageClient.tsx b/packages/frontend/src/app/u/[username]/ProfilePageClient.tsx index 3bc2467c1..a51560f2f 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 { 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,130 @@ interface ProfileData { interface ProfilePageClientProps { initialData: ProfileData; - username: string; + initialSources: SourceData[]; } -export default function ProfilePageClient({ initialData, username }: ProfilePageClientProps) { - const [activeTab, setActiveTab] = useState("activity"); - const data = initialData; +interface SourceData { + sourceId: string | null; + 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[]; + modelUsage?: ModelUsage[]; + contributions: DailyContribution[]; +} - 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, - }); - } +function getSourceKey(sourceId: string | null): string { + return sourceId ?? "__legacy__"; +} + +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, +}: ProfilePageClientProps) { + const [activeTab, setActiveTab] = useState("activity"); + const [selectedSourceKey, setSelectedSourceKey] = useState( + initialSources[0] ? getSourceKey(initialSources[0].sourceId) : null + ); + 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, @@ -139,6 +198,32 @@ export default function ProfilePageClient({ initialData, username }: ProfilePage const EARLY_ADOPTERS = ["code-yeongyu", "gtg7784", "qodot"]; const showResubmitBanner = EARLY_ADOPTERS.includes(data.user.username) && data.stats.submissionCount === 1; + const selectedSource = useMemo(() => { + if (initialSources.length === 0) return null; + return ( + initialSources.find((source) => getSourceKey(source.sourceId) === selectedSourceKey) + ?? initialSources[0] + ); + }, [initialSources, selectedSourceKey]); + + 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 ( @@ -181,6 +266,87 @@ const EARLY_ADOPTERS = ["code-yeongyu", "gtg7784", "qodot"]; )} {activeTab === "breakdown" && } {activeTab === "models" && } + {activeTab === "sources" && ( + initialSources.length > 0 ? ( + + + {initialSources.map((source) => { + const isSelected = selectedSource + ? getSourceKey(source.sourceId) === getSourceKey(selectedSource.sourceId) + : false; + + return ( + setSelectedSourceKey(getSourceKey(source.sourceId))} + 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 + + + ); + })} + + + {selectedSource && ( + + + {selectedSource.sourceName} + + {selectedSource.sourceId ?? "legacy"} Β·{" "} + {selectedSource.updatedAt + ? `Updated ${new Date(selectedSource.updatedAt).toLocaleString()}` + : "No updates yet"} + + + + + {selectedSource.clients.map((client) => ( + {client} + ))} + {selectedSource.models.slice(0, 8).map((model) => ( + {model} + ))} + + + {selectedSourceGraphData ? ( + + + + current.cost > max.cost ? current : max, + selectedSource.modelUsage[0])?.model + } + /> + + + + ) : ( + + )} + + )} + + ) : + )} @@ -267,3 +433,113 @@ 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; +`; diff --git a/packages/frontend/src/app/u/[username]/page.tsx b/packages/frontend/src/app/u/[username]/page.tsx index 3d45f4ab8..544ac9eb7 100644 --- a/packages/frontend/src/app/u/[username]/page.tsx +++ b/packages/frontend/src/app/u/[username]/page.tsx @@ -22,6 +22,22 @@ async function getProfileData(username: string) { return res.json(); } +async function getSourceData(username: string) { + 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(); +} + export async function generateMetadata({ params }: { params: Promise<{ username: string }> }): Promise { const { username } = await params; return { @@ -52,11 +68,19 @@ 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), + getSourceData(username), + ]); if (!data) { notFound(); } - return ; + return ( + + ); } diff --git a/packages/frontend/src/components/profile/index.tsx b/packages/frontend/src/components/profile/index.tsx index 6f6d6a8e5..878b69f12 100644 --- a/packages/frontend/src/components/profile/index.tsx +++ b/packages/frontend/src/components/profile/index.tsx @@ -534,7 +534,7 @@ function EmbedIcon() { ); } -export type ProfileTab = "activity" | "breakdown" | "models"; +export type ProfileTab = "activity" | "breakdown" | "models" | "sources"; export interface ProfileTabBarProps { activeTab: ProfileTab; @@ -619,6 +619,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) => { From 3032e383ded0ea6ada7f674bc6b722dde7fc894d Mon Sep 17 00:00:00 2001 From: Junho Yeo Date: Thu, 2 Apr 2026 09:28:52 +0900 Subject: [PATCH 09/25] feat(profile): split source summaries from source detail views The first device-view pass bundled summary and detail payloads into one sources endpoint. This separates the lightweight source list from the heavier per-source detail response so profile pages can show device cards without shipping full contribution histories for every machine up front. Constraint: Must keep source/device views aligned with the source-scoped submission model already on this branch Constraint: Must avoid bloating the profile page payload with every source's full contribution history Rejected: Keep a single /sources endpoint with embedded detail for all sources | unnecessary payload growth and tighter coupling between card list and detail graph Rejected: Drop server-side initial source detail entirely | worse first-load UX for the default selected source Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep /sources for summaries and /sources/[sourceId] for detailed histories; if more device UI is added, build on this separation rather than rejoining the payloads Tested: bunx vitest run packages/frontend/__tests__/api/userSources.test.ts packages/frontend/__tests__/api/userSourceDetail.test.ts packages/frontend/__tests__/api/usersProfile.test.ts; packages/frontend/node_modules/.bin/eslint --config packages/frontend/eslint.config.mjs src/app/u/[username]/page.tsx src/app/u/[username]/ProfilePageClient.tsx src/app/api/users/[username]/sources/route.ts src/app/api/users/[username]/sources/[sourceId]/route.ts src/app/api/users/[username]/sources/shared.ts src/components/profile/index.tsx __tests__/api/userSources.test.ts __tests__/api/userSourceDetail.test.ts Not-tested: Full frontend typecheck remains blocked by the pre-existing packages/frontend/src/components/BlackholeHero.tsx asset import typing error --- .../__tests__/api/userSourceDetail.test.ts | 250 ++++++++++ .../__tests__/api/userSources.test.ts | 60 +-- .../[username]/sources/[sourceId]/route.ts | 242 ++++++++++ .../app/api/users/[username]/sources/route.ts | 427 ++---------------- .../api/users/[username]/sources/shared.ts | 265 +++++++++++ .../app/u/[username]/ProfilePageClient.tsx | 117 ++++- .../frontend/src/app/u/[username]/page.tsx | 27 +- 7 files changed, 924 insertions(+), 464 deletions(-) create mode 100644 packages/frontend/__tests__/api/userSourceDetail.test.ts create mode 100644 packages/frontend/src/app/api/users/[username]/sources/[sourceId]/route.ts create mode 100644 packages/frontend/src/app/api/users/[username]/sources/shared.ts diff --git a/packages/frontend/__tests__/api/userSourceDetail.test.ts b/packages/frontend/__tests__/api/userSourceDetail.test.ts new file mode 100644 index 000000000..44858b06e --- /dev/null +++ b/packages/frontend/__tests__/api/userSourceDetail.test.ts @@ -0,0 +1,250 @@ +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: "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"); + }); +}); diff --git a/packages/frontend/__tests__/api/userSources.test.ts b/packages/frontend/__tests__/api/userSources.test.ts index 35e16a7b2..58b0894d2 100644 --- a/packages/frontend/__tests__/api/userSources.test.ts +++ b/packages/frontend/__tests__/api/userSources.test.ts @@ -52,6 +52,7 @@ const mockState = vi.hoisted(() => { 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()), }; @@ -64,6 +65,7 @@ const mockState = vi.hoisted(() => { const and = vi.fn(() => "and"); const gte = vi.fn(() => "gte"); const desc = vi.fn(() => "desc"); + const sql = vi.fn(() => "sql"); return { db, @@ -72,6 +74,7 @@ const mockState = vi.hoisted(() => { and, gte, desc, + sql, reset() { selectResults.length = 0; db.select.mockClear(); @@ -79,6 +82,7 @@ const mockState = vi.hoisted(() => { and.mockClear(); gte.mockClear(); desc.mockClear(); + sql.mockClear(); }, pushSelectResult(rows: Array>) { selectResults.push(rows); @@ -98,6 +102,7 @@ vi.mock("drizzle-orm", () => ({ and: mockState.and, gte: mockState.gte, desc: mockState.desc, + sql: mockState.sql, })); type ModuleExports = typeof import("../../src/app/api/users/[username]/sources/route"); @@ -164,60 +169,11 @@ describe("GET /api/users/[username]/sources", () => { mockState.pushSelectResult([ { sourceId: "machine-a", - sourceName: "Work MacBook", - date: "2026-03-01", - timestampMs: 1700000000000, - tokens: 500, - cost: "5.2500", - inputTokens: 300, - outputTokens: 200, - sourceBreakdown: { - claude: { - tokens: 500, - cost: 5.25, - input: 300, - output: 200, - cacheRead: 50, - cacheWrite: 10, - reasoning: 5, - messages: 2, - models: { - "claude-sonnet-4": { - tokens: 500, - cost: 5.25, - input: 300, - output: 200, - cacheRead: 50, - cacheWrite: 10, - reasoning: 5, - messages: 2, - }, - }, - }, - }, + activeDays: 1, }, { sourceId: null, - sourceName: null, - date: "2026-03-01", - timestampMs: 1700000001000, - tokens: 300, - cost: "3.2500", - inputTokens: 200, - outputTokens: 100, - sourceBreakdown: { - cursor: { - tokens: 300, - cost: 3.25, - input: 200, - output: 100, - cacheRead: 0, - cacheWrite: 0, - reasoning: 0, - messages: 1, - modelId: "gpt-4.1", - }, - }, + activeDays: 1, }, ]); @@ -232,6 +188,7 @@ describe("GET /api/users/[username]/sources", () => { expect(body.sources[0]).toMatchObject({ sourceId: "machine-a", + sourceKey: "machine-a", sourceName: "Work MacBook", stats: { totalTokens: 1000, @@ -245,6 +202,7 @@ describe("GET /api/users/[username]/sources", () => { expect(body.sources[1]).toMatchObject({ sourceId: null, + sourceKey: "__legacy__", sourceName: "Legacy / Unknown device", stats: { totalTokens: 300, 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..99794c855 --- /dev/null +++ b/packages/frontend/src/app/api/users/[username]/sources/[sourceId]/route.ts @@ -0,0 +1,242 @@ +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, + 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) + .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(client === "kilocode" ? "kilo" : 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) { + 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/route.ts b/packages/frontend/src/app/api/users/[username]/sources/route.ts index 883fe1488..97b3229f4 100644 --- a/packages/frontend/src/app/api/users/[username]/sources/route.ts +++ b/packages/frontend/src/app/api/users/[username]/sources/route.ts @@ -1,6 +1,12 @@ +import { and, desc, eq, gte, sql } from "drizzle-orm"; import { NextResponse } from "next/server"; -import { and, desc, eq, gte } from "drizzle-orm"; import { db, dailyBreakdown, submissions, users } from "@/lib/db"; +import { + createAccumulator, + normalizeClientId, + sourceKey, + toIsoString, +} from "./shared"; export const revalidate = 60; @@ -8,270 +14,6 @@ interface RouteParams { params: Promise<{ username: string }>; } -type ModelData = { - tokens: number; - cost: number; - input: number; - output: number; - cacheRead: number; - cacheWrite: number; - reasoning: number; - messages: number; -}; - -type ClientBreakdown = { - tokens: number; - cost: number; - input: number; - output: number; - cacheRead: number; - cacheWrite: number; - reasoning: number; - messages: number; - models?: Record; - modelId?: string; -}; - -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< - string, - { - date: string; - timestampMs: number | null; - tokens: number; - cost: number; - inputTokens: number; - outputTokens: number; - clients: Record; - models: Record; - } - >; -}; - -const LEGACY_CLIENT_ALIASES: Record = { kilocode: "kilo" }; - -function normalizeClientId(id: string): string { - return LEGACY_CLIENT_ALIASES[id] ?? id; -} - -function sourceKey(sourceId: string | null): string { - return sourceId ?? "__legacy__"; -} - -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(); -} - -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"; -} - -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(), - }; -} - -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 = - existing ?? - ({ - date: row.date, - timestampMs: row.timestampMs ?? null, - tokens: 0, - cost: 0, - inputTokens: 0, - outputTokens: 0, - clients: {}, - models: {}, - } satisfies SourceSummaryAccumulator["contributions"] extends Map - ? T - : never); - - if (existing) { - if (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); -} - -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); -} - export async function GET(_request: Request, { params }: RouteParams) { try { const { username } = await params; @@ -294,7 +36,7 @@ export async function GET(_request: Request, { params }: RouteParams) { const oneYearAgo = new Date(); oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1); - const [submissionRows, dailyRows] = await Promise.all([ + const [submissionRows, activeDayRows] = await Promise.all([ db .select({ id: submissions.id, @@ -317,18 +59,10 @@ export async function GET(_request: Request, { params }: RouteParams) { .from(submissions) .where(eq(submissions.userId, user.id)) .orderBy(desc(submissions.updatedAt), desc(submissions.id)), - db .select({ sourceId: submissions.sourceId, - sourceName: submissions.sourceName, - date: dailyBreakdown.date, - timestampMs: dailyBreakdown.timestampMs, - tokens: dailyBreakdown.tokens, - cost: dailyBreakdown.cost, - inputTokens: dailyBreakdown.inputTokens, - outputTokens: dailyBreakdown.outputTokens, - sourceBreakdown: dailyBreakdown.sourceBreakdown, + activeDays: sql`COUNT(DISTINCT CASE WHEN ${dailyBreakdown.tokens} > 0 THEN ${dailyBreakdown.date} END)::int`, }) .from(dailyBreakdown) .innerJoin(submissions, eq(dailyBreakdown.submissionId, submissions.id)) @@ -338,10 +72,13 @@ export async function GET(_request: Request, { params }: RouteParams) { gte(dailyBreakdown.date, oneYearAgo.toISOString().split("T")[0]) ) ) - .orderBy(desc(dailyBreakdown.date)), + .groupBy(submissions.sourceId), ]); - const bySource = new Map(); + 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); @@ -372,127 +109,43 @@ export async function GET(_request: Request, { params }: RouteParams) { source.dateEnd = row.dateEnd; } - if (row.sourceName?.trim() && source.sourceName === toSourceName(row.sourceId, null)) { + 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) { - const key = sourceKey(row.sourceId); - if (!bySource.has(key)) { - bySource.set(key, createAccumulator(row.sourceId, row.sourceName)); - } - mergeSourceContribution(bySource.get(key)!, row); - } - const sources = Array.from(bySource.values()) - .map((source) => { - 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; - } - - const maxCostPlaceholder = 0; - const intensity = maxCostPlaceholder; // replaced below - - return { - date: day.date, - timestampMs: day.timestampMs, - totals: { - tokens: day.tokens, - cost: day.cost, - messages: 0, - }, - intensity: intensity 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, - }; - }); - - const activeDays = normalizedContributions.filter( - (contribution) => contribution.totals.tokens > 0 - ).length; - - return { - sourceId: 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, - }, - 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, - }; - }) + .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({ @@ -500,7 +153,7 @@ export async function GET(_request: Request, { params }: RouteParams) { sources, }); } catch (error) { - console.error("User sources error:", 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..75c0e73f2 --- /dev/null +++ b/packages/frontend/src/app/api/users/[username]/sources/shared.ts @@ -0,0 +1,265 @@ +export const LEGACY_SOURCE_PARAM = "__legacy__"; + +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 function sourceKey(sourceId: string | null): string { + return sourceId ?? LEGACY_SOURCE_PARAM; +} + +export function decodeSourceParam(sourceIdOrLegacy: string): string | null { + return sourceIdOrLegacy === LEGACY_SOURCE_PARAM ? null : decodeURIComponent(sourceIdOrLegacy); +} + +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 a51560f2f..ff8e52d11 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 { useMemo, useState } 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"; @@ -52,11 +52,14 @@ interface ProfileData { interface ProfilePageClientProps { initialData: ProfileData; - initialSources: SourceData[]; + initialSources: SourceSummaryData[]; + initialSelectedSource: SourceDetailData | null; + username: string; } -interface SourceData { +interface SourceSummaryData { sourceId: string | null; + sourceKey: string; sourceName: string; stats: { totalTokens: number; @@ -76,12 +79,11 @@ interface SourceData { updatedAt: string | null; clients: string[]; models: string[]; - modelUsage?: ModelUsage[]; - contributions: DailyContribution[]; } -function getSourceKey(sourceId: string | null): string { - return sourceId ?? "__legacy__"; +interface SourceDetailData extends SourceSummaryData { + modelUsage?: ModelUsage[]; + contributions: DailyContribution[]; } function buildGraphData( @@ -154,10 +156,18 @@ function buildGraphData( export default function ProfilePageClient({ initialData, initialSources, + initialSelectedSource, + username, }: ProfilePageClientProps) { const [activeTab, setActiveTab] = useState("activity"); const [selectedSourceKey, setSelectedSourceKey] = useState( - initialSources[0] ? getSourceKey(initialSources[0].sourceId) : null + initialSources[0]?.sourceKey ?? null + ); + const [loadingSourceKey, setLoadingSourceKey] = useState( + initialSelectedSource ? null : (initialSources[0]?.sourceKey ?? null) + ); + const [sourceDetailCache, setSourceDetailCache] = useState>( + initialSelectedSource ? { [initialSelectedSource.sourceKey]: initialSelectedSource } : {} ); const data = initialData; @@ -195,17 +205,59 @@ export default function ProfilePageClient({ 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 selectedSource = useMemo(() => { + const selectedSourceSummary = useMemo(() => { if (initialSources.length === 0) return null; return ( - initialSources.find((source) => getSourceKey(source.sourceId) === selectedSourceKey) + initialSources.find((source) => source.sourceKey === selectedSourceKey) ?? initialSources[0] ); }, [initialSources, selectedSourceKey]); + const selectedSource = useMemo( + () => (selectedSourceKey ? sourceDetailCache[selectedSourceKey] ?? null : null), + [selectedSourceKey, sourceDetailCache] + ); + + 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, + })); + }) + .catch((error) => { + console.error(error); + }) + .finally(() => { + if (!cancelled) { + setLoadingSourceKey((current) => + current === selectedSourceKey ? null : current + ); + } + }); + + return () => { + cancelled = true; + }; + }, [selectedSourceKey, sourceDetailCache, username]); + const selectedSourceGraphData = useMemo( () => selectedSource @@ -271,15 +323,18 @@ const EARLY_ADOPTERS = ["code-yeongyu", "gtg7784", "qodot"]; {initialSources.map((source) => { - const isSelected = selectedSource - ? getSourceKey(source.sourceId) === getSourceKey(selectedSource.sourceId) - : false; + const isSelected = source.sourceKey === selectedSourceKey; return ( setSelectedSourceKey(getSourceKey(source.sourceId))} + onClick={() => { + setSelectedSourceKey(source.sourceKey); + setLoadingSourceKey( + sourceDetailCache[source.sourceKey] ? null : source.sourceKey + ); + }} type="button" > @@ -301,28 +356,32 @@ const EARLY_ADOPTERS = ["code-yeongyu", "gtg7784", "qodot"]; })} - {selectedSource && ( + {selectedSourceSummary && ( - {selectedSource.sourceName} + {selectedSourceSummary.sourceName} - {selectedSource.sourceId ?? "legacy"} Β·{" "} - {selectedSource.updatedAt - ? `Updated ${new Date(selectedSource.updatedAt).toLocaleString()}` + {selectedSourceSummary.sourceId ?? "legacy"} Β·{" "} + {selectedSourceSummary.updatedAt + ? `Updated ${new Date(selectedSourceSummary.updatedAt).toLocaleString()}` : "No updates yet"} - {selectedSource.clients.map((client) => ( + {selectedSourceSummary.clients.map((client) => ( {client} ))} - {selectedSource.models.slice(0, 8).map((model) => ( + {selectedSourceSummary.models.slice(0, 8).map((model) => ( {model} ))} - {selectedSourceGraphData ? ( + {loadingSourceKey === selectedSourceKey && !selectedSource ? ( + + Loading source details… + + ) : selectedSourceGraphData && selectedSource ? ( }): Promise { const { username } = await params; return { @@ -70,17 +86,24 @@ export default async function ProfilePage({ params }: { params: Promise<{ userna const { username } = await params; const [data, sourceData] = await Promise.all([ getProfileData(username), - getSourceData(username), + getSourceSummaries(username), ]); if (!data) { notFound(); } + + const initialSourceKey = sourceData?.sources?.[0]?.sourceKey; + const sourceDetailData = initialSourceKey + ? await getSourceDetail(username, initialSourceKey) + : { source: null }; return ( ); } From 74cf2b785a0f3d40eb587eac0b9b82d6b0727a9e Mon Sep 17 00:00:00 2001 From: Junho Yeo Date: Thu, 2 Apr 2026 09:31:08 +0900 Subject: [PATCH 10/25] feat(profile): add lightweight source summary endpoint The device/source view now has lightweight summary and detail endpoints, but external consumers still need a compact per-source payload for cards, embeds, badges, or quick previews. This adds a dedicated summary route so clients can fetch one source's headline metrics without pulling the full contribution history. Constraint: Must build on the split source summary/detail API shape already on this branch Constraint: Must stay lightweight and avoid returning the full per-day history payload Rejected: Reuse the full source detail endpoint for summary consumers | unnecessary payload size for badge/embed/preview use cases Rejected: Add source summary fields only to the top-level user profile API | couples a focused source capability back into a broader profile response Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep source summary routes compact; if more source-level consumers appear, expand this route before bloating the detail response Tested: bunx vitest run packages/frontend/__tests__/api/userSources.test.ts packages/frontend/__tests__/api/userSourceDetail.test.ts packages/frontend/__tests__/api/userSourceSummary.test.ts packages/frontend/__tests__/api/usersProfile.test.ts; packages/frontend/node_modules/.bin/eslint --config packages/frontend/eslint.config.mjs src/app/api/users/[username]/sources/route.ts src/app/api/users/[username]/sources/[sourceId]/route.ts src/app/api/users/[username]/sources/[sourceId]/summary/route.ts src/app/api/users/[username]/sources/shared.ts __tests__/api/userSources.test.ts __tests__/api/userSourceDetail.test.ts __tests__/api/userSourceSummary.test.ts Not-tested: Full frontend typecheck remains blocked by the pre-existing packages/frontend/src/components/BlackholeHero.tsx asset import typing error --- .../__tests__/api/userSourceSummary.test.ts | 220 ++++++++++++++++++ .../sources/[sourceId]/summary/route.ts | 183 +++++++++++++++ 2 files changed, 403 insertions(+) create mode 100644 packages/frontend/__tests__/api/userSourceSummary.test.ts create mode 100644 packages/frontend/src/app/api/users/[username]/sources/[sourceId]/summary/route.ts diff --git a/packages/frontend/__tests__/api/userSourceSummary.test.ts b/packages/frontend/__tests__/api/userSourceSummary.test.ts new file mode 100644 index 000000000..a3525e612 --- /dev/null +++ b/packages/frontend/__tests__/api/userSourceSummary.test.ts @@ -0,0 +1,220 @@ +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: "machine-a", + sourceName: "Work MacBook", + totalTokens: 1000, + totalCost: 10.5, + submissionCount: 2, + activeDays: 1, + topClient: "claude", + topModel: "claude-sonnet-4", + }); + }); + + 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/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..f4cd50b11 --- /dev/null +++ b/packages/frontend/src/app/api/users/[username]/sources/[sourceId]/summary/route.ts @@ -0,0 +1,183 @@ +import { and, eq, gte, isNull } from "drizzle-orm"; +import { NextResponse } from "next/server"; +import { db, dailyBreakdown, submissions, users } from "@/lib/db"; +import { + createAccumulator, + decodeSourceParam, + 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; + + 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])[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])[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) { + console.error("User source summary error:", error); + return NextResponse.json( + { error: "Failed to fetch user source summary" }, + { status: 500 } + ); + } +} From 56e060f8ebebbea0823d4507f4bdfbf93b1d757d Mon Sep 17 00:00:00 2001 From: Junho Yeo Date: Thu, 2 Apr 2026 09:33:22 +0900 Subject: [PATCH 11/25] feat(profile): show selected device preview from the summary endpoint The branch already exposed a lightweight source summary route, but the profile UI still consumed only the summary list and full detail payloads. This wires the selected device panel to fetch and display a compact preview from `/sources/[sourceId]/summary`, so the new endpoint is used for actual UI affordances rather than existing only for future consumers. Constraint: Must reuse the lightweight source summary endpoint instead of duplicating top-client/top-model derivation in the client Constraint: Must preserve the existing default selected-device UX Rejected: Continue showing only the full detail panel | leaves the new summary endpoint unused by the profile UI Rejected: Move summary-only fields back into the source list payload | defeats the endpoint separation introduced earlier Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep quick-preview metadata sourced from the summary endpoint so summary/detail responsibilities stay distinct Tested: bunx vitest run packages/frontend/__tests__/api/userSourceSummary.test.ts packages/frontend/__tests__/api/userSources.test.ts packages/frontend/__tests__/api/userSourceDetail.test.ts packages/frontend/__tests__/api/usersProfile.test.ts; packages/frontend/node_modules/.bin/eslint --config packages/frontend/eslint.config.mjs src/app/u/[username]/page.tsx src/app/u/[username]/ProfilePageClient.tsx src/app/api/users/[username]/sources/[sourceId]/summary/route.ts __tests__/api/userSourceSummary.test.ts Not-tested: Full frontend typecheck remains blocked by the pre-existing packages/frontend/src/components/BlackholeHero.tsx asset import typing error --- .../app/u/[username]/ProfilePageClient.tsx | 151 ++++++++++++++++++ .../frontend/src/app/u/[username]/page.tsx | 29 +++- 2 files changed, 177 insertions(+), 3 deletions(-) diff --git a/packages/frontend/src/app/u/[username]/ProfilePageClient.tsx b/packages/frontend/src/app/u/[username]/ProfilePageClient.tsx index ff8e52d11..edab35ad7 100644 --- a/packages/frontend/src/app/u/[username]/ProfilePageClient.tsx +++ b/packages/frontend/src/app/u/[username]/ProfilePageClient.tsx @@ -54,6 +54,7 @@ interface ProfilePageClientProps { initialData: ProfileData; initialSources: SourceSummaryData[]; initialSelectedSource: SourceDetailData | null; + initialSelectedSourceSummary: SourcePreviewSummary | null; username: string; } @@ -86,6 +87,23 @@ interface SourceDetailData extends SourceSummaryData { 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: { @@ -157,6 +175,7 @@ export default function ProfilePageClient({ initialData, initialSources, initialSelectedSource, + initialSelectedSourceSummary, username, }: ProfilePageClientProps) { const [activeTab, setActiveTab] = useState("activity"); @@ -169,6 +188,11 @@ export default function ProfilePageClient({ const [sourceDetailCache, setSourceDetailCache] = useState>( initialSelectedSource ? { [initialSelectedSource.sourceKey]: initialSelectedSource } : {} ); + const [sourceSummaryCache, setSourceSummaryCache] = useState>( + initialSelectedSourceSummary + ? { [initialSelectedSourceSummary.sourceKey]: initialSelectedSourceSummary } + : {} + ); const data = initialData; const graphData = useMemo( @@ -221,6 +245,11 @@ export default function ProfilePageClient({ [selectedSourceKey, sourceDetailCache] ); + const selectedSourcePreview = useMemo( + () => (selectedSourceKey ? sourceSummaryCache[selectedSourceKey] ?? null : null), + [selectedSourceKey, sourceSummaryCache] + ); + useEffect(() => { if (!selectedSourceKey || sourceDetailCache[selectedSourceKey]) { return; @@ -258,6 +287,36 @@ export default function ProfilePageClient({ }; }, [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 @@ -377,6 +436,45 @@ export default function ProfilePageClient({ ))} + {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} + + + + + )} + {loadingSourceKey === selectedSourceKey && !selectedSource ? ( Loading source details… @@ -603,6 +701,59 @@ const SourceTag = styled.span` 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); diff --git a/packages/frontend/src/app/u/[username]/page.tsx b/packages/frontend/src/app/u/[username]/page.tsx index 4b71ca620..c92e3a23f 100644 --- a/packages/frontend/src/app/u/[username]/page.tsx +++ b/packages/frontend/src/app/u/[username]/page.tsx @@ -54,6 +54,25 @@ async function getSourceDetail(username: string, sourceKey: string) { return res.json(); } +async function getSourceSummary(username: string, sourceKey: string) { + 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(); +} + export async function generateMetadata({ params }: { params: Promise<{ username: string }> }): Promise { const { username } = await params; return { @@ -94,15 +113,19 @@ export default async function ProfilePage({ params }: { params: Promise<{ userna } const initialSourceKey = sourceData?.sources?.[0]?.sourceKey; - const sourceDetailData = initialSourceKey - ? await getSourceDetail(username, initialSourceKey) - : { source: null }; + const [sourceDetailData, sourceSummaryData] = initialSourceKey + ? await Promise.all([ + getSourceDetail(username, initialSourceKey), + getSourceSummary(username, initialSourceKey), + ]) + : [{ source: null }, { source: null }]; return ( ); From 13d13e8d1e107bd9756f4542dd4265877322258f Mon Sep 17 00:00:00 2001 From: Junho Yeo Date: Thu, 2 Apr 2026 11:08:09 +0900 Subject: [PATCH 12/25] fix(sources): avoid legacy sentinel collisions in source keys The source routes used `"__legacy__"` as both the unsourced sentinel and a possible user-provided `sourceId`, which made a real `sourceId="__legacy__"` collide with legacy rows. This namespaces real source keys with a prefix and routes the detail API through the shared client alias normalizer. Constraint: Must preserve support for legacy unsourced rows while keeping source URLs stable enough for the new profile UI Rejected: Keep `__legacy__` as a raw dual-purpose key | real source rows can collide with the legacy sentinel and become ambiguous Rejected: Introduce a random/non-deterministic surrogate per source key | makes routing and hydration harder for the profile UI Confidence: high Scope-risk: narrow Reversibility: clean Directive: Treat route-facing source keys as encoded transport values, not raw source IDs; all real IDs should remain namespaced away from the legacy sentinel Tested: bunx vitest run packages/frontend/__tests__/api/userSources.test.ts packages/frontend/__tests__/api/userSourceDetail.test.ts packages/frontend/__tests__/api/userSourceSummary.test.ts packages/frontend/__tests__/api/usersProfile.test.ts; packages/frontend/node_modules/.bin/eslint --config packages/frontend/eslint.config.mjs src/app/api/users/[username]/sources/shared.ts src/app/api/users/[username]/sources/[sourceId]/route.ts __tests__/api/userSources.test.ts __tests__/api/userSourceDetail.test.ts __tests__/api/userSourceSummary.test.ts Not-tested: Full frontend typecheck remains blocked by the pre-existing packages/frontend/src/components/BlackholeHero.tsx asset import typing error --- .../__tests__/api/userSourceDetail.test.ts | 45 ++++++++++++++++++- .../__tests__/api/userSourceSummary.test.ts | 2 +- .../__tests__/api/userSources.test.ts | 2 +- .../[username]/sources/[sourceId]/route.ts | 3 +- .../api/users/[username]/sources/shared.ts | 15 ++++++- 5 files changed, 61 insertions(+), 6 deletions(-) diff --git a/packages/frontend/__tests__/api/userSourceDetail.test.ts b/packages/frontend/__tests__/api/userSourceDetail.test.ts index 44858b06e..588837c23 100644 --- a/packages/frontend/__tests__/api/userSourceDetail.test.ts +++ b/packages/frontend/__tests__/api/userSourceDetail.test.ts @@ -191,7 +191,7 @@ describe("GET /api/users/[username]/sources/[sourceId]", () => { expect(response.status).toBe(200); expect(body.source).toMatchObject({ sourceId: "machine-a", - sourceKey: "machine-a", + sourceKey: "source:machine-a", sourceName: "Work MacBook", stats: { totalTokens: 1000, @@ -247,4 +247,47 @@ describe("GET /api/users/[username]/sources/[sourceId]", () => { 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 index a3525e612..d064397a4 100644 --- a/packages/frontend/__tests__/api/userSourceSummary.test.ts +++ b/packages/frontend/__tests__/api/userSourceSummary.test.ts @@ -186,7 +186,7 @@ describe("GET /api/users/[username]/sources/[sourceId]/summary", () => { expect(response.status).toBe(200); expect(body.source).toMatchObject({ sourceId: "machine-a", - sourceKey: "machine-a", + sourceKey: "source:machine-a", sourceName: "Work MacBook", totalTokens: 1000, totalCost: 10.5, diff --git a/packages/frontend/__tests__/api/userSources.test.ts b/packages/frontend/__tests__/api/userSources.test.ts index 58b0894d2..ac97a3276 100644 --- a/packages/frontend/__tests__/api/userSources.test.ts +++ b/packages/frontend/__tests__/api/userSources.test.ts @@ -188,7 +188,7 @@ describe("GET /api/users/[username]/sources", () => { expect(body.sources[0]).toMatchObject({ sourceId: "machine-a", - sourceKey: "machine-a", + sourceKey: "source:machine-a", sourceName: "Work MacBook", stats: { totalTokens: 1000, 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 index 99794c855..093f0d44c 100644 --- a/packages/frontend/src/app/api/users/[username]/sources/[sourceId]/route.ts +++ b/packages/frontend/src/app/api/users/[username]/sources/[sourceId]/route.ts @@ -6,6 +6,7 @@ import { createAccumulator, decodeSourceParam, mergeSourceContribution, + normalizeClientId, sourceKey, toIsoString, } from "../shared"; @@ -125,7 +126,7 @@ export async function GET(_request: Request, { params }: RouteParams) { } for (const client of row.sourcesUsed || []) { - source.clients.add(client === "kilocode" ? "kilo" : client); + source.clients.add(normalizeClientId(client)); } for (const model of row.modelsUsed || []) { diff --git a/packages/frontend/src/app/api/users/[username]/sources/shared.ts b/packages/frontend/src/app/api/users/[username]/sources/shared.ts index 75c0e73f2..a90fec728 100644 --- a/packages/frontend/src/app/api/users/[username]/sources/shared.ts +++ b/packages/frontend/src/app/api/users/[username]/sources/shared.ts @@ -1,4 +1,5 @@ export const LEGACY_SOURCE_PARAM = "__legacy__"; +const SOURCE_KEY_PREFIX = "source:"; export type ModelData = { tokens: number; @@ -61,11 +62,21 @@ export function normalizeClientId(id: string): string { } export function sourceKey(sourceId: string | null): string { - return sourceId ?? LEGACY_SOURCE_PARAM; + return sourceId == null + ? LEGACY_SOURCE_PARAM + : `${SOURCE_KEY_PREFIX}${encodeURIComponent(sourceId)}`; } export function decodeSourceParam(sourceIdOrLegacy: string): string | null { - return sourceIdOrLegacy === LEGACY_SOURCE_PARAM ? null : decodeURIComponent(sourceIdOrLegacy); + if (sourceIdOrLegacy === LEGACY_SOURCE_PARAM) { + return null; + } + + if (!sourceIdOrLegacy.startsWith(SOURCE_KEY_PREFIX)) { + return decodeURIComponent(sourceIdOrLegacy); + } + + return decodeURIComponent(sourceIdOrLegacy.slice(SOURCE_KEY_PREFIX.length)); } export function toIsoString(value: Date | string | null | undefined): string | null { From 6e8273ed6a0a60028bac054e998b6574a287a276 Mon Sep 17 00:00:00 2001 From: Junho Yeo Date: Mon, 13 Apr 2026 00:03:11 +0900 Subject: [PATCH 13/25] fix(frontend): catch fetch throws in profile source data fetching Wrap getSourceSummaries, getSourceDetail, and getSourceSummary in try/catch so a network-level fetch failure (DNS, connection refused) degrades to empty data instead of rejecting Promise.all and crashing the entire profile page render. --- .../frontend/src/app/u/[username]/page.tsx | 72 +++++++++++-------- 1 file changed, 42 insertions(+), 30 deletions(-) diff --git a/packages/frontend/src/app/u/[username]/page.tsx b/packages/frontend/src/app/u/[username]/page.tsx index c92e3a23f..ecec7a29d 100644 --- a/packages/frontend/src/app/u/[username]/page.tsx +++ b/packages/frontend/src/app/u/[username]/page.tsx @@ -23,54 +23,66 @@ async function getProfileData(username: string) { } async function getSourceSummaries(username: string) { - const baseUrl = process.env.NEXT_PUBLIC_URL - || (process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : null) - || 'http://127.0.0.1:3000'; + 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 }, - }); + const res = await fetch(`${baseUrl}/api/users/${username}/sources`, { + next: { revalidate: 60 }, + }); - if (!res.ok) { + if (!res.ok) { + return { sources: [] }; + } + + return res.json(); + } catch { return { sources: [] }; } - - return res.json(); } async function getSourceDetail(username: string, sourceKey: string) { - const baseUrl = process.env.NEXT_PUBLIC_URL - || (process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : null) - || 'http://127.0.0.1:3000'; + 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 }, - }); + const res = await fetch(`${baseUrl}/api/users/${username}/sources/${encodeURIComponent(sourceKey)}`, { + next: { revalidate: 60 }, + }); - if (!res.ok) { + if (!res.ok) { + return { source: null }; + } + + return res.json(); + } catch { return { source: null }; } - - return res.json(); } async function getSourceSummary(username: string, sourceKey: string) { - 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 }, + 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 }; } - ); - if (!res.ok) { + return res.json(); + } catch { return { source: null }; } - - return res.json(); } export async function generateMetadata({ params }: { params: Promise<{ username: string }> }): Promise { From 9bdf61272314de9f0cbc34a3b76562cce6513c8e Mon Sep 17 00:00:00 2001 From: Junho Yeo Date: Mon, 20 Apr 2026 22:12:44 +0900 Subject: [PATCH 14/25] fix(submit): harden source-scoped multi-machine submit path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review feedback on PR #389 before rollout: - H1: add .for("update") to the onConflictDoNothing fallback SELECT so a second transaction racing the insert re-reads the conflicted row under the row lock instead of between releases. - H2: replace brittle string-match 409 signaling with a SourceIdentityRequiredError subclass + instanceof check so middleware wrapping the error cannot silently turn 409 into 500. - H4: wrap decodeURIComponent in safeDecodeURIComponent (throwing InvalidSourceParamError) so crawler probes of /api/users/:u/sources/source:%ZZ return 400 instead of 500. - M3: move loadUserSubmitMetrics inside the submit transaction so the returned metrics match exactly what was written and we don't re-run three SELECTs on every submit. - M1: treat mtime.elapsed() failure as a stale lock (not 0 age) so a malformed lock file with clock-skewed mtime recycles immediately. - M2: replace English-only "No tasks are running" substring match with locale-agnostic CSV parse of tasklist output. - M4: comment explaining why migration 0005 must run as one transaction (no CONCURRENTLY β€” window between old and new uniqueness). - M5: update generateSubmissionHash docstring to mark it informational only now that the db uniqueness was dropped. - M6: remove placeholder "concurrent submissions" test that only asserted Set.size; leave a comment pointing at the real coverage. - Rollout: include an upgrade hint in the 409 response body so CLI users without source identity see actionable text. Also add the server-side rename endpoint the user asked for: - PATCH /api/settings/sources/[sourceId] { name: string | null } Session-authenticated, scope-checked by (user_id, source_id), rejects control characters, invalidates the user's profile cache. null / empty clears the custom label and falls back to the default at render time. - 9 vitest cases cover 401 / 400 / 404 / happy-path / legacy sentinel. Constraint: Cannot break existing CLI clients mid-rollout β€” old CLI submits without meta.sourceId still hit the unsourced scope and succeed. Rejected: Drop submission_hash column in this PR | keep the column and re-label as informational; a column-drop migration is a separate change. Rejected: Auth via Bearer API token | rename is a profile-affecting action and belongs to the web session, matching /api/settings/tokens. Confidence: high Scope-risk: moderate Directive: SourceIdentityRequiredError and InvalidSourceParamError must stay exported β€” the tests depend on instanceof checks against them. Not-tested: Real concurrent insert race on the fallback SELECT path (requires integration tests with an actual Postgres fixture). Not-tested: Windows tasklist CSV parse in non-English Windows (unit test only covers the structural parse). --- crates/tokscale-cli/src/auth.rs | 26 ++- .../api/settingsSourcesRename.test.ts | 186 ++++++++++++++++++ .../frontend/__tests__/api/submit.test.ts | 14 +- .../frontend/__tests__/api/submitAuth.test.ts | 43 ++-- .../api/settings/sources/[sourceId]/route.ts | 119 +++++++++++ packages/frontend/src/app/api/submit/route.ts | 44 +++-- .../[username]/sources/[sourceId]/route.ts | 7 + .../sources/[sourceId]/summary/route.ts | 7 + .../api/users/[username]/sources/shared.ts | 19 +- .../0005_complete_ted_forrester.sql | 6 + .../frontend/src/lib/validation/submission.ts | 16 +- 11 files changed, 431 insertions(+), 56 deletions(-) create mode 100644 packages/frontend/__tests__/api/settingsSourcesRename.test.ts create mode 100644 packages/frontend/src/app/api/settings/sources/[sourceId]/route.ts diff --git a/crates/tokscale-cli/src/auth.rs b/crates/tokscale-cli/src/auth.rs index 0fa9245ea..b414782f3 100644 --- a/crates/tokscale-cli/src/auth.rs +++ b/crates/tokscale-cli/src/auth.rs @@ -197,11 +197,13 @@ fn lock_age(path: &Path, state: Option) -> Duration { return Duration::from_millis(age_ms.min(u64::MAX as u128) as u64); } - fs::metadata(path) - .and_then(|metadata| metadata.modified()) - .ok() - .and_then(|modified| modified.elapsed().ok()) - .unwrap_or_default() + // 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, + } } fn lock_owner_is_alive(pid: u32) -> Option { @@ -216,14 +218,24 @@ fn lock_owner_is_alive(pid: u32) -> Option { #[cfg(windows)] { + // Locale-agnostic parse: tasklist /FO CSV /NH emits one row per + // matching process with the PID in the second CSV column. "No tasks + // are running" is localized text and cannot be string-matched safely, + // so we check the structured output instead. let output = std::process::Command::new("tasklist") - .args(["/FI", &format!("PID eq {}", pid)]) + .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(stdout.contains(&pid.to_string()) && !stdout.contains("No tasks are running")) + let pid_str = pid.to_string(); + let matched = stdout.lines().any(|line| { + line.split(',').nth(1).is_some_and(|col| { + col.trim().trim_matches('"') == pid_str + }) + }); + Some(matched) } Ok(_) => None, Err(_) => None, 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 a7c8024f3..8076b798a 100644 --- a/packages/frontend/__tests__/api/submit.test.ts +++ b/packages/frontend/__tests__/api/submit.test.ts @@ -439,16 +439,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 365b60171..4c92a306f 100644 --- a/packages/frontend/__tests__/api/submitAuth.test.ts +++ b/packages/frontend/__tests__/api/submitAuth.test.ts @@ -159,10 +159,12 @@ vi.mock("drizzle-orm", () => ({ 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(() => { @@ -314,9 +316,7 @@ describe("POST /api/submit auth path", () => { errors: [], warnings: [], }); - mockState.db.transaction.mockRejectedValue( - new Error("Source identity is required for accounts with source-scoped submissions") - ); + mockState.db.transaction.mockRejectedValue(new SourceIdentityRequiredError()); const response = await POST( new Request("http://localhost:3000/api/submit", { @@ -332,6 +332,7 @@ describe("POST /api/submit auth path", () => { 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.", }); }); @@ -434,21 +435,11 @@ describe("POST /api/submit auth path", () => { mockState.mergeTimestampMs.mockReturnValue(null); mockState.resolveSubmissionScope.mockReturnValue({ kind: "create" }); - mockState.pushSelectResult([ - { - totalTokens: 1500, - totalCost: "1.5000", - dateStart: "2024-12-01", - dateEnd: "2024-12-01", - }, - ]); - mockState.pushSelectResult([{ activeDays: 1 }]); - mockState.pushSelectResult([{ sourcesUsed: ["claude"] }]); mockState.db.transaction.mockImplementation(async (callback) => { const selectResults = [ - [], - [{ id: "submission-1" }], - [], + [], // 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, @@ -460,8 +451,18 @@ describe("POST /api/submit auth path", () => { activeDays: 1, rowCount: 1, }, - ], - [{ sourceBreakdown: mockSourceBreakdown }], + ], // 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(() => { @@ -475,9 +476,13 @@ describe("POST /api/submit auth path", () => { 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(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() ?? []), }; 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 52c8d8378..661ff1798 100644 --- a/packages/frontend/src/app/api/submit/route.ts +++ b/packages/frontend/src/app/api/submit/route.ts @@ -18,9 +18,19 @@ import { type ClientBreakdownData, } from "@/lib/db/helpers"; -const SOURCE_IDENTITY_REQUIRED_ERROR = +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; @@ -55,10 +65,12 @@ function normalizeOptionalString(value: string | undefined): string | null { return trimmed === "" ? null : trimmed; } -async function loadUserSubmitMetrics(userId: string) { +type TxClient = Parameters[0]>[0]; + +async function loadUserSubmitMetrics(tx: TxClient, userId: string) { const [userAggregatesRows, userDayAggregatesRows, userSubmissionsRows] = await Promise.all([ - db + tx .select({ totalTokens: sql`COALESCE(SUM(${submissions.totalTokens}), 0)::bigint`, totalCost: sql`COALESCE(SUM(CAST(${submissions.totalCost} AS DECIMAL(12,4))), 0)::text`, @@ -67,14 +79,14 @@ async function loadUserSubmitMetrics(userId: string) { }) .from(submissions) .where(eq(submissions.userId, userId)), - db + 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)), - db + tx .select({ sourcesUsed: submissions.sourcesUsed, }) @@ -229,7 +241,7 @@ export async function POST(request: Request) { ); if (scopeResolution.kind === "rejectMissingSourceIdentity") { - throw new Error(SOURCE_IDENTITY_REQUIRED_ERROR); + throw new SourceIdentityRequiredError(); } if (scopeResolution.kind === "existing") { @@ -278,9 +290,14 @@ export async function POST(request: Request) { 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"); } @@ -520,13 +537,16 @@ export async function POST(request: Request) { .set(submissionUpdate) .where(eq(submissions.id, submissionId)); + const metrics = await loadUserSubmitMetrics(tx, tokenRecord.userId); + return { submissionId, isNewSubmission, + metrics, }; }); - const metrics = await loadUserSubmitMetrics(tokenRecord.userId); + const { metrics } = result; try { revalidateTag("leaderboard", "max"); @@ -546,12 +566,12 @@ export async function POST(request: Request) { warnings: validation.warnings.length > 0 ? validation.warnings : undefined, }); } catch (error) { - if ( - error instanceof Error && - error.message === SOURCE_IDENTITY_REQUIRED_ERROR - ) { + if (error instanceof SourceIdentityRequiredError) { return NextResponse.json( - { error: SOURCE_IDENTITY_REQUIRED_ERROR }, + { + error: SOURCE_IDENTITY_REQUIRED_MESSAGE, + hint: SOURCE_IDENTITY_REQUIRED_HINT, + }, { status: 409 } ); } 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 index 093f0d44c..a983a8765 100644 --- a/packages/frontend/src/app/api/users/[username]/sources/[sourceId]/route.ts +++ b/packages/frontend/src/app/api/users/[username]/sources/[sourceId]/route.ts @@ -5,6 +5,7 @@ import { aggregateModelUsage, createAccumulator, decodeSourceParam, + InvalidSourceParamError, mergeSourceContribution, normalizeClientId, sourceKey, @@ -234,6 +235,12 @@ export async function GET(_request: Request, { params }: RouteParams) { }, }); } 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" }, 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 index f4cd50b11..d68ced335 100644 --- 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 @@ -4,6 +4,7 @@ import { db, dailyBreakdown, submissions, users } from "@/lib/db"; import { createAccumulator, decodeSourceParam, + InvalidSourceParamError, mergeSourceContribution, sourceKey, toIsoString, @@ -174,6 +175,12 @@ export async function GET(_request: Request, { params }: RouteParams) { }, }); } 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" }, diff --git a/packages/frontend/src/app/api/users/[username]/sources/shared.ts b/packages/frontend/src/app/api/users/[username]/sources/shared.ts index a90fec728..b8b866155 100644 --- a/packages/frontend/src/app/api/users/[username]/sources/shared.ts +++ b/packages/frontend/src/app/api/users/[username]/sources/shared.ts @@ -61,22 +61,37 @@ 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 decodeURIComponent(sourceIdOrLegacy); + return safeDecodeURIComponent(sourceIdOrLegacy); } - return decodeURIComponent(sourceIdOrLegacy.slice(SOURCE_KEY_PREFIX.length)); + return safeDecodeURIComponent(sourceIdOrLegacy.slice(SOURCE_KEY_PREFIX.length)); } export function toIsoString(value: Date | string | null | undefined): string | null { diff --git a/packages/frontend/src/lib/db/migrations/0005_complete_ted_forrester.sql b/packages/frontend/src/lib/db/migrations/0005_complete_ted_forrester.sql index 4ea9ee147..2e957b606 100644 --- a/packages/frontend/src/lib/db/migrations/0005_complete_ted_forrester.sql +++ b/packages/frontend/src/lib/db/migrations/0005_complete_ted_forrester.sql @@ -1,3 +1,9 @@ +-- 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 diff --git a/packages/frontend/src/lib/validation/submission.ts b/packages/frontend/src/lib/validation/submission.ts index c97f2afa4..453bb40ad 100644 --- a/packages/frontend/src/lib/validation/submission.ts +++ b/packages/frontend/src/lib/validation/submission.ts @@ -338,12 +338,16 @@ 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" + * Generate an informational fingerprint of a submission payload. + * + * The hash is stored on submissions.submissionHash for diagnostic purposes + * only β€” the database uniqueness constraint that previously consumed it was + * dropped in migration 0005 when the schema moved to source-scoped rows. + * Callers must NOT rely on collisions being rejected; duplicates across + * users or sources are allowed and expected. + * + * Encodes "what clients and dates are being submitted," not totals + * (which change on merge and would defeat the fingerprint). */ export function generateSubmissionHash(data: SubmissionData): string { // Sort contributions by date to ensure deterministic hash From 310cd58420508f5623367b13d0c91312d6ec3b5d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 20 Apr 2026 13:18:41 +0000 Subject: [PATCH 15/25] style: auto-fix lint issues [skip ci] --- crates/tokscale-cli/src/auth.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/tokscale-cli/src/auth.rs b/crates/tokscale-cli/src/auth.rs index b414782f3..e960b8bc5 100644 --- a/crates/tokscale-cli/src/auth.rs +++ b/crates/tokscale-cli/src/auth.rs @@ -201,7 +201,9 @@ fn lock_age(path: &Path, state: Option) -> Duration { // 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), + Ok(modified) => modified + .elapsed() + .unwrap_or(SOURCE_ID_LOCK_FORCE_STALE_AFTER), Err(_) => SOURCE_ID_LOCK_FORCE_STALE_AFTER, } } @@ -231,9 +233,9 @@ fn lock_owner_is_alive(pid: u32) -> Option { let stdout = String::from_utf8_lossy(&output.stdout); let pid_str = pid.to_string(); let matched = stdout.lines().any(|line| { - line.split(',').nth(1).is_some_and(|col| { - col.trim().trim_matches('"') == pid_str - }) + line.split(',') + .nth(1) + .is_some_and(|col| col.trim().trim_matches('"') == pid_str) }); Some(matched) } From 4dd80a517017f6a107c128a61b6ceb86944ba4f7 Mon Sep 17 00:00:00 2001 From: Junho Yeo Date: Mon, 20 Apr 2026 23:04:01 +0900 Subject: [PATCH 16/25] chore(db): drop unused submission_hash column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The column was originally a dedup key backing the submissions_user_hash_unique constraint. Migration 0005 already drops that constraint because source-scoped submissions can legitimately share a client/date fingerprint across machines, and nothing in the codebase reads submission_hash β€” there is no SELECT, no JOIN, no ORDER BY, and no implicit use in the onConflictDoNothing target on the submit path. It was paying djb2 compute per submit and storing dead bytes. Changes: - Append ALTER TABLE submissions DROP COLUMN submission_hash to migration 0005 so the reshape lands atomically with the schema changes that removed the constraint, keeping this PR's DB touches to a single migration file. - Remove submissionHash from the drizzle schema, drop the generateSubmissionHash generator from validation/submission.ts, and strip the two writes in api/submit/route.ts along with the now-dead hashData payload reshaping. - Update submitAuth.test.ts to drop the generateSubmissionHash mock. Constraint: Migration 0005 already reshapes this table in one transaction; bundling the DROP COLUMN into the same file keeps the rollback cost equivalent to what it already was β€” rolling back after source-scoped rows exist is not free either way. Rejected: Keep the column as a "debugging fingerprint" | no read site exists today, the CLI does not send or log the hash, and an index to make queries by it fast would just add more dead weight. If a future forensic use case appears, re-add with an index at that point. Rejected: Split into a separate follow-up PR | the PR author asked to bundle; application code and SQL stay consistent that way and a reviewer sees the constraint drop and the column drop together. Confidence: high Scope-risk: narrow Directive: Do NOT re-export generateSubmissionHash β€” the helper is gone and tests now assume that module surface. Not-tested: Post-deploy rollback path (same caveat as the rest of migration 0005). --- .../frontend/__tests__/api/submitAuth.test.ts | 4 -- packages/frontend/src/app/api/submit/route.ts | 10 ----- .../0005_complete_ted_forrester.sql | 7 +++- .../lib/db/migrations/meta/0005_snapshot.json | 6 --- packages/frontend/src/lib/db/schema.ts | 1 - .../frontend/src/lib/validation/submission.ts | 40 ------------------- 6 files changed, 6 insertions(+), 62 deletions(-) diff --git a/packages/frontend/__tests__/api/submitAuth.test.ts b/packages/frontend/__tests__/api/submitAuth.test.ts index 4c92a306f..c1df47b3d 100644 --- a/packages/frontend/__tests__/api/submitAuth.test.ts +++ b/packages/frontend/__tests__/api/submitAuth.test.ts @@ -3,7 +3,6 @@ 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 mergeClientBreakdowns = vi.fn(); const recalculateDayTotals = vi.fn(); @@ -79,7 +78,6 @@ const mockState = vi.hoisted(() => { return { authenticatePersonalToken, validateSubmission, - generateSubmissionHash, revalidateTag, mergeClientBreakdowns, recalculateDayTotals, @@ -98,7 +96,6 @@ const mockState = vi.hoisted(() => { reset() { authenticatePersonalToken.mockReset(); validateSubmission.mockReset(); - generateSubmissionHash.mockClear(); revalidateTag.mockClear(); mergeClientBreakdowns.mockReset(); recalculateDayTotals.mockReset(); @@ -137,7 +134,6 @@ vi.mock("@/lib/db", () => ({ vi.mock("@/lib/validation/submission", () => ({ validateSubmission: mockState.validateSubmission, - generateSubmissionHash: mockState.generateSubmissionHash, })); vi.mock("@/lib/db/helpers", () => ({ diff --git a/packages/frontend/src/app/api/submit/route.ts b/packages/frontend/src/app/api/submit/route.ts index 661ff1798..505112248 100644 --- a/packages/frontend/src/app/api/submit/route.ts +++ b/packages/frontend/src/app/api/submit/route.ts @@ -4,7 +4,6 @@ import { db, apiTokens, submissions, dailyBreakdown } from "@/lib/db"; import { and, eq, isNull, sql } from "drizzle-orm"; import { validateSubmission, - generateSubmissionHash, type SubmissionData, } from "@/lib/validation/submission"; import { authenticatePersonalToken } from "@/lib/auth/personalTokens"; @@ -198,13 +197,6 @@ 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) @@ -267,7 +259,6 @@ export async function POST(request: Request) { modelsUsed: [], status: "verified", cliVersion: data.meta.version, - submissionHash: generateSubmissionHash(hashData), }) .onConflictDoNothing() .returning({ id: submissions.id }); @@ -519,7 +510,6 @@ export async function POST(request: Request) { 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(), diff --git a/packages/frontend/src/lib/db/migrations/0005_complete_ted_forrester.sql b/packages/frontend/src/lib/db/migrations/0005_complete_ted_forrester.sql index 2e957b606..868aa7b7c 100644 --- a/packages/frontend/src/lib/db/migrations/0005_complete_ted_forrester.sql +++ b/packages/frontend/src/lib/db/migrations/0005_complete_ted_forrester.sql @@ -9,4 +9,9 @@ ALTER TABLE "submissions" DROP CONSTRAINT "submissions_user_hash_unique";--> sta 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"); +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/meta/0005_snapshot.json b/packages/frontend/src/lib/db/migrations/meta/0005_snapshot.json index 9144335eb..8fb13b0da 100644 --- a/packages/frontend/src/lib/db/migrations/meta/0005_snapshot.json +++ b/packages/frontend/src/lib/db/migrations/meta/0005_snapshot.json @@ -625,12 +625,6 @@ "primaryKey": false, "notNull": false }, - "submission_hash": { - "name": "submission_hash", - "type": "varchar(64)", - "primaryKey": false, - "notNull": false - }, "submit_count": { "name": "submit_count", "type": "integer", diff --git a/packages/frontend/src/lib/db/schema.ts b/packages/frontend/src/lib/db/schema.ts index 615c3fb65..9abe923bb 100644 --- a/packages/frontend/src/lib/db/schema.ts +++ b/packages/frontend/src/lib/db/schema.ts @@ -171,7 +171,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), diff --git a/packages/frontend/src/lib/validation/submission.ts b/packages/frontend/src/lib/validation/submission.ts index 4af79b361..21eb1c094 100644 --- a/packages/frontend/src/lib/validation/submission.ts +++ b/packages/frontend/src/lib/validation/submission.ts @@ -340,43 +340,3 @@ export function validateSubmission(data: unknown): ValidationResult { }; } -/** - * Generate an informational fingerprint of a submission payload. - * - * The hash is stored on submissions.submissionHash for diagnostic purposes - * only β€” the database uniqueness constraint that previously consumed it was - * dropped in migration 0005 when the schema moved to source-scoped rows. - * Callers must NOT rely on collisions being rejected; duplicates across - * users or sources are allowed and expected. - * - * Encodes "what clients and dates are being submitted," not totals - * (which change on merge and would defeat the fingerprint). - */ -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"); -} From 3ddf46e885a68d3e1db88f22ee0dd7b479fb440a Mon Sep 17 00:00:00 2001 From: Junho Yeo Date: Mon, 20 Apr 2026 23:20:58 +0900 Subject: [PATCH 17/25] fix(submit,profile): close remaining review lows + add rollout coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the three residual low-severity findings from the review of PR #389 and hardens the test suite with the one integration scenario that was previously missing. - L3: reject \p{C} control / format / surrogate / private-use / unassigned characters in submit-time sourceId and sourceName. The rename endpoint already enforces this; mismatched validation between the two write paths let a malicious CLI plant strings the rename endpoint could not un-plant without a round trip. Test coverage: five new cases in submit.test.ts (null byte, ANSI escape, ZWJ, RTL override, and ZWJ in sourceId). - L4: tie-break topClient / topModel alphabetically in the /sources/ [sourceId]/summary route. Equal token counts previously returned whichever Object.entries iteration order the JSON serialization produced first, which depends on DB row ordering β€” not stable across requests and caused UI flicker. Test coverage: a dedicated case with deliberately zulu-first insertion to prove the resolver returns "alpha" on ties. - L5: stop swallowing fetch failures in ProfilePageClient. A dropped detail fetch used to leave the Devices tab stuck on the loading spinner forever because the only failure handler was console.error. Add a sourceDetailErrorCache keyed by sourceKey plus a SourceErrorCard rendered in place of the spinner. On a successful retry the error entry is cleared from within the async .then callback, keeping the effect body free of synchronous setState (which would trip react-hooks/set-state-in-effect). - Rollout scenario: add an end-to-end test that drives the real route through a transaction callback with a mocked tx.select returning a source-scoped row and verifies the 409+hint response comes out of the actual SourceIdentityRequiredError throw path β€” not just the outer catch invoked by a manually thrown error. Constraint: react-hooks/set-state-in-effect forbids synchronous setState in effect bodies; error-clear has to live inside the .then callback so it runs post-await. Rejected: A component-level Testing Library test for the ProfilePageClient error card | the repo has no existing React component test harness and adding one is scope creep; visual QA on staging is the faster path to confidence here. Confidence: high Scope-risk: narrow Directive: OptionalSourceMetadataSchema is the single source of truth for sourceId / sourceName validation. Adding a third write path that bypasses it would re-open L3. Not-tested: Right-to-left override glyphs actually rendering in the profile UI (schema now blocks them; no UI-level assertion needed). Not-tested: Real-DB integration test for the rollout 409 path (covered via tx mock, not a live Postgres fixture). --- .../frontend/__tests__/api/submit.test.ts | 121 +++++++++++++++++ .../frontend/__tests__/api/submitAuth.test.ts | 126 ++++++++++++++++++ .../__tests__/api/userSourceSummary.test.ts | 95 +++++++++++++ .../sources/[sourceId]/summary/route.ts | 9 +- .../app/u/[username]/ProfilePageClient.tsx | 40 +++++- .../frontend/src/lib/validation/submission.ts | 16 ++- 6 files changed, 402 insertions(+), 5 deletions(-) diff --git a/packages/frontend/__tests__/api/submit.test.ts b/packages/frontend/__tests__/api/submit.test.ts index dbc71dd37..5892954c3 100644 --- a/packages/frontend/__tests__/api/submit.test.ts +++ b/packages/frontend/__tests__/api/submit.test.ts @@ -243,6 +243,127 @@ describe('POST /api/submit - Client-Level Merge', () => { expect(result.data?.meta.sourceName).toBe("Workstation"); }); + 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", + 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", + 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("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: { diff --git a/packages/frontend/__tests__/api/submitAuth.test.ts b/packages/frontend/__tests__/api/submitAuth.test.ts index c1df47b3d..9b2e0e10c 100644 --- a/packages/frontend/__tests__/api/submitAuth.test.ts +++ b/packages/frontend/__tests__/api/submitAuth.test.ts @@ -332,6 +332,132 @@ describe("POST /api/submit auth path", () => { }); }); + 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", + displayName: "Alice", + avatarUrl: null, + isAdmin: false, + expiresAt: null, + }); + mockState.validateSubmission.mockReturnValue({ + valid: true, + data: { + // Note: NO meta.sourceId β€” simulates old CLI. + meta: { + generatedAt: new Date().toISOString(), + version: "0.9.0", + 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: [], + }); + + // 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", diff --git a/packages/frontend/__tests__/api/userSourceSummary.test.ts b/packages/frontend/__tests__/api/userSourceSummary.test.ts index d064397a4..db0973933 100644 --- a/packages/frontend/__tests__/api/userSourceSummary.test.ts +++ b/packages/frontend/__tests__/api/userSourceSummary.test.ts @@ -197,6 +197,101 @@ describe("GET /api/users/[username]/sources/[sourceId]/summary", () => { }); }); + 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([ { 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 index d68ced335..9348ab4f1 100644 --- 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 @@ -131,6 +131,11 @@ export async function GET(_request: Request, { params }: RouteParams) { (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) => { @@ -141,7 +146,7 @@ export async function GET(_request: Request, { params }: RouteParams) { }, {} ) - ).sort((a, b) => b[1] - a[1])[0]?.[0] ?? null; + ).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>( @@ -153,7 +158,7 @@ export async function GET(_request: Request, { params }: RouteParams) { }, {} ) - ).sort((a, b) => b[1] - a[1])[0]?.[0] ?? null; + ).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0]?.[0] ?? null; return NextResponse.json({ user, diff --git a/packages/frontend/src/app/u/[username]/ProfilePageClient.tsx b/packages/frontend/src/app/u/[username]/ProfilePageClient.tsx index d8002838c..eb5ff3fda 100644 --- a/packages/frontend/src/app/u/[username]/ProfilePageClient.tsx +++ b/packages/frontend/src/app/u/[username]/ProfilePageClient.tsx @@ -193,6 +193,13 @@ export default function ProfilePageClient({ ? { [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( @@ -270,9 +277,23 @@ export default function ProfilePageClient({ ...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) => { + .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) { @@ -502,7 +523,12 @@ export default function ProfilePageClient({ )} - {loadingSourceKey === selectedSourceKey && !selectedSource ? ( + {selectedSourceKey && sourceDetailErrorCache[selectedSourceKey] ? ( + + Couldn’t load this device right now. Please try + again in a moment. + + ) : loadingSourceKey === selectedSourceKey && !selectedSource ? ( Loading source details… @@ -791,3 +817,13 @@ const SourceLoadingCard = styled.div` 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/lib/validation/submission.ts b/packages/frontend/src/lib/validation/submission.ts index 21eb1c094..b638dc1e3 100644 --- a/packages/frontend/src/lib/validation/submission.ts +++ b/packages/frontend/src/lib/validation/submission.ts @@ -84,6 +84,12 @@ 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() === "") { @@ -91,7 +97,15 @@ const OptionalSourceMetadataSchema = z.preprocess( } return value; }, - z.string().trim().min(1).max(255).optional() + 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({ From f70ac6538593f5f236aaf7c64a1b3066c5081013 Mon Sep 17 00:00:00 2001 From: Junho Yeo Date: Mon, 20 Apr 2026 23:32:50 +0900 Subject: [PATCH 18/25] test(cli): expand auth.rs coverage for source-id lock + hostname paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The multi-machine submit work in this PR rides on a pile of new helpers in auth.rs (lock serialization, stale takeover, per-device source-id persistence, hostname fallback) that previously only had happy-path coverage of their two public entry points. Fill in the internal helpers so regressions surface at the unit level rather than in production: - Lock state codec: serialize_source_id_lock_state format assertion, round-trip, whitespace tolerance, unknown-key ignore, partial/ malformed content rejection. - lock_age: future created_at_ms clamps to zero; past delta is in the expected window; absent metadata returns FORCE_STALE. - read_source_id_lock_state / remove_source_id_lock_if_matches: file missing, state mismatch (no delete), exact match (delete), missing path (false). - read_source_id: whitespace trim, empty-file β†’ None, missing-file β†’ None. - write_source_id: trailing newline, atomic overwrite, temp-file cleanup after rename. - get_device_name: format prefix + non-empty host component. - should_remove_stale_source_id_lock: dead-owner-but-fresh-age and unknown-probe-but-fresh-age branches (both should NOT remove). - acquire_source_id_lock: happy path (create + Drop cleanup) and stale-takeover past FORCE_STALE threshold. - get_source_id_path / get_source_id_lock_path: HOME-scoped paths. - get_submit_source_id: env override skips disk; whitespace env falls through and generates+persists. - get_submit_source_name: default fallback to get_device_name; empty env treated as unset. - current_unix_ms: sanity range check. Net effect on tokscale-cli tests: 355 β†’ 383 passing (28 new cases). tarpaulin workspace coverage climbs from 41.18% to 50.52% (+9.34pp). Constraint: Tests must not collide on env mutation β€” all HOME/env tests carry #[serial]. Rejected: Integration tests covering logout / whoami / open_browser | these hit the network, the terminal, or the OS browser; out of scope for a unit test suite and risky to run in CI. Confidence: high Scope-risk: narrow Directive: If a helper in auth.rs gains a new branch, add a unit case here β€” the file is now the coverage anchor for the CLI crate. Not-tested: Real concurrent lock contention across processes (only single-process stale takeover is exercised). --- crates/tokscale-cli/src/auth.rs | 394 ++++++++++++++++++++++++++++++++ 1 file changed, 394 insertions(+) diff --git a/crates/tokscale-cli/src/auth.rs b/crates/tokscale-cli/src/auth.rs index e960b8bc5..2d7136204 100644 --- a/crates/tokscale-cli/src/auth.rs +++ b/crates/tokscale-cli/src/auth.rs @@ -1059,4 +1059,398 @@ mod tests { env::remove_var("HOME"); } } + + // ===================================================================== + // 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_returns_none_on_malformed_line() { + // A line without '=' short-circuits via the `?` on split_once. + assert!(parse_source_id_lock_state("garbage\npid=1\ncreated_at_ms=2\n").is_none()); + } + + // ===================================================================== + // 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); + } + + // ===================================================================== + // 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); + } } From 11a4c8936d88eb914047cf21bbb42272944c2746 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 20 Apr 2026 14:33:39 +0000 Subject: [PATCH 19/25] style: auto-fix lint issues [skip ci] --- crates/tokscale-cli/src/auth.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/tokscale-cli/src/auth.rs b/crates/tokscale-cli/src/auth.rs index 2d7136204..60c294a6d 100644 --- a/crates/tokscale-cli/src/auth.rs +++ b/crates/tokscale-cli/src/auth.rs @@ -1332,7 +1332,7 @@ mod tests { // 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. + 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(); @@ -1451,6 +1451,10 @@ mod tests { 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); + assert!( + now > 1_735_689_600_000, + "clock reported unexpected time: {}", + now + ); } } From 8b942f05a1d4ec15e645cdd56156f81bf4883a07 Mon Sep 17 00:00:00 2001 From: Junho Yeo Date: Tue, 21 Apr 2026 00:37:53 +0900 Subject: [PATCH 20/25] test(core): bring session parser coverage up to near-100% MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Low-coverage parsers made the workspace coverage number look worse than it deserved. Adding parser-level unit tests for kilo, droid, and synthetic closes most of the gap and is low-risk: the tests drive real code paths with on-disk fixtures (temp SQLite DB, settings.json, sibling .jsonl), not mocks. Files touched and new coverage vs. previous tarpaulin run: - sessions/kilo.rs 0/61 β†’ 56/61 (0% β†’ 91.8%) - sessions/droid.rs 35/103 β†’ 101/103 (34% β†’ 98.1%) - sessions/synthetic.rs 35/105 β†’ 104/105 (33% β†’ 99.0%) Test additions: - kilo.rs β€” 10 new cases built on an in-memory SQLite fixture: happy path with full provider/token/agent fields; missing-file β†’ empty; user-role filter via the parser's SQL WHERE; skip rows missing modelID; fallback_timestamp used when time is absent; negative tokens and cost clamp to zero; session_id defaults to "unknown"; agent field wins over mode fallback; provider inferred from model via provider_identity; defaults to "kilo" when inference fails; malformed-json row skipped without taking the batch down. - droid.rs β€” 11 new cases covering the settings.json loader: full happy path (providerLockTimestamp β†’ millis); missing file; malformed JSON; missing tokenUsage; all-zero tokens; model-less payload falls back to get_default_model_from_provider; model-less payload extracts from sibling .jsonl system-reminder; provider inferred when providerLock absent; negative tokens clamp and timestamp falls back to file mtime; normalize_model_name duplicate hyphen collapse; extract_model_from_jsonl found vs. pattern absent. - synthetic.rs β€” 10 new cases: is_synthetic_gateway combined check; normalize strips "hf:" without slash; "accounts/…" without "/models/" passthrough; normalize_synthetic_gateway_fields returns false for non-gateway; empty-provider gets rewritten to "synthetic"; matches_synthetic_filter matches by client name alone. Plus five parse_octofriend_sqlite SQLite fixture tests: empty-when-no-known- tables, messages table happy path, zero-token row skip, seconds- timestamp β†’ ms conversion, token_usage fallback table parsed when messages absent. Workspace totals: tarpaulin moves from 41.18% β†’ 51.94% lines covered (+10.76pp absolute; was +9.34pp from auth.rs alone). tokscale-core lib tests 497 β†’ 553 passing. No production code touched. Constraint: parser tests must not depend on the real ~/.factory or ~/.local/share paths β€” all fixtures go through tempfile::tempdir so test runs are hermetic and parallel-safe. Rejected: Mock rusqlite at the trait level | real SQLite fixtures are cheap, catch schema drift, and match the established pattern in sessions/opencode.rs. Confidence: high Scope-risk: narrow Directive: If a session parser gets a new column/branch, extend the corresponding test module in this file β€” the parser test layout is now the repository convention for this kind of change. Not-tested: Real filesystem races against a live Droid/Kilo client (scope creep; covered indirectly by integration runs). --- crates/tokscale-core/src/sessions/droid.rs | 214 +++++++++++ crates/tokscale-core/src/sessions/kilo.rs | 354 ++++++++++++++++++ .../tokscale-core/src/sessions/synthetic.rs | 244 ++++++++++++ 3 files changed, 812 insertions(+) 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 e594af389..4d977e748 100644 --- a/crates/tokscale-core/src/sessions/kilo.rs +++ b/crates/tokscale-core/src/sessions/kilo.rs @@ -152,6 +152,7 @@ pub fn parse_kilo_sqlite_with_fallback( #[cfg(test)] mod tests { use super::*; + use rusqlite::Connection; #[test] fn test_parse_kilo_message_structure() { @@ -176,4 +177,357 @@ mod tests { assert_eq!(msg.cost, Some(0.15)); 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_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_defaults_session_id_to_unknown() { + 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, "unknown"); + } + + #[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); + } } diff --git a/crates/tokscale-core/src/sessions/synthetic.rs b/crates/tokscale-core/src/sessions/synthetic.rs index f02e838a0..81bb9f06f 100644 --- a/crates/tokscale-core/src/sessions/synthetic.rs +++ b/crates/tokscale-core/src/sessions/synthetic.rs @@ -362,4 +362,248 @@ 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")); + } } From 4b9ac5d5cea5cb37b6b765a44ec1907903e3c5f7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 20 Apr 2026 15:39:00 +0000 Subject: [PATCH 21/25] style: auto-fix lint issues [skip ci] --- crates/tokscale-core/src/sessions/synthetic.rs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/crates/tokscale-core/src/sessions/synthetic.rs b/crates/tokscale-core/src/sessions/synthetic.rs index 81bb9f06f..ab58630b6 100644 --- a/crates/tokscale-core/src/sessions/synthetic.rs +++ b/crates/tokscale-core/src/sessions/synthetic.rs @@ -422,7 +422,8 @@ mod tests { 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(); + conn.execute_batch("CREATE TABLE other (x INTEGER);") + .unwrap(); drop(conn); assert!(parse_octofriend_sqlite(&db).is_empty()); @@ -510,7 +511,17 @@ mod tests { 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", + "zero", + "hf:x/y", + 0, + 0, + 0, + 0, + 0, + 0.0, + 1_700_000_000.0_f64, + "sess-1", + "synthetic", ], ) .unwrap(); From 17e09d3f6311e0bc75912111a1374db5a21f813b Mon Sep 17 00:00:00 2001 From: Junho Yeo Date: Tue, 21 Apr 2026 00:53:18 +0900 Subject: [PATCH 22/25] fix(review): preserve renamed sourceName; harden Windows PID probe + lock parse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the three unresolved review threads on PR #389: 1. devin-ai-integration (πŸ”΄ real bug): CLI submits were clobbering user renames. Every `tokscale submit` sent `sourceName = "CLI on "` and the route updated submissions.sourceName unconditionally, so a PATCH /api/settings/sources/:sourceId rename only survived until the next submit. Restrict sourceName (and sourceId) writes in the update branch to the upgradeLegacyRow case only β€” i.e., the first time a legacy unsourced row is being promoted to source-scoped. Existing source-scoped rows keep whatever sourceName is already in the DB. Two new vitest cases in submitAuth.test.ts lock this down: - preserves a user-renamed sourceName on subsequent merges - still writes sourceId+sourceName when upgrading a legacy row 2. cubic-dev-ai (P2): Windows PID probe split tasklist CSV output by ',' and read column 1 as the PID, which misreads any process whose image name legitimately contains a comma (CSV quotes the field but the naive split does not honor quoting). `tasklist /FI "PID eq N"` is already server-side filtered β€” zero rows on no match, one row on match β€” so we only need to detect whether any non-empty CSV row came back. No parsing of the PID column is required. 3. devin-ai-integration (🟑): parse_source_id_lock_state's `line.split_once('=')?` aborted the whole parse on the first line without an `=` (stray blank line, trailing whitespace, future metadata key). Skip unrecognized lines with `else { continue }`; a valid pid/created_at_ms pair still produces Some(state). Updated existing test to assert the new forgiving behavior via a fixture that mixes garbage, blank, and valid lines. Constraint: Write-path for sourceName has to stay split across insert / upgrade / update branches β€” the insert path already stamps sourceName on the fresh row, the upgrade path must stamp it for the first time on a former legacy row, and the plain update path must NEVER touch it (per user's rename intent). Rejected: Add a source_name_custom boolean | adds a column + a write-path branch for no extra invariant over the "only write on insert/upgrade" rule. Rejected: CSV-aware parse of tasklist output | the server-side PID filter gives us the presence check for free; parsing adds failure surface without buying anything. Confidence: high Scope-risk: narrow Directive: Do NOT re-add `submissionUpdate.sourceName = sourceName` outside the upgradeLegacyRow guard β€” the rename preservation contract depends on it and is covered by the submitAuth tests. Not-tested: Windows tasklist CSV with a commaed image name (no CI runner reproduces it; fix is pure simplification so there's nothing new to misparse). --- crates/tokscale-cli/src/auth.rs | 38 ++- .../frontend/__tests__/api/submitAuth.test.ts | 286 ++++++++++++++++++ packages/frontend/src/app/api/submit/route.ts | 19 +- 3 files changed, 324 insertions(+), 19 deletions(-) diff --git a/crates/tokscale-cli/src/auth.rs b/crates/tokscale-cli/src/auth.rs index 60c294a6d..2d0a4a43d 100644 --- a/crates/tokscale-cli/src/auth.rs +++ b/crates/tokscale-cli/src/auth.rs @@ -171,7 +171,13 @@ fn parse_source_id_lock_state(content: &str) -> Option { let mut created_at_ms = None; for line in content.lines() { - let (key, value) = line.split_once('=')?; + // 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(), @@ -220,10 +226,12 @@ fn lock_owner_is_alive(pid: u32) -> Option { #[cfg(windows)] { - // Locale-agnostic parse: tasklist /FO CSV /NH emits one row per - // matching process with the PID in the second CSV column. "No tasks - // are running" is localized text and cannot be string-matched safely, - // so we check the structured output instead. + // Locale-agnostic: `tasklist /FI "PID eq N" /FO CSV /NH` already + // filters server-side on PID. The filter emits exactly 0 rows when + // no process matches, and 1 row when one does. We only need to + // detect whether any non-empty CSV row came back β€” we do NOT try + // to parse the PID back out, because process names can legitimately + // contain commas and a naive split(',') would misread the column. let output = std::process::Command::new("tasklist") .args(["/FI", &format!("PID eq {}", pid), "/FO", "CSV", "/NH"]) .output(); @@ -231,12 +239,7 @@ fn lock_owner_is_alive(pid: u32) -> Option { match output { Ok(output) if output.status.success() => { let stdout = String::from_utf8_lossy(&output.stdout); - let pid_str = pid.to_string(); - let matched = stdout.lines().any(|line| { - line.split(',') - .nth(1) - .is_some_and(|col| col.trim().trim_matches('"') == pid_str) - }); + let matched = stdout.lines().any(|line| !line.trim().is_empty()); Some(matched) } Ok(_) => None, @@ -1104,9 +1107,16 @@ mod tests { } #[test] - fn test_parse_source_id_lock_state_returns_none_on_malformed_line() { - // A line without '=' short-circuits via the `?` on split_once. - assert!(parse_source_id_lock_state("garbage\npid=1\ncreated_at_ms=2\n").is_none()); + 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); } // ===================================================================== diff --git a/packages/frontend/__tests__/api/submitAuth.test.ts b/packages/frontend/__tests__/api/submitAuth.test.ts index 9b2e0e10c..fd3fb1bbb 100644 --- a/packages/frontend/__tests__/api/submitAuth.test.ts +++ b/packages/frontend/__tests__/api/submitAuth.test.ts @@ -656,4 +656,290 @@ describe("POST /api/submit auth path", () => { 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: [], + }); + + // 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 = { + 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", { + 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.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", { + method: "POST", + headers: { + Authorization: "Bearer tt_valid", + "Content-Type": "application/json", + }, + body: JSON.stringify({ meta: {}, contributions: [] }), + }) + ); + + expect(response.status).toBe(200); + 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/src/app/api/submit/route.ts b/packages/frontend/src/app/api/submit/route.ts index 505112248..2e2829bc8 100644 --- a/packages/frontend/src/app/api/submit/route.ts +++ b/packages/frontend/src/app/api/submit/route.ts @@ -515,11 +515,20 @@ export async function POST(request: Request) { updatedAt: new Date(), }; - if (sourceName !== null) { - submissionUpdate.sourceName = sourceName; - } - if (sourceId !== null && upgradeLegacyRow) { - submissionUpdate.sourceId = sourceId; + // 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 From 3406677a169d4974e50a3efacea39dd89b41dc5e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 20 Apr 2026 15:54:14 +0000 Subject: [PATCH 23/25] style: auto-fix lint issues [skip ci] --- crates/tokscale-cli/src/auth.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/crates/tokscale-cli/src/auth.rs b/crates/tokscale-cli/src/auth.rs index 2d0a4a43d..57411ebc1 100644 --- a/crates/tokscale-cli/src/auth.rs +++ b/crates/tokscale-cli/src/auth.rs @@ -1111,10 +1111,9 @@ mod tests { // 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(); + 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); } From adb159ab06ea002283153a2bb330153daffe2cec Mon Sep 17 00:00:00 2001 From: Junho Yeo Date: Tue, 21 Apr 2026 01:30:03 +0900 Subject: [PATCH 24/25] fix(cli): tasklist INFO banner over-matched the Windows PID probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prior "any non-empty line is a match" simplification I pushed in response to the cubic CSV-parse concern regressed into a different bug: `tasklist /FI "PID eq N" /FO CSV /NH` still writes a localized INFO banner to stdout when no PID matches β€” e.g. English emits INFO: No tasks are running which match the specified criteria. That line is non-empty, so the probe would report every dead PID as alive on Windows, and the stale-lock cleanup would never fire until FORCE_STALE_AFTER (10s) kicked in on every acquire. Fix per the reviewer's exact suggestion: distinguish CSV data rows from the INFO banner by `line.trim().starts_with('"')`. Because `/FO CSV` wraps every field in double quotes, data rows always start with `"`, while the banner (in any locale) does not. This also keeps the "no naive `split(',')` over the PID column" property the earlier change was trying to preserve β€” process names with commas remain correctly quoted data rows. Also extract the classification into `tasklist_output_indicates_match` so it can be unit-tested on all platforms, not just cfg(windows). Six new cases lock the contract: - accepts a CSV data row - rejects the English INFO banner - rejects a non-English banner (Korean fixture) to prove the locale-agnostic property - rejects empty / whitespace-only output - accepts a process name that contains a comma - ignores leading/trailing blank lines around a data row cargo test -p tokscale-cli: 383 β†’ 389 passing. clippy clean. Constraint: The real cfg(windows) `lock_owner_is_alive` can't run on the macOS/Linux test runners; the helper is compiled on all platforms and gated with `#[cfg_attr(not(windows), allow(dead_code))]` so this regression surfaces in CI everywhere. Rejected: Full CSV parser (csv crate) | overkill; the presence-of- quoted-row check is sufficient and faster. Rejected: Filter banner by prefix "INFO:" | locale-dependent β€” the banner starts with 정보: on Korean Windows, informaciΓ³n: on Spanish, etc. `starts_with('"')` is the only locale-agnostic signal. Confidence: high Scope-risk: narrow Directive: If a future refactor tries to simplify the Windows branch back to a plain non-empty check, the INFO-banner test will fail β€” keep the CSV-data-row guard. Not-tested: Calling `tasklist.exe` for real on a Windows CI runner (no Windows CI yet; helper is exercised via fixtures instead). --- crates/tokscale-cli/src/auth.rs | 84 +++++++++++++++++++++++++++++---- 1 file changed, 76 insertions(+), 8 deletions(-) diff --git a/crates/tokscale-cli/src/auth.rs b/crates/tokscale-cli/src/auth.rs index 57411ebc1..bef2b8332 100644 --- a/crates/tokscale-cli/src/auth.rs +++ b/crates/tokscale-cli/src/auth.rs @@ -214,6 +214,29 @@ fn lock_age(path: &Path, state: Option) -> Duration { } } +// 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)] { @@ -226,12 +249,6 @@ fn lock_owner_is_alive(pid: u32) -> Option { #[cfg(windows)] { - // Locale-agnostic: `tasklist /FI "PID eq N" /FO CSV /NH` already - // filters server-side on PID. The filter emits exactly 0 rows when - // no process matches, and 1 row when one does. We only need to - // detect whether any non-empty CSV row came back β€” we do NOT try - // to parse the PID back out, because process names can legitimately - // contain commas and a naive split(',') would misread the column. let output = std::process::Command::new("tasklist") .args(["/FI", &format!("PID eq {}", pid), "/FO", "CSV", "/NH"]) .output(); @@ -239,8 +256,7 @@ fn lock_owner_is_alive(pid: u32) -> Option { match output { Ok(output) if output.status.success() => { let stdout = String::from_utf8_lossy(&output.stdout); - let matched = stdout.lines().any(|line| !line.trim().is_empty()); - Some(matched) + Some(tasklist_output_indicates_match(&stdout)) } Ok(_) => None, Err(_) => None, @@ -249,6 +265,7 @@ fn lock_owner_is_alive(pid: u32) -> Option { #[cfg(not(any(unix, windows)))] { + let _ = pid; None } } @@ -1163,6 +1180,57 @@ mod tests { 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 // ===================================================================== From 92681851a0ca8f1c281894dc4b0c3fc8dc368fc1 Mon Sep 17 00:00:00 2001 From: Junho Yeo Date: Tue, 21 Apr 2026 02:32:31 +0900 Subject: [PATCH 25/25] =?UTF-8?q?chore(db):=200006=20=E2=80=94=20drop=20un?= =?UTF-8?q?used=20submissions=20indexes,=20cover=20FK,=20add=20submit=5Fco?= =?UTF-8?q?unt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaced by the prod-DB audit during PR #389 review. All changes safe to ship alongside 0005 because 0006 only touches indexes and a no-op ADD COLUMN IF NOT EXISTS. Index cleanup β€” pg_stat_user_indexes on prod at the time of this migration: idx_submissions_user_id 214 scans (redundant with idx_submissions_leaderboard, which starts with user_id and serves every plain user_id lookup as a left-prefix) idx_submissions_status 1 scan idx_submissions_total_tokens 0 scans idx_submissions_date_range 0 scans idx_submissions_leaderboard 3,270,000 scans ← kept, it earns its keep Dropping the four trims INSERT/UPDATE overhead on every `tokscale submit` for zero query-path loss. FK coverage β€” device_codes.user_id is the only FK column in the schema without a covering index. Small table, so cascade-delete on a user currently seq-scans it; adding the index pins the cost to log(n) forever. submit_count safety net β€” this column exists on prod (added via `drizzle-kit push` some time ago, which writes straight from schema.ts without emitting a SQL file) but no earlier `.sql` migration has an ALTER TABLE for it. A fresh developer restore via `drizzle-kit migrate` from 0000..0005 therefore ends up without the column, and the app crashes at first submit. `ADD COLUMN IF NOT EXISTS` here is a no-op on prod and a correctness fix on every fresh environment. Confirmed via a fresh-DB replay: all 7 migrations now produce the expected final schema including submit_count. Verification: - Dry-run on a full prod clone (pg_dump β†’ local postgres:17): 0005 + 0006 applied in one transaction, total ~3 ms. Post- migration submissions has 5 indexes (pkey, created_at, leaderboard, user_source_unique, user_unsourced_unique), device_codes has 7 including the new user_id one, `submit_count` column reported as "already exists, skipping" on prod (expected) and is present on fresh replay. - schema.ts updated so drizzle's diff stays clean: removed the four dropped indexes, added idx_device_codes_user_id. - bunx vitest: 184/184 passing, tsc clean, eslint clean. Constraint: IF EXISTS / IF NOT EXISTS guards are required β€” prod already has submit_count and the to-be-dropped indexes, fresh DBs do not. Idempotent migration shape avoids divergence. Rejected: Split into two migrations (index cleanup + submit_count backfill) | they're all "schema drift fallout from the PR #389 audit" and shipping one migration in one transaction keeps the rollout window minimal. Rejected: Drop idx_submissions_created_at too | it has 11,668 prod scans, still used by time-range queries. Confidence: high Scope-risk: narrow Directive: If you add a new FK column elsewhere in schema.ts, give it a covering index in the same migration. The audit caught device_codes.user_id by accident β€” there's no lint for it. Not-tested: A prod-scale bloat or long-running query interaction while 0006 is in its ~3 ms transaction (no way to simulate without hitting live prod). --- ...bmissions_indexes_and_add_submit_count.sql | 31 + .../lib/db/migrations/meta/0006_snapshot.json | 897 ++++++++++++++++++ .../src/lib/db/migrations/meta/_journal.json | 7 + packages/frontend/src/lib/db/schema.ts | 12 +- 4 files changed, 943 insertions(+), 4 deletions(-) create mode 100644 packages/frontend/src/lib/db/migrations/0006_cleanup_submissions_indexes_and_add_submit_count.sql create mode 100644 packages/frontend/src/lib/db/migrations/meta/0006_snapshot.json diff --git a/packages/frontend/src/lib/db/migrations/0006_cleanup_submissions_indexes_and_add_submit_count.sql b/packages/frontend/src/lib/db/migrations/0006_cleanup_submissions_indexes_and_add_submit_count.sql new file mode 100644 index 000000000..4935df057 --- /dev/null +++ b/packages/frontend/src/lib/db/migrations/0006_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/0006_snapshot.json b/packages/frontend/src/lib/db/migrations/meta/0006_snapshot.json new file mode 100644 index 000000000..a2720e338 --- /dev/null +++ b/packages/frontend/src/lib/db/migrations/meta/0006_snapshot.json @@ -0,0 +1,897 @@ +{ + "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": {} + }, + "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/_journal.json b/packages/frontend/src/lib/db/migrations/meta/_journal.json index e6dfb3eff..16ffef149 100644 --- a/packages/frontend/src/lib/db/migrations/meta/_journal.json +++ b/packages/frontend/src/lib/db/migrations/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1775081591892, "tag": "0005_complete_ted_forrester", "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1776706090702, + "tag": "0006_cleanup_submissions_indexes_and_add_submit_count", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/frontend/src/lib/db/schema.ts b/packages/frontend/src/lib/db/schema.ts index 9abe923bb..d07c67481 100644 --- a/packages/frontend/src/lib/db/schema.ts +++ b/packages/frontend/src/lib/db/schema.ts @@ -132,6 +132,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), ] ); @@ -183,11 +187,11 @@ 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), + // 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,