diff --git a/.cargo/config.toml b/.cargo/config.toml index a965e33..0d82fd2 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -3,4 +3,3 @@ recorder-playground = "run -p recorder --example playground -- --environment de [build] rustflags = ["-Zthreads=8", "-Zshare-generics=y"] -#rustflags = ["-Zthreads=8"] diff --git a/.vscode/settings.json b/.vscode/settings.json index fcbf6ee..830d5c2 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -41,16 +41,6 @@ "name": "konobangu-dev", "database": "konobangu", "username": "konobangu" - }, - { - "previewLimit": 50, - "server": "localhost", - "port": 32770, - "askForPassword": true, - "driver": "PostgreSQL", - "name": "docker-pgsql", - "database": "konobangu", - "username": "konobangu" } ] } diff --git a/Cargo.lock b/Cargo.lock index d1236ba..283fd0f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6983,9 +6983,9 @@ dependencies = [ [[package]] name = "testcontainers-modules" -version = "0.12.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f29549c522bd43086d038c421ed69cdf88bc66387acf3aa92b26f965fa95ec2" +checksum = "eac95cde96549fc19c6bf19ef34cc42bd56e264c1cb97e700e21555be0ecf9e2" dependencies = [ "testcontainers", ] diff --git a/Cargo.toml b/Cargo.toml index d96df67..452ab8d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,5 @@ +# cargo-features = ["codegen-backend"] + [workspace] members = [ "packages/testing-torrents", @@ -9,6 +11,11 @@ members = [ ] resolver = "2" +[profile.dev] +debug = 0 +# [simd not supported by cranelift](https://github.com/rust-lang/rustc_codegen_cranelift/issues/171) +# codegen-backend = "cranelift" + [workspace.dependencies] testing-torrents = { path = "./packages/testing-torrents" } util = { path = "./packages/util" } @@ -28,7 +35,7 @@ futures = "0.3" quirks_path = "0.1" snafu = { version = "0.8", features = ["futures"] } testcontainers = { version = "0.24" } -testcontainers-modules = { version = "0.12" } +testcontainers-modules = { version = "0.12.1" } testcontainers-ext = { version = "0.1.0", features = ["tracing"] } serde = { version = "1", features = ["derive"] } tokio = { version = "1.45.1", features = [ diff --git a/apps/recorder/src/app/context.rs b/apps/recorder/src/app/context.rs index 6ed5155..917c61b 100644 --- a/apps/recorder/src/app/context.rs +++ b/apps/recorder/src/app/context.rs @@ -40,13 +40,13 @@ pub struct AppContext { cache: CacheService, mikan: MikanClient, auth: AuthService, - graphql: GraphQLService, storage: StorageService, crypto: CryptoService, working_dir: String, environment: Environment, message: MessageService, task: OnceCell, + graphql: OnceCell, } impl AppContext { @@ -65,7 +65,6 @@ impl AppContext { let auth = AuthService::from_conf(config.auth).await?; let mikan = MikanClient::from_config(config.mikan).await?; let crypto = CryptoService::from_config(config.crypto).await?; - let graphql = GraphQLService::from_config_and_database(config.graphql, db.clone()).await?; let ctx = Arc::new(AppContext { config: config_cloned, @@ -77,10 +76,10 @@ impl AppContext { storage, mikan, working_dir: working_dir.to_string(), - graphql, crypto, message, task: OnceCell::new(), + graphql: OnceCell::new(), }); ctx.task @@ -89,6 +88,12 @@ impl AppContext { }) .await?; + ctx.graphql + .get_or_try_init(async || { + GraphQLService::from_config_and_ctx(config.graphql, ctx.clone()).await + }) + .await?; + Ok(ctx) } } @@ -119,7 +124,7 @@ impl AppContextTrait for AppContext { &self.auth } fn graphql(&self) -> &GraphQLService { - &self.graphql + self.graphql.get().expect("graphql should be set") } fn storage(&self) -> &dyn StorageServiceTrait { &self.storage diff --git a/apps/recorder/src/app/core.rs b/apps/recorder/src/app/core.rs index 7b07359..a95f857 100644 --- a/apps/recorder/src/app/core.rs +++ b/apps/recorder/src/app/core.rs @@ -1,7 +1,8 @@ use std::{net::SocketAddr, sync::Arc}; use axum::Router; -use tokio::signal; +use tokio::{net::TcpSocket, signal}; +use tracing::instrument; use super::{builder::AppBuilder, context::AppContextTrait}; use crate::{ @@ -22,14 +23,31 @@ impl App { AppBuilder::default() } + #[instrument(err, skip(self))] pub async fn serve(&self) -> RecorderResult<()> { let context = &self.context; let config = context.config(); - let listener = tokio::net::TcpListener::bind(&format!( - "{}:{}", - config.server.binding, config.server.port - )) - .await?; + + let listener = { + let addr: SocketAddr = + format!("{}:{}", config.server.binding, config.server.port).parse()?; + + let socket = if addr.is_ipv4() { + TcpSocket::new_v4() + } else { + TcpSocket::new_v6() + }?; + + socket.set_reuseaddr(true)?; + + #[cfg(all(unix, not(target_os = "solaris")))] + if let Err(e) = socket.set_reuseport(true) { + tracing::warn!("Failed to set SO_REUSEPORT: {}", e); + } + + socket.bind(addr)?; + socket.listen(1024) + }?; let mut router = Router::>::new(); diff --git a/apps/recorder/src/database/service.rs b/apps/recorder/src/database/service.rs index dcf71f4..e5e406c 100644 --- a/apps/recorder/src/database/service.rs +++ b/apps/recorder/src/database/service.rs @@ -10,10 +10,6 @@ use sea_orm_migration::MigratorTrait; use super::DatabaseConfig; use crate::{errors::RecorderResult, migrations::Migrator}; -pub trait DatabaseServiceConnectionTrait { - fn get_database_connection(&self) -> &DatabaseConnection; -} - pub struct DatabaseService { connection: DatabaseConnection, #[cfg(all(any(test, feature = "playground"), feature = "testcontainers"))] diff --git a/apps/recorder/src/errors/app_error.rs b/apps/recorder/src/errors/app_error.rs index 0368960..6baffe4 100644 --- a/apps/recorder/src/errors/app_error.rs +++ b/apps/recorder/src/errors/app_error.rs @@ -25,6 +25,8 @@ pub enum RecorderError { source: Box, }, #[snafu(transparent)] + NetAddrParseError { source: std::net::AddrParseError }, + #[snafu(transparent)] RegexError { source: regex::Error }, #[snafu(transparent)] InvalidMethodError { source: http::method::InvalidMethod }, diff --git a/apps/recorder/src/graphql/infra/transformer.rs b/apps/recorder/src/graphql/infra/transformer.rs index 4ee72fc..619bba3 100644 --- a/apps/recorder/src/graphql/infra/transformer.rs +++ b/apps/recorder/src/graphql/infra/transformer.rs @@ -139,7 +139,7 @@ fn add_crypto_column_output_conversion( ); } -pub fn crypto_transformer(context: &mut BuilderContext, ctx: Arc) { +pub fn add_crypto_transformers(context: &mut BuilderContext, ctx: Arc) { add_crypto_column_input_conversion::( context, ctx.clone(), @@ -150,7 +150,7 @@ pub fn crypto_transformer(context: &mut BuilderContext, ctx: Arc( + add_crypto_column_input_conversion::( context, ctx.clone(), &credential_3rd::Column::Password, diff --git a/apps/recorder/src/graphql/schema.rs b/apps/recorder/src/graphql/schema.rs index acdfd31..7df60e3 100644 --- a/apps/recorder/src/graphql/schema.rs +++ b/apps/recorder/src/graphql/schema.rs @@ -1,21 +1,27 @@ +use std::sync::Arc; + use async_graphql::dynamic::*; use once_cell::sync::OnceCell; -use sea_orm::{DatabaseConnection, EntityTrait, Iterable}; +use sea_orm::{EntityTrait, Iterable}; use seaography::{Builder, BuilderContext, FilterType, FilterTypesMapHelper}; -use crate::graphql::{ - infra::{ - filter::{ - JSONB_FILTER_NAME, SUBSCRIBER_ID_FILTER_INFO, init_custom_filter_info, - register_jsonb_input_filter_to_dynamic_schema, subscriber_id_condition_function, +use crate::{ + app::AppContextTrait, + graphql::{ + infra::{ + filter::{ + JSONB_FILTER_NAME, SUBSCRIBER_ID_FILTER_INFO, init_custom_filter_info, + register_jsonb_input_filter_to_dynamic_schema, subscriber_id_condition_function, + }, + guard::{guard_entity_with_subscriber_id, guard_field_with_subscriber_id}, + transformer::{ + add_crypto_transformers, build_filter_condition_transformer, + build_mutation_input_object_transformer, + }, + util::{get_entity_column_key, get_entity_key}, }, - guard::{guard_entity_with_subscriber_id, guard_field_with_subscriber_id}, - transformer::{ - build_filter_condition_transformer, build_mutation_input_object_transformer, - }, - util::{get_entity_column_key, get_entity_key}, + views::register_subscriptions_to_schema, }, - views::register_subscriptions_to_schema, }; pub static CONTEXT: OnceCell = OnceCell::new(); @@ -88,11 +94,13 @@ where } pub fn build_schema( - database: DatabaseConnection, + app_ctx: Arc, depth: Option, complexity: Option, ) -> Result { use crate::models::*; + let database = app_ctx.db().as_ref().clone(); + init_custom_filter_info(); let context = CONTEXT.get_or_init(|| { let mut context = BuilderContext::default(); @@ -148,6 +156,7 @@ pub fn build_schema( &mut context, &subscriber_tasks::Column::Job, ); + add_crypto_transformers(&mut context, app_ctx); for column in subscribers::Column::iter() { if !matches!(column, subscribers::Column::Id) { restrict_filter_input_for_entity::( @@ -159,6 +168,7 @@ pub fn build_schema( } context }); + let mut builder = Builder::new(context, database.clone()); { diff --git a/apps/recorder/src/graphql/service.rs b/apps/recorder/src/graphql/service.rs index da3958f..4ef25d9 100644 --- a/apps/recorder/src/graphql/service.rs +++ b/apps/recorder/src/graphql/service.rs @@ -1,8 +1,9 @@ +use std::sync::Arc; + use async_graphql::dynamic::Schema; -use sea_orm::DatabaseConnection; use super::{build_schema, config::GraphQLConfig}; -use crate::errors::RecorderResult; +use crate::{app::AppContextTrait, errors::RecorderResult}; #[derive(Debug)] pub struct GraphQLService { @@ -10,12 +11,12 @@ pub struct GraphQLService { } impl GraphQLService { - pub async fn from_config_and_database( + pub async fn from_config_and_ctx( config: GraphQLConfig, - db: DatabaseConnection, + ctx: Arc, ) -> RecorderResult { let schema = build_schema( - db, + ctx, config.depth_limit.and_then(|l| l.into()), config.complexity_limit.and_then(|l| l.into()), )?; diff --git a/apps/recorder/src/web/middleware/snapshots/recorder__web__middleware__request_id__tests__create_or_fetch_request_id.snap.new b/apps/recorder/src/web/middleware/snapshots/recorder__web__middleware__request_id__tests__create_or_fetch_request_id.snap.new new file mode 100644 index 0000000..ecbb0f4 --- /dev/null +++ b/apps/recorder/src/web/middleware/snapshots/recorder__web__middleware__request_id__tests__create_or_fetch_request_id.snap.new @@ -0,0 +1,6 @@ +--- +source: apps/recorder/src/web/middleware/request_id.rs +assertion_line: 126 +expression: id +--- +"foo-barbaz" diff --git a/apps/webui/package.json b/apps/webui/package.json index 7ff6a35..c1bf8a6 100644 --- a/apps/webui/package.json +++ b/apps/webui/package.json @@ -47,10 +47,12 @@ "@radix-ui/react-toggle-group": "^1.1.9", "@radix-ui/react-tooltip": "^1.2.6", "@rsbuild/plugin-react": "^1.2.0", + "@tanstack/react-form": "^1.12.1", "@tanstack/react-query": "^5.75.6", "@tanstack/react-router": "^1.112.13", "@tanstack/react-table": "^8.21.3", "@tanstack/router-devtools": "^1.112.13", + "@tanstack/store": "^0.7.1", "arktype": "^2.1.6", "chart.js": "^4.4.8", "class-variance-authority": "^0.7.1", @@ -63,7 +65,7 @@ "input-otp": "^1.4.2", "jotai": "^2.12.3", "jotai-signal": "^0.9.0", - "lucide-react": "^0.509.0", + "lucide-react": "^0.512.0", "oidc-client-rx": "0.1.0-alpha.9", "react": "^19.1.0", "react-day-picker": "9.6.0", @@ -77,8 +79,7 @@ "tailwindcss": "^4.0.6", "tw-animate-css": "^1.2.7", "type-fest": "^4.40.0", - "vaul": "^1.1.2", - "zod": "^3.24.4" + "vaul": "^1.1.2" }, "devDependencies": { "@graphql-codegen/cli": "^5.0.6", @@ -94,7 +95,7 @@ "@types/react": "^19.1.2", "@types/react-dom": "^19.1.2", "chalk": "^5.4.1", - "commander": "^13.1.0", + "commander": "^14.0.0", "postcss": "^8.5.3" } } diff --git a/apps/webui/src/components/ui/data-table-view-options.tsx b/apps/webui/src/components/ui/data-table-view-options.tsx index 719fbbb..1de9ca1 100644 --- a/apps/webui/src/components/ui/data-table-view-options.tsx +++ b/apps/webui/src/components/ui/data-table-view-options.tsx @@ -23,11 +23,7 @@ export function DataTableViewOptions({ return ( - diff --git a/apps/webui/src/components/ui/form-field-errors.tsx b/apps/webui/src/components/ui/form-field-errors.tsx new file mode 100644 index 0000000..2490a98 --- /dev/null +++ b/apps/webui/src/components/ui/form-field-errors.tsx @@ -0,0 +1,53 @@ +import { StandardSchemaV1Issue } from "@tanstack/react-form"; +import { AlertCircle } from "lucide-react"; +import { useMemo } from "react"; + +interface ErrorDisplayProps { + errors?: + | string + | StandardSchemaV1Issue + | Array; +} + +export function FormFieldErrors({ errors }: ErrorDisplayProps) { + const errorList = useMemo( + () => + (Array.isArray(errors) ? errors : [errors]).filter(Boolean) as Array< + string | StandardSchemaV1Issue + >, + [errors] + ); + + if (!errorList.length) { + return null; + } + + return ( +
    + {errorList.map((error, index) => { + if (typeof error === "string") { + return ( +
  • + + {error} +
  • + ); + } + return ( +
  • +
    + + {error.message} +
    +
  • + ); + })} +
+ ); +} diff --git a/apps/webui/src/components/ui/label.tsx b/apps/webui/src/components/ui/label.tsx index a1ce58f..48f384c 100644 --- a/apps/webui/src/components/ui/label.tsx +++ b/apps/webui/src/components/ui/label.tsx @@ -3,7 +3,7 @@ import * as LabelPrimitive from "@radix-ui/react-label"; import * as React from "react"; -import { cn } from "@/presentation/utils"; +import { cn } from "@/presentation/utils/index"; function Label({ className, diff --git a/apps/webui/src/components/ui/tanstack-form.tsx b/apps/webui/src/components/ui/tanstack-form.tsx new file mode 100644 index 0000000..43fabc3 --- /dev/null +++ b/apps/webui/src/components/ui/tanstack-form.tsx @@ -0,0 +1,143 @@ +import { Slot } from "@radix-ui/react-slot"; +import * as React from "react"; + +import { Label } from "@/components/ui/label"; +import { cn } from "@/presentation/utils"; +import { + createFormHook, + createFormHookContexts, + useStore, +} from "@tanstack/react-form"; + +const { + fieldContext, + formContext, + useFieldContext: useFormFieldContext, + useFormContext, +} = createFormHookContexts(); + +const { useAppForm, withForm } = createFormHook({ + fieldContext, + formContext, + fieldComponents: { + FormLabel, + FormControl, + FormDescription, + FormMessage, + FormItem, + }, + formComponents: {}, +}); + +type FormItemContextValue = { + id: string; +}; + +const FormItemContext = React.createContext( + {} as FormItemContextValue +); + +function FormItem({ className, ...props }: React.ComponentProps<"div">) { + const id = React.useId(); + + return ( + +
+ + ); +} + +const useFieldContext = () => { + const { id } = React.useContext(FormItemContext); + const { name, store, ...fieldContext } = useFormFieldContext(); + + const errors = useStore(store, (state) => state.meta.errors); + if (!fieldContext) { + throw new Error("useFieldContext should be used within "); + } + + return { + id, + name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + errors, + store, + ...fieldContext, + }; +}; + +function FormLabel({ + className, + ...props +}: React.ComponentProps) { + const { formItemId, errors } = useFieldContext(); + + return ( +