Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions crates/trapfall-core/src/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ impl Store {
self.db.get_project_by_id(id).await
}

/// Resolve an ingest URL project segment: numeric DSN seq first, then legacy UUID.
pub async fn resolve_project(&self, segment: &str) -> Result<Option<Project>> {
self.db.resolve_project(segment).await
}

pub async fn get_project_by_dsn_key(&self, sentry_key: &str) -> Result<Option<Project>> {
self.db.get_project_by_dsn_key(sentry_key).await
}
Expand Down
19 changes: 19 additions & 0 deletions crates/trapfall-db/migrations/postgres/006_project_seq.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
-- Numeric per-project sequence used as the DSN project id.
-- Sentry JS SDKs require an all-digit project id in the DSN path (#328).
-- The UUID `id` stays the primary key; `seq` is the public, DSN-facing id.
CREATE SEQUENCE IF NOT EXISTS projects_seq_seq;

ALTER TABLE projects ADD COLUMN IF NOT EXISTS seq BIGINT;

-- Backfill existing rows in creation order. Idempotent: only touches NULL rows.
DO $$
DECLARE
r RECORD;
BEGIN
FOR r IN SELECT id FROM projects WHERE seq IS NULL ORDER BY created_at, id LOOP
UPDATE projects SET seq = nextval('projects_seq_seq') WHERE id = r.id;
END LOOP;
END
$$;

CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_seq ON projects(seq);
87 changes: 82 additions & 5 deletions crates/trapfall-db/src/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -202,15 +202,39 @@ pub fn new_id() -> String {
Uuid::new_v4().to_string()
}

/// Generate a DSN with the given host and project ID.
pub fn generate_dsn_with(host: &str, project_id: &str) -> String {
/// Generate a DSN with the given host and numeric project sequence.
///
/// Sentry JS SDKs require the DSN path to be an all-digit project id, so the
/// per-project `seq` (not the internal UUID) is rendered into the DSN.
/// Format: `https://{key}@{host}/{seq}`.
pub fn generate_dsn_with(host: &str, seq: i64) -> String {
generate_dsn_with_scheme("https", host, seq)
}

/// Like [`generate_dsn_with`] but with an explicit URL scheme (`http`/`https`).
pub fn generate_dsn_with_scheme(scheme: &str, host: &str, seq: i64) -> String {
let key = Uuid::new_v4();
format!("https://{key}@{host}/{project_id}")
format!("{scheme}://{key}@{host}/{seq}")
}

/// Extract the DSN key from a full DSN URL.
/// Extract the DSN key from a full DSN URL (any scheme, e.g. `http://` or `https://`).
pub fn extract_dsn_key(dsn: &str) -> String {
dsn.split('@').next().unwrap_or("").trim_start_matches("https://").to_string()
let userinfo = dsn.split('@').next().unwrap_or("");
match userinfo.split_once("://") {
Some((_, key)) => key.to_string(),
None => userinfo.to_string(),
}
}

/// Parse a path segment as a numeric project sequence (all ASCII digits).
///
/// Returns `None` for UUIDs and any other non-numeric input so callers can
/// fall back to legacy UUID resolution.
pub fn parse_project_seq(segment: &str) -> Option<i64> {
if segment.is_empty() || !segment.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
segment.parse().ok()
}

/// Extract the host from a DSN URL.
Expand Down Expand Up @@ -373,3 +397,56 @@ pub struct AttachmentRow {
pub disk_path: String,
pub created_at: String,
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn dsn_uses_numeric_project_id() {
let dsn = generate_dsn_with("trapfall.example.com", 7);
assert!(dsn.starts_with("https://"));
assert!(dsn.ends_with("@trapfall.example.com/7"));
let path = dsn.rsplit('/').next().unwrap();
assert!(path.bytes().all(|b| b.is_ascii_digit()));
}

#[test]
fn dsn_with_http_scheme() {
let dsn = generate_dsn_with_scheme("http", "localhost:9090", 1);
assert!(dsn.starts_with("http://"));
assert!(dsn.ends_with("@localhost:9090/1"));
}

#[test]
fn extract_key_handles_http_and_https() {
assert_eq!(extract_dsn_key("https://abc@h.example/1"), "abc");
assert_eq!(extract_dsn_key("http://abc@localhost:9090/1"), "abc");
assert_eq!(extract_dsn_key("abc@h/1"), "abc");
assert_eq!(extract_dsn_key(""), "");
}

#[test]
fn extract_key_roundtrips_generated_dsn() {
for scheme in ["http", "https"] {
let dsn = generate_dsn_with_scheme(scheme, "h:1", 3);
let key = extract_dsn_key(&dsn);
assert!(Uuid::parse_str(&key).is_ok(), "key should be a bare uuid, got {key}");
}
}

#[test]
fn extract_host_unchanged_for_numeric_dsn() {
assert_eq!(extract_dsn_host("http://k@localhost:9090/5"), "localhost:9090");
}

#[test]
fn parse_seq_numeric_only() {
assert_eq!(parse_project_seq("42"), Some(42));
assert_eq!(parse_project_seq(""), None);
assert_eq!(parse_project_seq("-1"), None);
assert_eq!(parse_project_seq("2e3c789d-98cc-4cee-b0d1-6ed51cab436b"), None);
assert_eq!(parse_project_seq("12abc"), None);
assert_eq!(parse_project_seq("99999999999999999999999"), None);
}
}
22 changes: 22 additions & 0 deletions crates/trapfall-db/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,15 @@ pub async fn run_sqlite_migrations(pool: &sqlx::SqlitePool) -> Result<()> {
sqlx::query(include_str!("../../trapfalld/migrations/20260613000001_transactions.sql")).execute(pool).await?;
sqlx::query(include_str!("../../trapfalld/migrations/20260627000001_release_health.sql")).execute(pool).await?;
sqlx::query(include_str!("../../trapfalld/migrations/20260627000002_attachments.sql")).execute(pool).await?;
// Numeric project seq for DSNs (#328). ALTER TABLE ADD COLUMN is not idempotent in SQLite,
// so guard on pragma_table_info.
let has_seq: bool = sqlx::query_scalar("SELECT COUNT(*) > 0 FROM pragma_table_info('projects') WHERE name = 'seq'")
.fetch_one(pool)
.await
.unwrap_or(false);
if !has_seq {
sqlx::query(include_str!("../../trapfalld/migrations/20260701000001_project_seq.sql")).execute(pool).await?;
}
Ok(())
}

Expand All @@ -169,6 +178,7 @@ pub async fn run_postgres_migrations(pool: &sqlx::PgPool) -> Result<()> {
sqlx::query(include_str!("../migrations/postgres/003_transactions.sql")).execute(pool).await?;
sqlx::query(include_str!("../migrations/postgres/004_release_health.sql")).execute(pool).await?;
sqlx::query(include_str!("../migrations/postgres/005_attachments.sql")).execute(pool).await?;
sqlx::query(include_str!("../migrations/postgres/006_project_seq.sql")).execute(pool).await?;
Ok(())
}

Expand All @@ -188,6 +198,18 @@ pub trait Database: Send + Sync {
async fn get_project_by_slug(&self, slug: &str) -> Result<Option<Project>>;
async fn get_project_by_id(&self, id: &str) -> Result<Option<Project>>;
async fn get_project_by_dsn_key(&self, sentry_key: &str) -> Result<Option<Project>>;
/// Look up a project by its numeric DSN sequence.
async fn get_project_by_seq(&self, seq: i64) -> Result<Option<Project>>;
/// Resolve the project path segment of an ingest URL: numeric `seq` first,
/// falling back to the legacy UUID id so DSNs issued before #328 keep working.
async fn resolve_project(&self, segment: &str) -> Result<Option<Project>> {
if let Some(seq) = crate::common::parse_project_seq(segment) {
if let Some(p) = self.get_project_by_seq(seq).await? {
return Ok(Some(p));
}
}
self.get_project_by_id(segment).await
}
async fn list_projects(&self) -> Result<Vec<Project>>;
async fn rotate_dsn(&self, project_id: &str) -> Result<String>;
async fn archive_project(&self, project_id: &str) -> Result<()>;
Expand Down
39 changes: 28 additions & 11 deletions crates/trapfall-db/src/postgres.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,18 +53,22 @@ impl Database for PostgresBackend {

async fn create_project_with_host(&self, slug: &str, name: &str, host: &str) -> Result<Project> {
let id = new_id();
let dsn = generate_dsn_with(host, &id);
let seq: i64 = sqlx::query_scalar("SELECT nextval('projects_seq_seq')").fetch_one(&self.pool).await?;
let dsn = generate_dsn_with(host, seq);
let dsn_key = extract_dsn_key(&dsn);
let now = now_rfc3339();
sqlx::query("INSERT INTO projects (id, slug, name, dsn_key, dsn, created_at) VALUES ($1, $2, $3, $4, $5, $6)")
.bind(&id)
.bind(slug)
.bind(name)
.bind(&dsn_key)
.bind(&dsn)
.bind(&now)
.execute(&self.pool)
.await?;
sqlx::query(
"INSERT INTO projects (id, slug, name, dsn_key, dsn, created_at, seq) VALUES ($1, $2, $3, $4, $5, $6, $7)",
)
.bind(&id)
.bind(slug)
.bind(name)
.bind(&dsn_key)
.bind(&dsn)
.bind(&now)
.bind(seq)
.execute(&self.pool)
.await?;

Ok(Project { id, slug: slug.to_string(), name: name.to_string(), dsn, created_at: now, archived_at: None })
}
Expand All @@ -91,6 +95,17 @@ impl Database for PostgresBackend {
Ok(row.map(Into::into))
}

async fn get_project_by_seq(&self, seq: i64) -> Result<Option<Project>> {
let row = sqlx::query_as::<_, ProjectRow>(
"SELECT id, slug, name, dsn, created_at, archived_at FROM projects WHERE seq = $1",
)
.bind(seq)
.fetch_optional(&self.pool)
.await?;

Ok(row.map(Into::into))
}

async fn get_project_by_dsn_key(&self, sentry_key: &str) -> Result<Option<Project>> {
let row = sqlx::query_as::<_, ProjectRow>(
"SELECT id, slug, name, dsn, created_at, archived_at FROM projects WHERE dsn_key = $1",
Expand All @@ -114,7 +129,9 @@ impl Database for PostgresBackend {
async fn rotate_dsn(&self, project_id: &str) -> Result<String> {
let project = self.get_project_by_id(project_id).await?.ok_or_else(|| anyhow::anyhow!("Project not found"))?;
let host = extract_dsn_host(&project.dsn);
let new_dsn = generate_dsn_with(&host, project_id);
let seq: i64 =
sqlx::query_scalar("SELECT seq FROM projects WHERE id = $1").bind(project_id).fetch_one(&self.pool).await?;
let new_dsn = generate_dsn_with(&host, seq);
let new_dsn_key = extract_dsn_key(&new_dsn);
sqlx::query("UPDATE projects SET dsn = $1, dsn_key = $2 WHERE id = $3")
.bind(&new_dsn)
Expand Down
88 changes: 77 additions & 11 deletions crates/trapfall-db/src/sqlite.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,18 +50,23 @@ impl Database for SqliteBackend {

async fn create_project_with_host(&self, slug: &str, name: &str, host: &str) -> Result<Project> {
let id = new_id();
let dsn = generate_dsn_with(host, &id);
let seq: i64 =
sqlx::query_scalar("SELECT COALESCE(MAX(seq), 0) + 1 FROM projects").fetch_one(&self.pool).await?;
let dsn = generate_dsn_with(host, seq);
let dsn_key = extract_dsn_key(&dsn);
let now = chrono::Utc::now().to_rfc3339();
sqlx::query("INSERT INTO projects (id, slug, name, dsn_key, dsn, created_at) VALUES (?, ?, ?, ?, ?, ?)")
.bind(&id)
.bind(slug)
.bind(name)
.bind(&dsn_key)
.bind(&dsn)
.bind(&now)
.execute(&self.pool)
.await?;
sqlx::query(
"INSERT INTO projects (id, slug, name, dsn_key, dsn, created_at, seq) VALUES (?, ?, ?, ?, ?, ?, ?)",
)
.bind(&id)
.bind(slug)
.bind(name)
.bind(&dsn_key)
.bind(&dsn)
.bind(&now)
.bind(seq)
.execute(&self.pool)
.await?;

Ok(Project { id, slug: slug.to_string(), name: name.to_string(), dsn, created_at: now, archived_at: None })
}
Expand All @@ -88,6 +93,17 @@ impl Database for SqliteBackend {
Ok(row.map(Into::into))
}

async fn get_project_by_seq(&self, seq: i64) -> Result<Option<Project>> {
let row = sqlx::query_as::<_, ProjectRow>(
"SELECT id, slug, name, dsn, created_at, archived_at FROM projects WHERE seq = ?",
)
.bind(seq)
.fetch_optional(&self.pool)
.await?;

Ok(row.map(Into::into))
}

async fn get_project_by_dsn_key(&self, sentry_key: &str) -> Result<Option<Project>> {
let row = sqlx::query_as::<_, ProjectRow>(
"SELECT id, slug, name, dsn, created_at, archived_at FROM projects WHERE dsn_key = ?",
Expand Down Expand Up @@ -116,7 +132,9 @@ impl Database for SqliteBackend {
.nth(1)
.map(|s| s.split('/').next().unwrap_or("localhost:9090"))
.unwrap_or("localhost:9090");
let new_dsn = generate_dsn_with(host, project_id);
let seq: i64 =
sqlx::query_scalar("SELECT seq FROM projects WHERE id = ?").bind(project_id).fetch_one(&self.pool).await?;
let new_dsn = generate_dsn_with(host, seq);
let new_dsn_key = extract_dsn_key(&new_dsn);
sqlx::query("UPDATE projects SET dsn = ?, dsn_key = ? WHERE id = ?")
.bind(&new_dsn)
Expand Down Expand Up @@ -1090,9 +1108,57 @@ mod tests {
.execute(&pool)
.await
.unwrap();
sqlx::query(include_str!("../../trapfalld/migrations/20260701000001_project_seq.sql"))
.execute(&pool)
.await
.unwrap();
SqliteBackend::new(pool)
}

#[tokio::test]
async fn test_seq_backfill_for_legacy_projects() {
let pool = sqlx::sqlite::SqlitePoolOptions::new().max_connections(1).connect("sqlite::memory:").await.unwrap();
// Pre-#328 schema: no seq column.
sqlx::query(include_str!("../../trapfalld/migrations/20260606000001_initial.sql"))
.execute(&pool)
.await
.unwrap();
for (id, slug) in [("legacy-uuid-a", "a"), ("legacy-uuid-b", "b")] {
sqlx::query("INSERT INTO projects (id, slug, name, dsn_key, dsn, created_at) VALUES (?, ?, ?, 'k', 'https://k@h/x', 'now')")
.bind(id)
.bind(slug)
.bind(slug)
.execute(&pool)
.await
.unwrap();
}
crate::run_sqlite_migrations(&pool).await.unwrap();
// Idempotent on restart.
crate::run_sqlite_migrations(&pool).await.unwrap();

let db = SqliteBackend::new(pool);
assert_eq!(db.resolve_project("1").await.unwrap().unwrap().id, "legacy-uuid-a");
assert_eq!(db.resolve_project("2").await.unwrap().unwrap().id, "legacy-uuid-b");
// Legacy UUID DSNs still resolve.
assert_eq!(db.resolve_project("legacy-uuid-b").await.unwrap().unwrap().slug, "b");
// New projects continue the sequence and produce numeric DSNs.
let p = db.create_project("c", "C").await.unwrap();
assert!(p.dsn.ends_with("/3"), "got {}", p.dsn);
// Rotating a backfilled legacy project switches its DSN to the numeric id.
let rotated = db.rotate_dsn("legacy-uuid-a").await.unwrap();
assert!(rotated.ends_with("/1"), "got {rotated}");
}

#[tokio::test]
async fn test_resolve_numeric_and_legacy_uuid() {
let db = open_backend().await;
let p = db.create_project("r", "R").await.unwrap();
let seq = p.dsn.rsplit('/').next().unwrap();
assert_eq!(db.resolve_project(seq).await.unwrap().unwrap().id, p.id);
assert_eq!(db.resolve_project(&p.id).await.unwrap().unwrap().id, p.id);
assert!(db.resolve_project("424242").await.unwrap().is_none());
}

#[tokio::test]
async fn test_create_and_lookup_project() {
let db = open_backend().await;
Expand Down
Loading
Loading