diff --git a/api/.sqlx/query-02a53bed1dc404067dd50b4e600bf8d6ce7cb1668ca03cf474e744faa045e1f5.json b/api/.sqlx/query-02a53bed1dc404067dd50b4e600bf8d6ce7cb1668ca03cf474e744faa045e1f5.json new file mode 100644 index 00000000..38ad5a34 --- /dev/null +++ b/api/.sqlx/query-02a53bed1dc404067dd50b4e600bf8d6ce7cb1668ca03cf474e744faa045e1f5.json @@ -0,0 +1,63 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n seq,\n change_type as \"change_type: ChangeType\",\n scope_name as \"scope_name: ScopeName\",\n package_name as \"package_name: PackageName\",\n data,\n created_at\n FROM changes\n WHERE seq > $1\n ORDER BY seq ASC\n LIMIT $2\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "seq", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "change_type: ChangeType", + "type_info": { + "Custom": { + "name": "change_type", + "kind": { + "Enum": [ + "PACKAGE_VERSION_ADDED", + "PACKAGE_TAG_ADDED" + ] + } + } + } + }, + { + "ordinal": 2, + "name": "scope_name: ScopeName", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "package_name: PackageName", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "data", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false + ] + }, + "hash": "02a53bed1dc404067dd50b4e600bf8d6ce7cb1668ca03cf474e744faa045e1f5" +} diff --git a/api/.sqlx/query-4b34f563b276542980b3b538dca53d705b326c4ecc20c7e8b87c0a41147011be.json b/api/.sqlx/query-4b34f563b276542980b3b538dca53d705b326c4ecc20c7e8b87c0a41147011be.json new file mode 100644 index 00000000..6358e8c7 --- /dev/null +++ b/api/.sqlx/query-4b34f563b276542980b3b538dca53d705b326c4ecc20c7e8b87c0a41147011be.json @@ -0,0 +1,75 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO changes (change_type, scope_name, package_name, data)\n VALUES ($1, $2, $3, $4)\n RETURNING\n seq,\n change_type as \"change_type: ChangeType\",\n scope_name as \"scope_name: ScopeName\",\n package_name as \"package_name: PackageName\",\n data,\n created_at\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "seq", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "change_type: ChangeType", + "type_info": { + "Custom": { + "name": "change_type", + "kind": { + "Enum": [ + "PACKAGE_VERSION_ADDED", + "PACKAGE_TAG_ADDED" + ] + } + } + } + }, + { + "ordinal": 2, + "name": "scope_name: ScopeName", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "package_name: PackageName", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "data", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + { + "Custom": { + "name": "change_type", + "kind": { + "Enum": [ + "PACKAGE_VERSION_ADDED", + "PACKAGE_TAG_ADDED" + ] + } + } + }, + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false + ] + }, + "hash": "4b34f563b276542980b3b538dca53d705b326c4ecc20c7e8b87c0a41147011be" +} diff --git a/api/migrations/20250402220510_changes.sql b/api/migrations/20250402220510_changes.sql new file mode 100644 index 00000000..3079232f --- /dev/null +++ b/api/migrations/20250402220510_changes.sql @@ -0,0 +1,16 @@ +CREATE TYPE change_type AS ENUM ( + 'PACKAGE_VERSION_ADDED', + 'PACKAGE_TAG_ADDED' +); + +CREATE TABLE changes ( + seq BIGSERIAL PRIMARY KEY, + change_type change_type NOT NULL, + scope_name text NOT NULL, + package_name text NOT NULL, + data TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX changes_scope_name_idx ON changes (scope_name, package_name); +CREATE INDEX changes_created_at_idx ON changes (created_at); diff --git a/api/src/api/changes.rs b/api/src/api/changes.rs new file mode 100644 index 00000000..30cbac37 --- /dev/null +++ b/api/src/api/changes.rs @@ -0,0 +1,165 @@ +// Copyright 2024 the JSR authors. All rights reserved. MIT license. +use hyper::{Body, Request}; +use routerify::prelude::*; +use serde::{Deserialize, Serialize}; +use tracing::instrument; + +use crate::{ + db::{Change, Database}, + util::{pagination, ApiResult}, +}; + +#[derive(Serialize, Deserialize)] +pub struct ApiChange { + pub seq: i64, + pub r#type: String, + pub id: String, + pub changes: serde_json::Value, +} + +impl From for ApiChange { + fn from(change: Change) -> Self { + Self { + seq: change.seq, + r#type: change.change_type.to_string(), + id: format!("@jsr/{}__{}", change.scope_name, change.package_name), + changes: serde_json::from_str(&change.data).unwrap(), + } + } +} + +#[instrument(name = "GET /api/_changes", skip(req), err)] +pub async fn list_changes(req: Request) -> ApiResult> { + let db = req.data::().unwrap(); + let (start, limit) = pagination(&req); + let changes = db.list_changes(start, limit).await?; + Ok(changes.into_iter().map(ApiChange::from).collect()) +} + +#[cfg(test)] +mod tests { + use super::ApiChange; + use crate::db::ChangeType; + use crate::ids::PackageName; + use crate::ids::ScopeName; + use crate::util::test::ApiResultExt; + use crate::util::test::TestSetup; + use serde_json::json; + + #[tokio::test] + async fn list_empty_changes() { + let mut t = TestSetup::new().await; + + let changes = t + .http() + .get("/api/_changes") + .call() + .await + .unwrap() + .expect_ok::>() + .await; + + assert!(changes.is_empty()); + } + + #[tokio::test] + async fn list_single_change() { + let mut t = TestSetup::new().await; + + t.ephemeral_database + .create_change( + ChangeType::PackageVersionAdded, + &ScopeName::new("test-scope".to_string()).unwrap(), + &PackageName::new("test-package".to_string()).unwrap(), + json!({ + "version": "1.0.0" + }), + ) + .await + .unwrap(); + + let changes = t + .http() + .get("/api/_changes") + .call() + .await + .unwrap() + .expect_ok::>() + .await; + + assert_eq!(changes.len(), 1); + let change = &changes[0]; + assert_eq!(change.r#type, ChangeType::PackageVersionAdded.to_string()); + assert_eq!(change.id, "@jsr/test-scope__test-package"); + assert_eq!(change.changes["version"], "1.0.0"); + } + + #[tokio::test] + async fn list_changes_pagination() { + let mut t = TestSetup::new().await; + + // Create two changes + t.ephemeral_database + .create_change( + ChangeType::PackageVersionAdded, + &ScopeName::new("test-scope".to_string()).unwrap(), + &PackageName::new("test-package-1".to_string()).unwrap(), + json!({ + "name": "test-package-1", + }), + ) + .await + .unwrap(); + + t.ephemeral_database + .create_change( + ChangeType::PackageVersionAdded, + &ScopeName::new("test-scope".to_string()).unwrap(), + &PackageName::new("test-package-2".to_string()).unwrap(), + json!({ + "version": "1.0.0", + }), + ) + .await + .unwrap(); + + // Test limit parameter + let changes = t + .http() + .get("/api/_changes?limit=1&since=0") + .call() + .await + .unwrap() + .expect_ok::>() + .await; + + assert_eq!(changes.len(), 1); + assert_eq!(changes[0].id, "@jsr/test-scope__test-package-1"); + + // Test since parameter + let changes = t + .http() + .get(format!("/api/_changes?since={}", changes[0].seq)) + .call() + .await + .unwrap() + .expect_ok::>() + .await; + + assert_eq!(changes.len(), 1); + assert_eq!(changes[0].id, "@jsr/test-scope__test-package-2"); + + // Test since + limit combination + let changes = t + .http() + .get("/api/_changes?since=0&limit=1") + .call() + .await + .unwrap() + .expect_ok::>() + .await; + + assert_eq!(changes.len(), 1); + assert_eq!(changes[0].id, "@jsr/test-scope__test-package-1"); + } +} diff --git a/api/src/api/mod.rs b/api/src/api/mod.rs index 4c0d206d..667c6542 100644 --- a/api/src/api/mod.rs +++ b/api/src/api/mod.rs @@ -1,6 +1,7 @@ // Copyright 2024 the JSR authors. All rights reserved. MIT license. mod admin; mod authorization; +mod changes; mod errors; mod package; mod publishing_task; @@ -9,6 +10,7 @@ mod self_user; mod types; mod users; +use changes::list_changes; use hyper::Body; use hyper::Response; use package::global_list_handler; @@ -40,6 +42,7 @@ pub fn api_router() -> Router { util::json(global_metrics_handler), ), ) + .get("/_changes", util::json(list_changes)) .middleware(Middleware::pre(util::auth_middleware)) .scope("/admin", admin_router()) .scope("/scopes", scope_router()) diff --git a/api/src/db/database.rs b/api/src/db/database.rs index 06af6a26..b765f345 100644 --- a/api/src/db/database.rs +++ b/api/src/db/database.rs @@ -56,6 +56,64 @@ impl Database { .await } + #[instrument(name = "Database::list_changes", skip(self), err)] + pub async fn list_changes( + &self, + since: i64, + limit: i64, + ) -> Result> { + sqlx::query_as!( + Change, + r#" + SELECT + seq, + change_type as "change_type: ChangeType", + scope_name as "scope_name: ScopeName", + package_name as "package_name: PackageName", + data, + created_at + FROM changes + WHERE seq > $1 + ORDER BY seq ASC + LIMIT $2 + "#, + since, + limit + ) + .fetch_all(&self.pool) + .await + } + + #[instrument(name = "Database::create_change", skip(self), err)] + pub async fn create_change( + &self, + change_type: ChangeType, + scope_name: &ScopeName, + package_name: &PackageName, + data: serde_json::Value, + ) -> Result { + sqlx::query_as!( + Change, + r#" + INSERT INTO changes (change_type, scope_name, package_name, data) + VALUES ($1, $2, $3, $4) + RETURNING + seq, + change_type as "change_type: ChangeType", + scope_name as "scope_name: ScopeName", + package_name as "package_name: PackageName", + data, + created_at + "#, + change_type as _, + scope_name as _, + package_name as _, + data.to_string() + ) + .fetch_one(&self.pool) + .await + } + #[instrument(name = "Database::get_user_public", skip(self), err)] pub async fn get_user_public(&self, id: Uuid) -> Result> { sqlx::query_as!( diff --git a/api/src/db/models.rs b/api/src/db/models.rs index 5f179c1f..9095d17b 100644 --- a/api/src/db/models.rs +++ b/api/src/db/models.rs @@ -824,3 +824,38 @@ impl sqlx::postgres::PgHasArrayType for DownloadKind { sqlx::postgres::PgTypeInfo::with_name("_download_kind") } } + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::Type)] +#[sqlx(type_name = "change_type", rename_all = "SCREAMING_SNAKE_CASE")] +#[serde(rename_all = "snake_case")] +pub enum ChangeType { + PackageVersionAdded, + PackageTagAdded, +} + +impl std::fmt::Display for ChangeType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::PackageVersionAdded => write!(f, "PACKAGE_VERSION_ADDED"), + Self::PackageTagAdded => write!(f, "PACKAGE_TAG_ADDED"), + } + } +} + +#[derive(Debug, Clone)] +pub struct Change { + pub seq: i64, + pub change_type: ChangeType, + pub scope_name: ScopeName, + pub package_name: PackageName, + pub data: String, + pub created_at: DateTime, +} + +#[derive(Debug)] +pub struct NewChange<'s> { + pub change_type: ChangeType, + pub scope_name: &'s ScopeName, + pub package_name: &'s PackageName, + pub data: &'s str, +} diff --git a/api/src/publish.rs b/api/src/publish.rs index 85118fd9..2ec09e83 100644 --- a/api/src/publish.rs +++ b/api/src/publish.rs @@ -5,6 +5,7 @@ use std::collections::HashSet; use crate::api::ApiError; use crate::buckets::Buckets; use crate::buckets::UploadTaskBody; +use crate::db::ChangeType; use crate::db::Database; use crate::db::DependencyKind; use crate::db::ExportsMap; @@ -234,6 +235,29 @@ async fn process_publishing_task( ); } + tokio::spawn({ + let db = db.clone(); + let scope = publishing_task.package_scope.clone(); + let name = publishing_task.package_name.clone(); + let version = publishing_task.package_version.clone(); + + async move { + if let Err(e) = db + .create_change( + ChangeType::PackageVersionAdded, + &scope, + &name, + serde_json::json!({ + "version": version.to_string(), + }), + ) + .await + { + error!("Failed to create change record: {}", e); + } + } + }); + Ok(()) } diff --git a/api/src/util.rs b/api/src/util.rs index 1c56088f..3277d1c8 100644 --- a/api/src/util.rs +++ b/api/src/util.rs @@ -283,13 +283,19 @@ pub fn pagination(req: &Request) -> (i64, i64) { .and_then(|page| page.parse::().ok()) .unwrap_or(100) .clamp(1, 100); - let page = req - .query("page") - .and_then(|page| page.parse::().ok()) - .unwrap_or(1) - .max(1); - let start = (page * limit) - limit; + let start = if let Some(since) = + req.query("since").and_then(|s| s.parse::().ok()) + { + since + } else { + let page = req + .query("page") + .and_then(|page| page.parse::().ok()) + .unwrap_or(1) + .max(1); + (page * limit) - limit + }; (start, limit) }