From f7fcbc9aadc91381fc486731610ac82c124c6bba Mon Sep 17 00:00:00 2001 From: Isala Piyarisi Date: Sat, 30 May 2026 12:18:51 +0000 Subject: [PATCH 1/6] refactor(macros): consolidate duplicated attribute parsing into attrs.rs The 10 attribute macros each copy-pasted the same darling parse scaffold, duration parsing, and rate-limit extraction. Extract three shared helpers (parse_attrs, parse_optional_duration, extract_rate_limit) and route query/mutation/job/mcp_tool/daemon/workflow through them. Removes ~120 lines of duplication and a redundant rate-limit-key re-check in mcp_tool, with no behavior change. --- crates/forge-macros/src/attrs.rs | 46 +++++++++++++++++++++++++++ crates/forge-macros/src/daemon.rs | 10 ++---- crates/forge-macros/src/job.rs | 10 ++---- crates/forge-macros/src/mcp_tool.rs | 49 ++++------------------------- crates/forge-macros/src/mutation.rs | 37 ++++------------------ crates/forge-macros/src/query.rs | 48 +++++----------------------- crates/forge-macros/src/workflow.rs | 10 ++---- 7 files changed, 71 insertions(+), 139 deletions(-) diff --git a/crates/forge-macros/src/attrs.rs b/crates/forge-macros/src/attrs.rs index ef8a7f8a..2d939ffd 100644 --- a/crates/forge-macros/src/attrs.rs +++ b/crates/forge-macros/src/attrs.rs @@ -5,12 +5,58 @@ //! converts from the darling struct to its internal representation. use darling::FromMeta; +use darling::ast::NestedMeta; +use proc_macro::TokenStream; /// Default value function for darling `#[darling(default = "default_true")]`. pub fn default_true() -> bool { true } +/// Parse raw macro-attribute tokens into a darling attribute struct. +/// +/// Centralizes the parse-then-`from_list` dance every attribute macro performs. +/// On failure it returns the compile-error `TokenStream` so callers can simply +/// `return` it from their `expand_*` entry point. +pub fn parse_attrs(attr: TokenStream) -> Result { + let nested = NestedMeta::parse_meta_list(attr.into()) + .map_err(|e| TokenStream::from(e.into_compile_error()))?; + T::from_list(&nested).map_err(|e| TokenStream::from(e.write_errors())) +} + +/// Parse an optional duration attribute (e.g. `cache`, `timeout`) into seconds. +/// +/// `label` names the attribute in the error message (e.g. "timeout" or +/// "cache duration"). Bare integers without a unit suffix are rejected. +pub fn parse_optional_duration(value: &Option, label: &str) -> syn::Result> { + match value { + Some(s) => crate::utils::parse_duration_secs(s).map(Some).ok_or_else(|| { + syn::Error::new( + proc_macro2::Span::call_site(), + format!( + "invalid {label} \"{s}\": use a duration string like \"30s\", \"5m\", or \"1h\"" + ), + ) + }), + None => Ok(None), + } +} + +/// Validate an optional `rate_limit(...)` attribute and extract the +/// `(requests, per_secs, key)` triple. Returns all-`None` when absent. +pub fn extract_rate_limit( + rate_limit: &Option, +) -> syn::Result<(Option, Option, Option)> { + match rate_limit { + Some(rl) => { + validate_rate_limit(rl)?; + let per = parse_rate_limit_per(rl)?; + Ok((rl.requests, per, rl.key.clone())) + } + None => Ok((None, None, None)), + } +} + /// Rate limit configuration shared between query, mutation, and mcp_tool macros. /// /// Parses `rate_limit(requests = 100, per = "1m", key = "user")`. diff --git a/crates/forge-macros/src/daemon.rs b/crates/forge-macros/src/daemon.rs index 665260a8..835ab3a8 100644 --- a/crates/forge-macros/src/daemon.rs +++ b/crates/forge-macros/src/daemon.rs @@ -3,7 +3,6 @@ use quote::{format_ident, quote}; use syn::{ItemFn, parse_macro_input}; use darling::FromMeta; -use darling::ast::NestedMeta; use crate::attrs::default_true; use crate::utils::{parse_duration_tokens, to_pascal_case}; @@ -48,14 +47,9 @@ pub fn daemon_impl(attr: TokenStream, item: TokenStream) -> TokenStream { let forge = crate::utils::forge_path(); let input = parse_macro_input!(item as ItemFn); - let attr_args = match NestedMeta::parse_meta_list(attr.into()) { + let darling_attrs = match crate::attrs::parse_attrs::(attr) { Ok(v) => v, - Err(e) => return TokenStream::from(e.into_compile_error()), - }; - - let darling_attrs = match DarlingDaemonAttrs::from_list(&attr_args) { - Ok(v) => v, - Err(e) => return TokenStream::from(e.write_errors()), + Err(ts) => return ts, }; let attrs = DaemonAttrs { diff --git a/crates/forge-macros/src/job.rs b/crates/forge-macros/src/job.rs index ffa8edd2..8f5efd70 100644 --- a/crates/forge-macros/src/job.rs +++ b/crates/forge-macros/src/job.rs @@ -3,7 +3,6 @@ use quote::{format_ident, quote}; use syn::{ItemFn, parse_macro_input}; use darling::FromMeta; -use darling::ast::NestedMeta; use crate::attrs::{IdempotentMeta, RequireRole, RetryMeta, default_true, reject_reserved}; use crate::utils::{parse_duration_tokens, to_pascal_case}; @@ -132,14 +131,9 @@ pub fn job_impl(attr: TokenStream, item: TokenStream) -> TokenStream { let forge = crate::utils::forge_path(); let input = parse_macro_input!(item as ItemFn); - let attr_args = match NestedMeta::parse_meta_list(attr.into()) { + let darling_attrs = match crate::attrs::parse_attrs::(attr) { Ok(v) => v, - Err(e) => return TokenStream::from(e.into_compile_error()), - }; - - let darling_attrs = match DarlingJobAttrs::from_list(&attr_args) { - Ok(v) => v, - Err(e) => return TokenStream::from(e.write_errors()), + Err(ts) => return ts, }; let attrs = convert_job_attrs(darling_attrs); diff --git a/crates/forge-macros/src/mcp_tool.rs b/crates/forge-macros/src/mcp_tool.rs index d14dc03e..5a2e6d30 100644 --- a/crates/forge-macros/src/mcp_tool.rs +++ b/crates/forge-macros/src/mcp_tool.rs @@ -4,13 +4,9 @@ use quote::quote; use syn::{FnArg, ItemFn, Pat, ReturnType, Type, parse_macro_input}; use darling::FromMeta; -use darling::ast::NestedMeta; -use crate::attrs::{ - RateLimitMeta, RequireRole, default_true, parse_rate_limit_per, validate_rate_limit, - validate_rate_limit_key, -}; -use crate::utils::{parse_duration_secs, to_pascal_case}; +use crate::attrs::{RateLimitMeta, RequireRole, default_true}; +use crate::utils::to_pascal_case; /// Darling-parsed MCP tool attributes. #[derive(Debug, FromMeta)] @@ -45,14 +41,9 @@ struct DarlingMcpToolAttrs { pub fn expand_mcp_tool(attr: TokenStream, item: TokenStream) -> TokenStream { let input = parse_macro_input!(item as ItemFn); - let attr_args = match NestedMeta::parse_meta_list(attr.into()) { + let darling_attrs = match crate::attrs::parse_attrs::(attr) { Ok(v) => v, - Err(e) => return TokenStream::from(e.into_compile_error()), - }; - - let darling_attrs = match DarlingMcpToolAttrs::from_list(&attr_args) { - Ok(v) => v, - Err(e) => return TokenStream::from(e.write_errors()), + Err(ts) => return ts, }; let attrs = match convert_mcp_tool_attrs(darling_attrs) { @@ -84,37 +75,9 @@ struct McpToolAttrs { } fn convert_mcp_tool_attrs(darling: DarlingMcpToolAttrs) -> Result { - // Require a unit suffix on timeouts to match every other macro. Bare - // integers like `timeout = "30"` are ambiguous (seconds? milliseconds?) - // and were only accepted here historically. - let timeout = match darling.timeout { - Some(ref s) => match parse_duration_secs(s) { - Some(t) => Some(t), - None => { - return Err(syn::Error::new( - proc_macro2::Span::call_site(), - format!( - "invalid timeout \"{s}\": use a duration string like \"30s\", \"5m\", or \"1h\"" - ), - )); - } - }, - None => None, - }; - + let timeout = crate::attrs::parse_optional_duration(&darling.timeout, "timeout")?; let (rate_limit_requests, rate_limit_per_secs, rate_limit_key) = - if let Some(ref rl) = darling.rate_limit { - validate_rate_limit(rl)?; - let per = parse_rate_limit_per(rl)?; - if let Some(ref key) = rl.key - && let Err(msg) = validate_rate_limit_key(key) - { - return Err(syn::Error::new(proc_macro2::Span::call_site(), msg)); - } - (rl.requests, per, rl.key.clone()) - } else { - (None, None, None) - }; + crate::attrs::extract_rate_limit(&darling.rate_limit)?; Ok(McpToolAttrs { name: darling.name, diff --git a/crates/forge-macros/src/mutation.rs b/crates/forge-macros/src/mutation.rs index 5e0a8e29..62562dfe 100644 --- a/crates/forge-macros/src/mutation.rs +++ b/crates/forge-macros/src/mutation.rs @@ -5,18 +5,14 @@ use syn::visit::Visit; use syn::{FnArg, ItemFn, Pat, ReturnType, Type, parse_macro_input}; use darling::FromMeta; -use darling::ast::NestedMeta; -use crate::attrs::{ - RateLimitMeta, RequireRole, TablesList, default_true, parse_rate_limit_per, reject_reserved, - validate_rate_limit, -}; +use crate::attrs::{RateLimitMeta, RequireRole, TablesList, default_true, reject_reserved}; use crate::sql_extractor::{ DbDelegationDetector, ScopeCheckResult, SqlStringExtractor, TableExtractionResult, extract_changed_columns_from_sql, extract_tables_from_sql, sql_references_identity_scope, sql_scope_requires_tenant, }; -use crate::utils::{parse_duration_secs, parse_size_bytes, to_pascal_case}; +use crate::utils::{parse_size_bytes, to_pascal_case}; /// Attribute keys whose names are reserved for upcoming mutation-coalescing /// support (cursor positions, autosave, etc). Using one today is a hard @@ -154,14 +150,9 @@ impl Default for MutationAttrs { pub fn expand_mutation(attr: TokenStream, item: TokenStream) -> TokenStream { let input = parse_macro_input!(item as ItemFn); - let attr_args = match NestedMeta::parse_meta_list(attr.into()) { + let darling_attrs = match crate::attrs::parse_attrs::(attr) { Ok(v) => v, - Err(e) => return TokenStream::from(e.into_compile_error()), - }; - - let darling_attrs = match DarlingMutationAttrs::from_list(&attr_args) { - Ok(v) => v, - Err(e) => return TokenStream::from(e.write_errors()), + Err(ts) => return ts, }; let attrs = match convert_mutation_attrs(darling_attrs) { @@ -175,25 +166,9 @@ pub fn expand_mutation(attr: TokenStream, item: TokenStream) -> TokenStream { } fn convert_mutation_attrs(darling: DarlingMutationAttrs) -> Result { - let timeout = match darling.timeout { - Some(ref s) => Some(parse_duration_secs(s).ok_or_else(|| { - syn::Error::new( - proc_macro2::Span::call_site(), - format!( - "invalid timeout \"{s}\": use a duration string like \"30s\", \"5m\", or \"1h\"" - ), - ) - })?), - None => None, - }; - + let timeout = crate::attrs::parse_optional_duration(&darling.timeout, "timeout")?; let (rate_limit_requests, rate_limit_per_secs, rate_limit_key) = - if let Some(ref rl) = darling.rate_limit { - validate_rate_limit(rl)?; - (rl.requests, parse_rate_limit_per(rl)?, rl.key.clone()) - } else { - (None, None, None) - }; + crate::attrs::extract_rate_limit(&darling.rate_limit)?; Ok(MutationAttrs { name: darling.name, diff --git a/crates/forge-macros/src/query.rs b/crates/forge-macros/src/query.rs index a6503c91..0b5c81cd 100644 --- a/crates/forge-macros/src/query.rs +++ b/crates/forge-macros/src/query.rs @@ -5,18 +5,14 @@ use syn::visit::Visit; use syn::{FnArg, ItemFn, Pat, ReturnType, Type, parse_macro_input}; use darling::FromMeta; -use darling::ast::NestedMeta; -use crate::attrs::{ - RateLimitMeta, RequireRole, TablesList, default_true, parse_rate_limit_per, reject_reserved, - validate_rate_limit, -}; +use crate::attrs::{RateLimitMeta, RequireRole, TablesList, default_true, reject_reserved}; use crate::sql_extractor::{ DbDelegationDetector, ScopeCheckResult, SqlStringExtractor, TableExtractionResult, extract_columns_from_sql, extract_tables_from_sql, sql_references_identity_scope, sql_scope_requires_tenant, }; -use crate::utils::{parse_duration_secs, to_pascal_case}; +use crate::utils::to_pascal_case; /// Attribute keys whose names are reserved for upcoming reactor and /// result-guardrail features. Using one today is a hard compile error @@ -139,14 +135,9 @@ struct QueryAttrs { pub fn expand_query(attr: TokenStream, item: TokenStream) -> TokenStream { let input = parse_macro_input!(item as ItemFn); - let attr_args = match NestedMeta::parse_meta_list(attr.into()) { - Ok(v) => v, - Err(e) => return TokenStream::from(e.into_compile_error()), - }; - - let darling_attrs = match DarlingQueryAttrs::from_list(&attr_args) { + let darling_attrs = match crate::attrs::parse_attrs::(attr) { Ok(v) => v, - Err(e) => return TokenStream::from(e.write_errors()), + Err(ts) => return ts, }; let attrs = match convert_query_attrs(darling_attrs) { @@ -160,35 +151,10 @@ pub fn expand_query(attr: TokenStream, item: TokenStream) -> TokenStream { } fn convert_query_attrs(darling: DarlingQueryAttrs) -> Result { - let cache_ttl = match darling.cache { - Some(ref s) => Some(parse_duration_secs(s).ok_or_else(|| { - syn::Error::new( - proc_macro2::Span::call_site(), - format!("invalid cache duration \"{s}\": use a duration string like \"30s\", \"5m\", or \"1h\""), - ) - })?), - None => None, - }; - - let timeout = match darling.timeout { - Some(ref s) => Some(parse_duration_secs(s).ok_or_else(|| { - syn::Error::new( - proc_macro2::Span::call_site(), - format!( - "invalid timeout \"{s}\": use a duration string like \"30s\", \"5m\", or \"1h\"" - ), - ) - })?), - None => None, - }; - + let cache_ttl = crate::attrs::parse_optional_duration(&darling.cache, "cache duration")?; + let timeout = crate::attrs::parse_optional_duration(&darling.timeout, "timeout")?; let (rate_limit_requests, rate_limit_per_secs, rate_limit_key) = - if let Some(ref rl) = darling.rate_limit { - validate_rate_limit(rl)?; - (rl.requests, parse_rate_limit_per(rl)?, rl.key.clone()) - } else { - (None, None, None) - }; + crate::attrs::extract_rate_limit(&darling.rate_limit)?; Ok(QueryAttrs { name: darling.name, diff --git a/crates/forge-macros/src/workflow.rs b/crates/forge-macros/src/workflow.rs index 9abe894a..f3bc225a 100644 --- a/crates/forge-macros/src/workflow.rs +++ b/crates/forge-macros/src/workflow.rs @@ -6,7 +6,6 @@ use syn::{ExprAwait, ExprCall, ItemFn, Lit, parse_macro_input}; use std::collections::BTreeSet; use darling::FromMeta; -use darling::ast::NestedMeta; use crate::attrs::{RequireRole, default_true}; use crate::utils::{parse_duration_tokens, to_pascal_case}; @@ -466,14 +465,9 @@ pub fn workflow_impl(attr: TokenStream, item: TokenStream) -> TokenStream { let forge = crate::utils::forge_path(); let input = parse_macro_input!(item as ItemFn); - let attr_args = match NestedMeta::parse_meta_list(attr.into()) { + let darling_attrs = match crate::attrs::parse_attrs::(attr) { Ok(v) => v, - Err(e) => return TokenStream::from(e.into_compile_error()), - }; - - let darling_attrs = match DarlingWorkflowAttrs::from_list(&attr_args) { - Ok(v) => v, - Err(e) => return TokenStream::from(e.write_errors()), + Err(ts) => return ts, }; let attrs = convert_workflow_attrs(darling_attrs); From 2aec95174d1953084dbd3fbc964f504437cdd7b4 Mon Sep 17 00:00:00 2001 From: Isala Piyarisi Date: Sat, 30 May 2026 12:26:21 +0000 Subject: [PATCH 2/6] refactor(core): generate sealed context impls via declarative macros The HandlerContext/AuthenticatedContext/Sealed impls were 120+ lines of hand-written pass-through boilerplate across 8 context types. Replace with three small macro_rules! generators, keeping MutationContext's bespoke db() explicit. Same impls, ~90 fewer lines. --- crates/forge-core/src/context.rs | 156 +++++++++++-------------------- 1 file changed, 52 insertions(+), 104 deletions(-) diff --git a/crates/forge-core/src/context.rs b/crates/forge-core/src/context.rs index 8f813a39..754afbaf 100644 --- a/crates/forge-core/src/context.rs +++ b/crates/forge-core/src/context.rs @@ -72,21 +72,33 @@ pub trait AuthenticatedContext: HandlerContext { fn tenant_id(&self) -> Option; } -impl HandlerContext for crate::function::QueryContext { - fn db(&self) -> ForgeDb { - self.db() - } - - fn db_conn(&self) -> DbConn<'_> { - self.db_conn() - } +/// Forward [`HandlerContext`] to each context type's inherent `db()`/`db_conn()`. +macro_rules! impl_handler_context { + ($($ty:ty),+ $(,)?) => { + $( + impl HandlerContext for $ty { + fn db(&self) -> ForgeDb { self.db() } + fn db_conn(&self) -> DbConn<'_> { self.db_conn() } + } + )+ + }; } +impl_handler_context!( + crate::function::QueryContext, + crate::job::JobContext, + crate::cron::CronContext, + crate::daemon::DaemonContext, + crate::webhook::WebhookContext, + crate::workflow::WorkflowContext, + crate::mcp::McpToolContext, +); + +// MutationContext is the one exception: its inherent `db()` returns a +// transaction-bound handle, so the trait impl exposes the pool-backed view +// that intentionally bypasses the active transaction. impl HandlerContext for crate::function::MutationContext { fn db(&self) -> ForgeDb { - // MutationContext::tx() returns DbConn, not ForgeDb. - // For HandlerContext we expose the pool-backed ForgeDb view, which - // intentionally bypasses the active transaction. crate::function::ForgeDb::from_pool(self.pool_outside_transaction()) } @@ -95,101 +107,37 @@ impl HandlerContext for crate::function::MutationContext { } } -impl HandlerContext for crate::job::JobContext { - fn db(&self) -> ForgeDb { - self.db() - } - - fn db_conn(&self) -> DbConn<'_> { - self.db_conn() - } -} - -impl HandlerContext for crate::cron::CronContext { - fn db(&self) -> ForgeDb { - self.db() - } - - fn db_conn(&self) -> DbConn<'_> { - self.db_conn() - } -} - -impl HandlerContext for crate::daemon::DaemonContext { - fn db(&self) -> ForgeDb { - self.db() - } - - fn db_conn(&self) -> DbConn<'_> { - self.db_conn() - } -} - -impl HandlerContext for crate::webhook::WebhookContext { - fn db(&self) -> ForgeDb { - self.db() - } - - fn db_conn(&self) -> DbConn<'_> { - self.db_conn() - } -} - -impl HandlerContext for crate::workflow::WorkflowContext { - fn db(&self) -> ForgeDb { - self.db() - } - - fn db_conn(&self) -> DbConn<'_> { - self.db_conn() - } -} - -impl HandlerContext for crate::mcp::McpToolContext { - fn db(&self) -> ForgeDb { - self.db() - } - - fn db_conn(&self) -> DbConn<'_> { - self.db_conn() - } -} - -impl AuthenticatedContext for crate::function::QueryContext { - fn user_id(&self) -> crate::error::Result { - self.user_id() - } - - fn tenant_id(&self) -> Option { - self.tenant_id() - } -} - -impl AuthenticatedContext for crate::function::MutationContext { - fn user_id(&self) -> crate::error::Result { - self.user_id() - } - - fn tenant_id(&self) -> Option { - self.tenant_id() - } +/// Forward [`AuthenticatedContext`] to each context type's inherent accessors. +macro_rules! impl_authenticated_context { + ($($ty:ty),+ $(,)?) => { + $( + impl AuthenticatedContext for $ty { + fn user_id(&self) -> crate::error::Result { self.user_id() } + fn tenant_id(&self) -> Option { self.tenant_id() } + } + )+ + }; } -impl AuthenticatedContext for crate::mcp::McpToolContext { - fn user_id(&self) -> crate::error::Result { - self.user_id() - } +impl_authenticated_context!( + crate::function::QueryContext, + crate::function::MutationContext, + crate::mcp::McpToolContext, +); - fn tenant_id(&self) -> Option { - self.tenant_id() - } +macro_rules! impl_sealed { + ($($ty:ty),+ $(,)?) => { + $( impl Sealed for $ty {} )+ + }; } -impl Sealed for crate::function::QueryContext {} -impl Sealed for crate::function::MutationContext {} -impl Sealed for crate::job::JobContext {} -impl Sealed for crate::cron::CronContext {} -impl Sealed for crate::daemon::DaemonContext {} -impl Sealed for crate::webhook::WebhookContext {} -impl Sealed for crate::workflow::WorkflowContext {} -impl Sealed for crate::mcp::McpToolContext {} +impl_sealed!( + crate::function::QueryContext, + crate::function::MutationContext, + crate::job::JobContext, + crate::cron::CronContext, + crate::daemon::DaemonContext, + crate::webhook::WebhookContext, + crate::workflow::WorkflowContext, + crate::mcp::McpToolContext, +); From cef99240c19606c7063524159b39ce12759bb0ba Mon Sep 17 00:00:00 2001 From: Isala Piyarisi Date: Sat, 30 May 2026 12:29:21 +0000 Subject: [PATCH 3/6] refactor(svelte): extract shared store core from duplicated pub-sub All five stores (connection/query/subscription/job/workflow) plus the optimistic-mutation helper hand-rolled the same Set+notify()+ subscribe() plumbing. Introduce createStoreCore (get/set/update/subscribe with an onLastUnsubscribe hook) and route every store through it. Teardown and callback-ordering semantics preserved exactly; tsc --strict clean. --- packages/forge-svelte/stores.ts | 284 +++++++++++++------------------- 1 file changed, 111 insertions(+), 173 deletions(-) diff --git a/packages/forge-svelte/stores.ts b/packages/forge-svelte/stores.ts index 92032b2f..840428a9 100644 --- a/packages/forge-svelte/stores.ts +++ b/packages/forge-svelte/stores.ts @@ -37,28 +37,51 @@ export interface WorkflowStore extends Readable unsubscribe: () => void; } -export function createConnectionStore(): ConnectionStatusStore { - const client = getForgeClient(); - const subscribers = new Set<(value: ConnectionState) => void>(); - let currentState: ConnectionState = client.getConnectionState(); - - client.onConnectionStateChange((state: ConnectionState) => { - currentState = state; - subscribers.forEach((run) => run(state)); - }); +/** Shared subscriber bookkeeping for every store: holds the current value, + * notifies subscribers on change, and fires `onLastUnsubscribe` when the final + * subscriber detaches (so stores can release server-side subscriptions). */ +interface StoreCore { + get(): T; + set(value: T): void; + update(fn: (prev: T) => T): void; + subscribe(run: (value: T) => void, onLastUnsubscribe?: () => void): () => void; +} +function createStoreCore(initial: T): StoreCore { + const subscribers = new Set<(value: T) => void>(); + let state = initial; + const notify = () => subscribers.forEach((run) => run(state)); return { - subscribe(run) { - subscribers.add(run); - run(currentState); - return () => subscribers.delete(run); + get: () => state, + set(value) { + state = value; + notify(); + }, + update(fn) { + state = fn(state); + notify(); }, - get() { - return currentState; + subscribe(run, onLastUnsubscribe) { + subscribers.add(run); + run(state); + return () => { + subscribers.delete(run); + if (subscribers.size === 0) onLastUnsubscribe?.(); + }; }, }; } +export function createConnectionStore(): ConnectionStatusStore { + const client = getForgeClient(); + const core = createStoreCore(client.getConnectionState()); + client.onConnectionStateChange((state) => core.set(state)); + return { + subscribe: (run) => core.subscribe(run), + get: () => core.get(), + }; +} + type RejectEmptyObject = T extends Record ? never : T; /** Optional runtime validator for store payloads. Return null to surface a @@ -78,46 +101,34 @@ export function createQueryStore( options?: StoreOptions, ): QueryStore { const client = getForgeClient(); - const subscribers = new Set<(value: QueryResult) => void>(); - let state: QueryResult = { + const core = createStoreCore>({ loading: true, data: null, error: null, - }; - - const notify = () => subscribers.forEach((run) => run(state)); + }); const fetchData = async () => { - state = { ...state, loading: true, error: null }; - notify(); + core.set({ ...core.get(), loading: true, error: null }); try { const raw = await client.call(functionName, args); const data = options?.validate ? options.validate(raw) : (raw as TResult); if (data === null && options?.validate) { - state = { loading: false, data: null, error: VALIDATION_ERROR }; + core.set({ loading: false, data: null, error: VALIDATION_ERROR }); } else { - state = { loading: false, data: data as TResult, error: null }; + core.set({ loading: false, data: data as TResult, error: null }); } } catch (e) { - state = { loading: false, data: null, error: e as ForgeError }; + core.set({ loading: false, data: null, error: e as ForgeError }); } - notify(); }; fetchData(); return { - subscribe(run) { - subscribers.add(run); - run(state); - return () => subscribers.delete(run); - }, + subscribe: (run) => core.subscribe(run), refetch: fetchData, - reset: () => { - state = { loading: true, data: null, error: null }; - notify(); - }, + reset: () => core.set({ loading: true, data: null, error: null }), }; } @@ -127,17 +138,14 @@ export function createSubscriptionStore( options?: StoreOptions, ): SubscriptionStore { const client = getForgeClient(); - const subscribers = new Set<(value: SubscriptionResult) => void>(); - let unsubscribeFn: (() => void) | null = null; - let subscriptionId: string | null = null; - let state: SubscriptionResult = { + const core = createStoreCore>({ loading: true, data: null, error: null, stale: false, - }; - - const notify = () => subscribers.forEach((run) => run(state)); + }); + let unsubscribeFn: (() => void) | null = null; + let subscriptionId: string | null = null; const startSubscription = async () => { if (unsubscribeFn) { @@ -148,8 +156,7 @@ export function createSubscriptionStore( client._unregisterQuery(subscriptionId); } - state = { ...state, loading: true, error: null, stale: false }; - notify(); + core.set({ ...core.get(), loading: true, error: null, stale: false }); try { subscriptionId = crypto.randomUUID(); @@ -163,44 +170,37 @@ export function createSubscriptionStore( unsubscribeFn = client._subscribe(`sub:${subscriptionId}`, (raw: unknown) => { const data = options?.validate ? options.validate(raw) : (raw as TResult); if (data === null && options?.validate) { - state = { loading: false, data: null, error: VALIDATION_ERROR, stale: false }; + core.set({ loading: false, data: null, error: VALIDATION_ERROR, stale: false }); } else { - state = { loading: false, data: data as TResult, error: null, stale: false }; + core.set({ loading: false, data: data as TResult, error: null, stale: false }); } - notify(); }); const initialRaw = await client._registerQuery(subscriptionId, functionName, args); const initial = options?.validate ? options.validate(initialRaw) : (initialRaw as TResult); if (initial === null && options?.validate) { - state = { loading: false, data: null, error: VALIDATION_ERROR, stale: false }; + core.set({ loading: false, data: null, error: VALIDATION_ERROR, stale: false }); } else { - state = { loading: false, data: initial as TResult, error: null, stale: false }; + core.set({ loading: false, data: initial as TResult, error: null, stale: false }); } - notify(); } catch (e) { - state = { loading: false, data: null, error: e as ForgeError, stale: false }; - notify(); + core.set({ loading: false, data: null, error: e as ForgeError, stale: false }); } }; startSubscription(); return { - subscribe(run) { - subscribers.add(run); - run(state); - return () => { - subscribers.delete(run); - if (subscribers.size === 0 && unsubscribeFn) { + subscribe: (run) => + core.subscribe(run, () => { + if (unsubscribeFn) { unsubscribeFn(); unsubscribeFn = null; if (subscriptionId) { client._unregisterQuery(subscriptionId); } } - }; - }, + }), refetch: startSubscription, unsubscribe: () => { if (unsubscribeFn) { @@ -212,10 +212,7 @@ export function createSubscriptionStore( subscriptionId = null; } }, - reset: () => { - state = { loading: true, data: null, error: null, stale: false }; - notify(); - }, + reset: () => core.set({ loading: true, data: null, error: null, stale: false }), }; } @@ -248,10 +245,7 @@ export function createJobStore( args: RejectEmptyObject ): JobStore { const client = getForgeClient(); - const subscribers = new Set<(value: JobState & { loading: boolean }) => void>(); - let unsubscribeFn: (() => void) | null = null; - let clientSubId: string | null = null; - let state: JobState & { loading: boolean } = { + const core = createStoreCore & { loading: boolean }>({ jobId: "", status: "pending", progress: null, @@ -259,9 +253,9 @@ export function createJobStore( output: null, error: null, loading: true, - }; - - const notify = () => subscribers.forEach((run) => run(state)); + }); + let unsubscribeFn: (() => void) | null = null; + let clientSubId: string | null = null; const startJob = async () => { try { @@ -272,17 +266,15 @@ export function createJobStore( throw new Error("Invalid job ID returned from server"); } - state = { ...state, jobId, loading: false }; - notify(); + core.update((s) => ({ ...s, jobId, loading: false })); const applyJobData = (data: unknown) => { const jobData = asValidRecord(data, "job_id", "status"); if (!jobData || !JOB_STATUSES.has(jobData.status as string)) { - state = { ...state, status: "failed", error: "Invalid job update", loading: false }; - notify(); + core.update((s) => ({ ...s, status: "failed", error: "Invalid job update", loading: false })); return; } - state = { + core.set({ jobId: jobData.job_id as string, status: jobData.status as JobState["status"], progress: typeof jobData.progress === "number" ? jobData.progress : null, @@ -290,8 +282,7 @@ export function createJobStore( output: (jobData.output ?? null) as TOutput | null, error: typeof jobData.error === "string" ? jobData.error : null, loading: false, - }; - notify(); + }); }; clientSubId = crypto.randomUUID(); @@ -302,40 +293,23 @@ export function createJobStore( const initialData = await client._registerJob(clientSubId, jobId); if (initialData) applyJobData(initialData); } catch (e) { - state = { - ...state, - status: "failed", - error: (e as Error).message, - loading: false, - }; - notify(); + core.update((s) => ({ ...s, status: "failed", error: (e as Error).message, loading: false })); } }; startJob(); + const release = () => { + unsubscribeFn = null; + if (clientSubId) { + client._unregisterJob(clientSubId); + clientSubId = null; + } + }; + return { - subscribe(run) { - subscribers.add(run); - run(state); - return () => { - subscribers.delete(run); - if (subscribers.size === 0) { - unsubscribeFn = null; - if (clientSubId) { - client._unregisterJob(clientSubId); - clientSubId = null; - } - } - }; - }, - unsubscribe: () => { - unsubscribeFn = null; - if (clientSubId) { - client._unregisterJob(clientSubId); - clientSubId = null; - } - }, + subscribe: (run) => core.subscribe(run, release), + unsubscribe: release, }; } @@ -344,10 +318,7 @@ export function createWorkflowStore( args: RejectEmptyObject, ): WorkflowStore { const client = getForgeClient(); - const subscribers = new Set<(value: WorkflowState & { loading: boolean }) => void>(); - let unsubscribeFn: (() => void) | null = null; - let clientSubId: string | null = null; - let state: WorkflowState & { loading: boolean } = { + const core = createStoreCore & { loading: boolean }>({ workflowId: "", status: "pending", step: null, @@ -356,9 +327,9 @@ export function createWorkflowStore( output: null, error: null, loading: true, - }; - - const notify = () => subscribers.forEach((run) => run(state)); + }); + let unsubscribeFn: (() => void) | null = null; + let clientSubId: string | null = null; const startWorkflow = async () => { try { @@ -369,18 +340,16 @@ export function createWorkflowStore( throw new Error("Invalid workflow ID returned from server"); } - state = { ...state, workflowId, loading: false }; - notify(); + core.update((s) => ({ ...s, workflowId, loading: false })); const applyWorkflowData = (data: unknown) => { const wfData = asValidRecord(data, "workflow_id", "status"); if (!wfData || !WORKFLOW_STATUSES.has(wfData.status as string)) { - state = { ...state, status: "failed", error: "Invalid workflow update", loading: false }; - notify(); + core.update((s) => ({ ...s, status: "failed", error: "Invalid workflow update", loading: false })); return; } const rawSteps = Array.isArray(wfData.steps) ? wfData.steps : []; - state = { + core.set({ workflowId: wfData.workflow_id as string, status: wfData.status as WorkflowState["status"], step: typeof wfData.step === "string" ? wfData.step : null, @@ -396,8 +365,7 @@ export function createWorkflowStore( output: (wfData.output ?? null) as TOutput | null, error: typeof wfData.error === "string" ? wfData.error : null, loading: false, - }; - notify(); + }); }; clientSubId = crypto.randomUUID(); @@ -407,40 +375,23 @@ export function createWorkflowStore( const initialData = await client._registerWorkflow(clientSubId, workflowId); if (initialData) applyWorkflowData(initialData); } catch (e) { - state = { - ...state, - status: "failed", - error: (e as Error).message, - loading: false, - }; - notify(); + core.update((s) => ({ ...s, status: "failed", error: (e as Error).message, loading: false })); } }; startWorkflow(); + const release = () => { + unsubscribeFn = null; + if (clientSubId) { + client._unregisterWorkflow(clientSubId); + clientSubId = null; + } + }; + return { - subscribe(run) { - subscribers.add(run); - run(state); - return () => { - subscribers.delete(run); - if (subscribers.size === 0) { - unsubscribeFn = null; - if (clientSubId) { - client._unregisterWorkflow(clientSubId); - clientSubId = null; - } - } - }; - }, - unsubscribe: () => { - unsubscribeFn = null; - if (clientSubId) { - client._unregisterWorkflow(clientSubId); - clientSubId = null; - } - }, + subscribe: (run) => core.subscribe(run, release), + unsubscribe: release, }; } @@ -478,14 +429,11 @@ export function createOptimisticMutation( ): OptimisticMutationStore { const ttlMs = options?.ttlMs ?? 3000; const client = getForgeClient(); - const subscribers = new Set<(value: TData | null) => void>(); - let currentView: TData | null = null; + const core = createStoreCore(null); let latestSubData: TData | null = null; let pendingGeneration = 0; let ttlTimer: ReturnType | null = null; - const notify = () => subscribers.forEach((run) => run(currentView)); - const unsubscribeSub = subscription.subscribe((result) => { latestSubData = result.data; if (pendingGeneration > 0) { @@ -496,30 +444,22 @@ export function createOptimisticMutation( ttlTimer = null; } } - currentView = result.data; - notify(); + core.set(result.data); }); const data: Readable = { - subscribe(run) { - subscribers.add(run); - run(currentView); - return () => { - subscribers.delete(run); - if (subscribers.size === 0) { - unsubscribeSub(); - if (ttlTimer) clearTimeout(ttlTimer); - } - }; - }, + subscribe: (run) => + core.subscribe(run, () => { + unsubscribeSub(); + if (ttlTimer) clearTimeout(ttlTimer); + }), }; function fire(args: TArgs): void { - const snapshot = currentView; + const snapshot = core.get(); - if (currentView !== null) { - currentView = apply(currentView, args); - notify(); + if (snapshot !== null) { + core.set(apply(snapshot, args)); } const generation = ++pendingGeneration; @@ -528,8 +468,7 @@ export function createOptimisticMutation( ttlTimer = setTimeout(() => { if (pendingGeneration === generation) { pendingGeneration = 0; - currentView = latestSubData; - notify(); + core.set(latestSubData); } }, ttlMs); @@ -540,8 +479,7 @@ export function createOptimisticMutation( clearTimeout(ttlTimer); ttlTimer = null; } - currentView = snapshot; - notify(); + core.set(snapshot); } const error = err instanceof ForgeClientError From 71d61e258b66aeb275ecc7851d4ea5bd106472be Mon Sep 17 00:00:00 2001 From: Isala Piyarisi Date: Sat, 30 May 2026 12:34:44 +0000 Subject: [PATCH 4/6] refactor(core): generate duplicated test-context builder setters via macros The eight Test*ContextBuilder types copied byte-for-byte identical auth (as_user/as_subject/with_role/with_roles/with_claim), env (with_env/with_envs/ with_pool), and tenant (with_tenant) setters. Extract three macro_rules! generators in context/mod.rs and have each builder opt into the clusters it supports. No public API change; 583 forge-core tests pass. --- crates/forge-core/src/testing/context/cron.rs | 47 +--------- .../forge-core/src/testing/context/daemon.rs | 19 +--- crates/forge-core/src/testing/context/job.rs | 54 +---------- .../src/testing/context/mcp_tool.rs | 61 +----------- crates/forge-core/src/testing/context/mod.rs | 92 ++++++++++++++++++- .../src/testing/context/mutation.rs | 53 +---------- .../forge-core/src/testing/context/query.rs | 67 +------------- .../forge-core/src/testing/context/webhook.rs | 19 +--- .../src/testing/context/workflow.rs | 68 +------------- 9 files changed, 115 insertions(+), 365 deletions(-) diff --git a/crates/forge-core/src/testing/context/cron.rs b/crates/forge-core/src/testing/context/cron.rs index fd9dc9a9..d6eb968c 100644 --- a/crates/forge-core/src/testing/context/cron.rs +++ b/crates/forge-core/src/testing/context/cron.rs @@ -146,6 +146,9 @@ pub struct TestCronContextBuilder { env_vars: HashMap, } +impl_test_auth_builder!(TestCronContextBuilder); +impl_test_env_builder!(TestCronContextBuilder); + impl TestCronContextBuilder { pub fn new(cron_name: impl Into) -> Self { let now = Utc::now(); @@ -189,39 +192,6 @@ impl TestCronContextBuilder { self.is_catch_up = true; self } - - pub fn as_user(mut self, id: Uuid) -> Self { - self.user_id = Some(id); - self - } - - /// For non-UUID auth providers (Firebase, Clerk, etc.). - pub fn as_subject(mut self, subject: impl Into) -> Self { - self.claims - .insert("sub".to_string(), serde_json::json!(subject.into())); - self - } - - pub fn with_role(mut self, role: impl Into) -> Self { - self.roles.push(role.into()); - self - } - - pub fn with_roles(mut self, roles: Vec) -> Self { - self.roles.extend(roles); - self - } - - pub fn with_claim(mut self, key: impl Into, value: serde_json::Value) -> Self { - self.claims.insert(key.into(), value); - self - } - - pub fn with_pool(mut self, pool: PgPool) -> Self { - self.pool = Some(pool); - self - } - pub fn mock_http(self, pattern: &str, handler: F) -> Self where F: Fn(&MockRequest) -> MockResponse + Send + Sync + 'static, @@ -234,17 +204,6 @@ impl TestCronContextBuilder { let json = serde_json::to_value(response).unwrap_or(serde_json::Value::Null); self.mock_http(pattern, move |_| MockResponse::json(json.clone())) } - - pub fn with_env(mut self, key: impl Into, value: impl Into) -> Self { - self.env_vars.insert(key.into(), value.into()); - self - } - - pub fn with_envs(mut self, vars: HashMap) -> Self { - self.env_vars.extend(vars); - self - } - pub fn build(self) -> TestCronContext { TestCronContext { run_id: self.run_id.unwrap_or_else(Uuid::new_v4), diff --git a/crates/forge-core/src/testing/context/daemon.rs b/crates/forge-core/src/testing/context/daemon.rs index 41341dec..84d2a48c 100644 --- a/crates/forge-core/src/testing/context/daemon.rs +++ b/crates/forge-core/src/testing/context/daemon.rs @@ -82,6 +82,8 @@ pub struct TestDaemonContextBuilder { env_vars: HashMap, } +impl_test_env_builder!(TestDaemonContextBuilder); + impl TestDaemonContextBuilder { pub fn new(daemon_name: impl Into) -> Self { Self { @@ -97,12 +99,6 @@ impl TestDaemonContextBuilder { self.instance_id = Some(id); self } - - pub fn with_pool(mut self, pool: PgPool) -> Self { - self.pool = Some(pool); - self - } - pub fn mock_http(self, pattern: &str, handler: F) -> Self where F: Fn(&MockRequest) -> MockResponse + Send + Sync + 'static, @@ -115,17 +111,6 @@ impl TestDaemonContextBuilder { let json = serde_json::to_value(response).unwrap_or(serde_json::Value::Null); self.mock_http(pattern, move |_| MockResponse::json(json.clone())) } - - pub fn with_env(mut self, key: impl Into, value: impl Into) -> Self { - self.env_vars.insert(key.into(), value.into()); - self - } - - pub fn with_envs(mut self, vars: HashMap) -> Self { - self.env_vars.extend(vars); - self - } - pub fn build(self) -> TestDaemonContext { let (shutdown_tx, shutdown_rx) = watch::channel(false); diff --git a/crates/forge-core/src/testing/context/job.rs b/crates/forge-core/src/testing/context/job.rs index 808e7393..4b2e250c 100644 --- a/crates/forge-core/src/testing/context/job.rs +++ b/crates/forge-core/src/testing/context/job.rs @@ -197,6 +197,9 @@ pub struct TestJobContextBuilder { cancel_requested: bool, } +impl_test_auth_builder!(TestJobContextBuilder); +impl_test_env_builder!(TestJobContextBuilder); + impl TestJobContextBuilder { /// Create a new builder with job type. pub fn new(job_type: impl Into) -> Self { @@ -239,44 +242,6 @@ impl TestJobContextBuilder { self.max_attempts = 3; self } - - /// Set the authenticated user with a UUID. - pub fn as_user(mut self, id: Uuid) -> Self { - self.user_id = Some(id); - self - } - - /// For non-UUID auth providers (Firebase, Clerk, etc.). - pub fn as_subject(mut self, subject: impl Into) -> Self { - self.claims - .insert("sub".to_string(), serde_json::json!(subject.into())); - self - } - - /// Add a role. - pub fn with_role(mut self, role: impl Into) -> Self { - self.roles.push(role.into()); - self - } - - /// Add multiple roles. - pub fn with_roles(mut self, roles: Vec) -> Self { - self.roles.extend(roles); - self - } - - /// Add a custom claim. - pub fn with_claim(mut self, key: impl Into, value: serde_json::Value) -> Self { - self.claims.insert(key.into(), value); - self - } - - /// Set the database pool. - pub fn with_pool(mut self, pool: PgPool) -> Self { - self.pool = Some(pool); - self - } - /// Add an HTTP mock with a custom handler. pub fn mock_http(self, pattern: &str, handler: F) -> Self where @@ -291,19 +256,6 @@ impl TestJobContextBuilder { let json = serde_json::to_value(response).unwrap_or(serde_json::Value::Null); self.mock_http(pattern, move |_| MockResponse::json(json.clone())) } - - /// Set a single environment variable. - pub fn with_env(mut self, key: impl Into, value: impl Into) -> Self { - self.env_vars.insert(key.into(), value.into()); - self - } - - /// Set multiple environment variables. - pub fn with_envs(mut self, vars: HashMap) -> Self { - self.env_vars.extend(vars); - self - } - /// Start with cancellation already requested. /// /// Use this to test how jobs handle cancellation from the start. diff --git a/crates/forge-core/src/testing/context/mcp_tool.rs b/crates/forge-core/src/testing/context/mcp_tool.rs index 101fefd0..32ac4880 100644 --- a/crates/forge-core/src/testing/context/mcp_tool.rs +++ b/crates/forge-core/src/testing/context/mcp_tool.rs @@ -109,53 +109,11 @@ pub struct TestMcpToolContextBuilder { env_vars: HashMap, } -impl TestMcpToolContextBuilder { - pub fn as_user(mut self, id: Uuid) -> Self { - self.user_id = Some(id); - self - } - - /// For non-UUID auth providers (Firebase, Clerk, etc.). - pub fn as_subject(mut self, subject: impl Into) -> Self { - self.claims - .insert("sub".to_string(), serde_json::json!(subject.into())); - self - } - - pub fn with_role(mut self, role: impl Into) -> Self { - self.roles.push(role.into()); - self - } - - pub fn with_roles(mut self, roles: Vec) -> Self { - self.roles.extend(roles); - self - } - - pub fn with_claim(mut self, key: impl Into, value: serde_json::Value) -> Self { - self.claims.insert(key.into(), value); - self - } - - /// Set the tenant id for multi-tenant testing. - /// - /// Production code reads the tenant from `auth.claims["tenant_id"]`, so - /// this writes the same value into the claims map. Tests calling - /// `ctx.auth.tenant_id()` then behave identically to production. - pub fn with_tenant(mut self, tenant_id: Uuid) -> Self { - self.tenant_id = Some(tenant_id); - self.claims.insert( - "tenant_id".to_string(), - serde_json::Value::String(tenant_id.to_string()), - ); - self - } - - pub fn with_pool(mut self, pool: PgPool) -> Self { - self.pool = Some(pool); - self - } +impl_test_auth_builder!(TestMcpToolContextBuilder); +impl_test_env_builder!(TestMcpToolContextBuilder); +impl_test_tenant_builder!(TestMcpToolContextBuilder); +impl TestMcpToolContextBuilder { pub fn mock_http(self, pattern: &str, handler: F) -> Self where F: Fn(&MockRequest) -> MockResponse + Send + Sync + 'static, @@ -178,17 +136,6 @@ impl TestMcpToolContextBuilder { self.workflow_dispatch = Some(dispatch); self } - - pub fn with_env(mut self, key: impl Into, value: impl Into) -> Self { - self.env_vars.insert(key.into(), value.into()); - self - } - - pub fn with_envs(mut self, vars: HashMap) -> Self { - self.env_vars.extend(vars); - self - } - pub fn build(self) -> TestMcpToolContext { TestMcpToolContext { auth: build_test_auth(self.user_id, self.roles, self.claims), diff --git a/crates/forge-core/src/testing/context/mod.rs b/crates/forge-core/src/testing/context/mod.rs index 2a41ffc2..bf2d1b3d 100644 --- a/crates/forge-core/src/testing/context/mod.rs +++ b/crates/forge-core/src/testing/context/mod.rs @@ -1,11 +1,99 @@ -// TODO(pre-1.0): Collapse to 4 generic contexts per 07-DELETION-LIST.md - //! Test context builders for all Forge function types. +//! +//! Every builder shares the same auth/env/pool fields and setter methods. Rather +//! than copy those byte-for-byte into eight files, the setters are generated by +//! the macros below — each builder opts into the clusters it supports +//! (`impl_test_auth_builder!`, `impl_test_env_builder!`, `impl_test_tenant_builder!`). use crate::function::AuthContext; use std::collections::HashMap; use uuid::Uuid; +/// Generate the auth setter methods (`as_user`, `as_subject`, `with_role`, +/// `with_roles`, `with_claim`) for a test-context builder that carries +/// `user_id`, `roles`, and `claims` fields. +macro_rules! impl_test_auth_builder { + ($builder:ty) => { + impl $builder { + /// Set the authenticated user with a UUID. + pub fn as_user(mut self, id: uuid::Uuid) -> Self { + self.user_id = Some(id); + self + } + + /// For non-UUID auth providers (Firebase, Clerk, etc.). + pub fn as_subject(mut self, subject: impl Into) -> Self { + self.claims + .insert("sub".to_string(), serde_json::json!(subject.into())); + self + } + + /// Add a role. + pub fn with_role(mut self, role: impl Into) -> Self { + self.roles.push(role.into()); + self + } + + /// Add multiple roles. + pub fn with_roles(mut self, roles: Vec) -> Self { + self.roles.extend(roles); + self + } + + /// Add a custom claim. + pub fn with_claim(mut self, key: impl Into, value: serde_json::Value) -> Self { + self.claims.insert(key.into(), value); + self + } + } + }; +} + +/// Generate the env/pool setter methods (`with_env`, `with_envs`, `with_pool`) +/// for a test-context builder that carries `env_vars` and `pool` fields. +macro_rules! impl_test_env_builder { + ($builder:ty) => { + impl $builder { + /// Set a single environment variable. + pub fn with_env(mut self, key: impl Into, value: impl Into) -> Self { + self.env_vars.insert(key.into(), value.into()); + self + } + + /// Set multiple environment variables. + pub fn with_envs(mut self, vars: std::collections::HashMap) -> Self { + self.env_vars.extend(vars); + self + } + + /// Set the database pool. + pub fn with_pool(mut self, pool: sqlx::PgPool) -> Self { + self.pool = Some(pool); + self + } + } + }; +} + +/// Generate the `with_tenant` setter for a builder that carries a `tenant_id` +/// field. Mirrors production: the value is also written into `claims` so +/// `auth.tenant_id()` reads identically to a real request. +macro_rules! impl_test_tenant_builder { + ($builder:ty) => { + impl $builder { + /// Set the tenant ID for multi-tenant testing. + pub fn with_tenant(mut self, tenant_id: uuid::Uuid) -> Self { + self.tenant_id = Some(tenant_id); + self.claims.insert( + "tenant_id".to_string(), + serde_json::Value::String(tenant_id.to_string()), + ); + self + } + } + }; +} + mod cron; mod daemon; mod job; diff --git a/crates/forge-core/src/testing/context/mutation.rs b/crates/forge-core/src/testing/context/mutation.rs index a09c661f..c3dbf8ae 100644 --- a/crates/forge-core/src/testing/context/mutation.rs +++ b/crates/forge-core/src/testing/context/mutation.rs @@ -210,44 +210,10 @@ impl Default for TestMutationContextBuilder { } } -impl TestMutationContextBuilder { - /// Set the authenticated user with a UUID. - pub fn as_user(mut self, id: Uuid) -> Self { - self.user_id = Some(id); - self - } - - /// For non-UUID auth providers (Firebase, Clerk, etc.). - pub fn as_subject(mut self, subject: impl Into) -> Self { - self.claims - .insert("sub".to_string(), serde_json::json!(subject.into())); - self - } - - /// Add a role. - pub fn with_role(mut self, role: impl Into) -> Self { - self.roles.push(role.into()); - self - } - - /// Add multiple roles. - pub fn with_roles(mut self, roles: Vec) -> Self { - self.roles.extend(roles); - self - } - - /// Add a custom claim. - pub fn with_claim(mut self, key: impl Into, value: serde_json::Value) -> Self { - self.claims.insert(key.into(), value); - self - } - - /// Set the database pool. - pub fn with_pool(mut self, pool: PgPool) -> Self { - self.pool = Some(pool); - self - } +impl_test_auth_builder!(TestMutationContextBuilder); +impl_test_env_builder!(TestMutationContextBuilder); +impl TestMutationContextBuilder { /// Add an HTTP mock with a custom handler. pub fn mock_http(self, pattern: &str, handler: F) -> Self where @@ -274,19 +240,6 @@ impl TestMutationContextBuilder { self.workflow_dispatch = dispatch; self } - - /// Set a single environment variable. - pub fn with_env(mut self, key: impl Into, value: impl Into) -> Self { - self.env_vars.insert(key.into(), value.into()); - self - } - - /// Set multiple environment variables. - pub fn with_envs(mut self, vars: HashMap) -> Self { - self.env_vars.extend(vars); - self - } - /// Build the test context. pub fn build(self) -> TestMutationContext { TestMutationContext { diff --git a/crates/forge-core/src/testing/context/query.rs b/crates/forge-core/src/testing/context/query.rs index 51a41637..5d6a53cd 100644 --- a/crates/forge-core/src/testing/context/query.rs +++ b/crates/forge-core/src/testing/context/query.rs @@ -95,70 +95,11 @@ pub struct TestQueryContextBuilder { env_vars: HashMap, } -impl TestQueryContextBuilder { - /// Set the authenticated user with a UUID. - pub fn as_user(mut self, id: Uuid) -> Self { - self.user_id = Some(id); - self - } - - /// For non-UUID auth providers (Firebase, Clerk, etc.). - pub fn as_subject(mut self, subject: impl Into) -> Self { - self.claims - .insert("sub".to_string(), serde_json::json!(subject.into())); - self - } - - /// Add a role. - pub fn with_role(mut self, role: impl Into) -> Self { - self.roles.push(role.into()); - self - } - - /// Add multiple roles. - pub fn with_roles(mut self, roles: Vec) -> Self { - self.roles.extend(roles); - self - } - - /// Add a custom claim. - pub fn with_claim(mut self, key: impl Into, value: serde_json::Value) -> Self { - self.claims.insert(key.into(), value); - self - } - - /// Set the tenant ID for multi-tenant testing. - /// - /// Production code reads the tenant from `auth.claims["tenant_id"]`, so - /// this writes the same value into the claims map. Tests calling - /// `ctx.auth.tenant_id()` then behave identically to production. - pub fn with_tenant(mut self, tenant_id: Uuid) -> Self { - self.tenant_id = Some(tenant_id); - self.claims.insert( - "tenant_id".to_string(), - serde_json::Value::String(tenant_id.to_string()), - ); - self - } - - /// Set the database pool. - pub fn with_pool(mut self, pool: PgPool) -> Self { - self.pool = Some(pool); - self - } - - /// Set a single environment variable. - pub fn with_env(mut self, key: impl Into, value: impl Into) -> Self { - self.env_vars.insert(key.into(), value.into()); - self - } - - /// Set multiple environment variables. - pub fn with_envs(mut self, vars: HashMap) -> Self { - self.env_vars.extend(vars); - self - } +impl_test_auth_builder!(TestQueryContextBuilder); +impl_test_env_builder!(TestQueryContextBuilder); +impl_test_tenant_builder!(TestQueryContextBuilder); +impl TestQueryContextBuilder { /// Build the test context. pub fn build(self) -> TestQueryContext { TestQueryContext { diff --git a/crates/forge-core/src/testing/context/webhook.rs b/crates/forge-core/src/testing/context/webhook.rs index 660e98fe..a9542422 100644 --- a/crates/forge-core/src/testing/context/webhook.rs +++ b/crates/forge-core/src/testing/context/webhook.rs @@ -77,6 +77,8 @@ pub struct TestWebhookContextBuilder { env_vars: HashMap, } +impl_test_env_builder!(TestWebhookContextBuilder); + impl TestWebhookContextBuilder { pub fn new(webhook_name: impl Into) -> Self { Self { @@ -113,12 +115,6 @@ impl TestWebhookContextBuilder { } self } - - pub fn with_pool(mut self, pool: PgPool) -> Self { - self.pool = Some(pool); - self - } - pub fn mock_http(self, pattern: &str, handler: F) -> Self where F: Fn(&MockRequest) -> MockResponse + Send + Sync + 'static, @@ -136,17 +132,6 @@ impl TestWebhookContextBuilder { self.job_dispatch = dispatch; self } - - pub fn with_env(mut self, key: impl Into, value: impl Into) -> Self { - self.env_vars.insert(key.into(), value.into()); - self - } - - pub fn with_envs(mut self, vars: HashMap) -> Self { - self.env_vars.extend(vars); - self - } - pub fn build(self) -> TestWebhookContext { TestWebhookContext { webhook_name: self.webhook_name, diff --git a/crates/forge-core/src/testing/context/workflow.rs b/crates/forge-core/src/testing/context/workflow.rs index d7fa9a2e..2b61a47f 100644 --- a/crates/forge-core/src/testing/context/workflow.rs +++ b/crates/forge-core/src/testing/context/workflow.rs @@ -225,6 +225,10 @@ pub struct TestWorkflowContextBuilder { env_vars: HashMap, } +impl_test_auth_builder!(TestWorkflowContextBuilder); +impl_test_env_builder!(TestWorkflowContextBuilder); +impl_test_tenant_builder!(TestWorkflowContextBuilder); + impl TestWorkflowContextBuilder { /// Create a new builder. pub fn new(workflow_name: impl Into) -> Self { @@ -273,57 +277,6 @@ impl TestWorkflowContextBuilder { self.completed_steps.insert(name.into(), result); self } - - /// Set the tenant ID. - /// - /// Production reads the tenant from `auth.claims["tenant_id"]`, so this - /// writes the same value into the claims map. - pub fn with_tenant(mut self, tenant_id: Uuid) -> Self { - self.tenant_id = Some(tenant_id); - self.claims.insert( - "tenant_id".to_string(), - serde_json::Value::String(tenant_id.to_string()), - ); - self - } - - /// Set the authenticated user with a UUID. - pub fn as_user(mut self, id: Uuid) -> Self { - self.user_id = Some(id); - self - } - - /// For non-UUID auth providers (Firebase, Clerk, etc.). - pub fn as_subject(mut self, subject: impl Into) -> Self { - self.claims - .insert("sub".to_string(), serde_json::json!(subject.into())); - self - } - - /// Add a role. - pub fn with_role(mut self, role: impl Into) -> Self { - self.roles.push(role.into()); - self - } - - /// Add multiple roles. - pub fn with_roles(mut self, roles: Vec) -> Self { - self.roles.extend(roles); - self - } - - /// Add a custom claim. - pub fn with_claim(mut self, key: impl Into, value: serde_json::Value) -> Self { - self.claims.insert(key.into(), value); - self - } - - /// Set the database pool. - pub fn with_pool(mut self, pool: PgPool) -> Self { - self.pool = Some(pool); - self - } - /// Add an HTTP mock. pub fn mock_http(self, pattern: &str, handler: F) -> Self where @@ -338,19 +291,6 @@ impl TestWorkflowContextBuilder { let json = serde_json::to_value(response).unwrap_or(serde_json::Value::Null); self.mock_http(pattern, move |_| MockResponse::json(json.clone())) } - - /// Set a single environment variable. - pub fn with_env(mut self, key: impl Into, value: impl Into) -> Self { - self.env_vars.insert(key.into(), value.into()); - self - } - - /// Set multiple environment variables. - pub fn with_envs(mut self, vars: HashMap) -> Self { - self.env_vars.extend(vars); - self - } - /// Build the test context. pub fn build(self) -> TestWorkflowContext { let step_states: HashMap = self From d0c245e76fe1655391b566d7c885f49921476a8b Mon Sep 17 00:00:00 2001 From: Isala Piyarisi Date: Sat, 30 May 2026 12:45:00 +0000 Subject: [PATCH 5/6] refactor(cli): stop mutating config in run(); extract telemetry/migration init run() mutated self.config.gateway.port in place from the PORT env var, leaving the owned config inconsistent with how it was built. Resolve it into a local effective_port instead. Also lift the self-contained telemetry and migration prologue out of the 900-line run() into init_telemetry_subsystem() and apply_migrations(). The intricate cluster/worker/gateway wiring is left intact since it can only be safely validated by full integration tests. --- crates/forge/src/runtime/mod.rs | 62 ++++++++++++++++++++------------- 1 file changed, 38 insertions(+), 24 deletions(-) diff --git a/crates/forge/src/runtime/mod.rs b/crates/forge/src/runtime/mod.rs index bf84a172..ee8ce121 100644 --- a/crates/forge/src/runtime/mod.rs +++ b/crates/forge/src/runtime/mod.rs @@ -225,8 +225,10 @@ impl Forge { self.workflow_registry.persist_definitions(pool).await } - /// Start the runtime. Blocks until a ctrl-c or `Forge::shutdown()` is called. - pub async fn run(mut self) -> Result<()> { + /// Install the telemetry/tracing subscriber from the configured observability + /// settings. Best-effort: a failure is reported but does not abort startup + /// (it falls back to `eprintln!` since tracing macros would be dropped). + fn init_telemetry_subsystem(&self) { let telemetry_config = forge_runtime::TelemetryConfig::from_observability_config( &self.config.observability, &self.config.project.name, @@ -248,10 +250,31 @@ impl Forge { "Telemetry initialized" ); } - // init_telemetry failed before a subscriber could be installed; tracing - // macros would be silently dropped, so fall back to eprintln!. Err(e) => eprintln!("forge: failed to initialize telemetry: {e}"), } + } + + /// Run user migrations, then persist workflow definitions if any are registered. + async fn apply_migrations(&self, pool: &sqlx::PgPool) -> Result<()> { + let runner = MigrationRunner::new(pool.clone()); + + let mut user_migrations = load_migrations_from_dir(&self.migrations_dir)?; + user_migrations.extend(self.extra_migrations.clone()); + + runner.run(user_migrations).await?; + tracing::debug!("Migrations applied"); + + #[cfg(feature = "workflows")] + if !self.workflow_registry.is_empty() { + self.persist_workflow_definitions(pool).await?; + } + + Ok(()) + } + + /// Start the runtime. Blocks until a ctrl-c or `Forge::shutdown()` is called. + pub async fn run(mut self) -> Result<()> { + self.init_telemetry_subsystem(); tracing::debug!("Connecting to database"); @@ -265,18 +288,7 @@ impl Forge { tracing::debug!("Database connected"); - let runner = MigrationRunner::new(pool.clone()); - - let mut user_migrations = load_migrations_from_dir(&self.migrations_dir)?; - user_migrations.extend(self.extra_migrations.clone()); - - runner.run(user_migrations).await?; - tracing::debug!("Migrations applied"); - - #[cfg(feature = "workflows")] - if !self.workflow_registry.is_empty() { - self.persist_workflow_definitions(&pool).await?; - } + self.apply_migrations(&pool).await?; let hostname = get_hostname(); @@ -286,11 +298,13 @@ impl Forge { .and_then(|s| s.parse().ok()) .unwrap_or(IpAddr::V4(Ipv4Addr::UNSPECIFIED)); - if let Ok(port_str) = std::env::var("PORT") - && let Ok(port) = port_str.parse::() - { - self.config.gateway.port = port; - } + // PORT env var overrides the configured port. Resolve it into a local + // rather than mutating the config in place — `run()` should not leave + // the owned config in a different state than it was constructed with. + let effective_port: u16 = std::env::var("PORT") + .ok() + .and_then(|s| s.parse::().ok()) + .unwrap_or(self.config.gateway.port); let roles: Vec = self .config @@ -303,7 +317,7 @@ impl Forge { let node_info = NodeInfo::new_local( hostname, ip_address, - self.config.gateway.port, + effective_port, self.config.gateway.grpc_port, roles.clone(), self.config.node.worker_capabilities.clone(), @@ -731,7 +745,7 @@ impl Forge { } let gateway_config = RuntimeGatewayConfig { - port: self.config.gateway.port, + port: effective_port, max_connections: self.config.gateway.max_connections, sse_max_sessions: self.config.realtime.sse_max_sessions, request_timeout_secs: self.config.gateway.request_timeout.as_secs(), @@ -1130,7 +1144,7 @@ impl Forge { version = env!("CARGO_PKG_VERSION"), roles = ?role_names, worker_capabilities = ?capabilities, - port = self.config.gateway.port, + port = effective_port, db_pool_size = self.config.database.pool_size, cluster_discovery = ?self.config.cluster.discovery, observability = self.config.observability.enabled, From 0ab36feb14dd1e350e4a21d797dcfa66b1ef97c5 Mon Sep 17 00:00:00 2001 From: Isala Piyarisi Date: Sun, 31 May 2026 04:35:19 +0000 Subject: [PATCH 6/6] feat(geoip): split into offline-safe geoip + opt-in geoip-embedded The geoip feature bundled both the runtime MaxMind MMDB reader and the build-time-downloaded DB-IP country database, so it couldn't build offline and was excluded from the default 'full' preset entirely. Split them: - geoip (now in 'full'): pure-Rust maxminddb reader, offline-safe. Set signals.geoip_db_path to a GeoLite2-City MMDB for country/city enrichment. - geoip-embedded (opt-in): bakes in the DB-IP country DB; the only geoip option needing a build-time network fetch. GeoIpResolver gains a no-data Empty backend for the geoip-without-embedded case. Breaking: zero-config country enrichment now requires geoip-embedded. Updates binary-size + signals docs, skill api/frontend references, CHANGELOG. --- CHANGELOG.md | 8 +++ crates/forge-runtime/Cargo.toml | 24 +++++---- crates/forge-runtime/src/signals/geoip.rs | 51 +++++++++++-------- crates/forge/Cargo.toml | 16 ++++-- docs/docs/scale/binary-size.mdx | 21 +++++--- docs/docs/ship/signals.mdx | 25 ++++++--- .../references/api.md | 5 +- .../references/frontend.md | 2 +- 8 files changed, 98 insertions(+), 54 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca105c5f..f8961011 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **GeoIP feature split for offline-safe builds.** The `geoip` cargo feature now pulls only the pure-Rust runtime MaxMind MMDB reader (`maxminddb`) and is included in the default `full` preset — no build-time network fetch. The bundled DB-IP Country Lite database (and its build-time download) moved to a new opt-in `geoip-embedded` feature. **Breaking:** zero-config `country` enrichment now requires building with `--features geoip-embedded`; the default build enriches `country`/`city` only when `signals.geoip_db_path` points at a GeoLite2-City MMDB. This makes the default preset build cleanly in air-gapped/offline environments. + +### Internal + +- Removed duplication across the codebase: shared attribute parsing in `forge-macros`, declarative macros for the sealed handler-context impls and the eight test-context builders in `forge-core`, a shared store core in `@forge-rs/svelte`, and a smaller `Forge::run()` that no longer mutates its config in place. No public API change. + ## [0.10.2] - 2026-05-22 ### Changed diff --git a/crates/forge-runtime/Cargo.toml b/crates/forge-runtime/Cargo.toml index df6d406a..032c0bfd 100644 --- a/crates/forge-runtime/Cargo.toml +++ b/crates/forge-runtime/Cargo.toml @@ -121,10 +121,16 @@ daemons = [] # only JWT bearer auth (token passed directly in the request). mcp-oauth = ["gateway"] -# GeoIP enrichment (sub-feature of `gateway` since signals lives there). -# Bundles a ~10MB IP-to-country database; pulls a build-time download for -# `db_ip`. Skip if you don't run signals or want offline builds. -geoip = ["gateway", "dep:db_ip", "dep:maxminddb"] +# GeoIP enrichment for signals. `geoip` is offline-safe: it pulls only the +# pure-Rust MaxMind MMDB reader (`maxminddb`), which loads a city-level database +# from a file at runtime (point `signals.geoip_db_path` at a GeoLite2-City MMDB). +geoip = ["gateway", "dep:maxminddb"] + +# Zero-config country resolution via a bundled DB-IP Country Lite database baked +# into the binary. This is the ONLY geoip option that needs network at build +# time (the `db_ip` build script downloads the ~10MB database). Opt in when you +# want country enrichment without supplying an MMDB file and have build network. +geoip-embedded = ["geoip", "dep:db_ip"] # OpenTelemetry trace/metric/log exporters. Disable to skip 5+ OTel crates, # protobuf stubs, and reqwest-otlp. @@ -139,11 +145,10 @@ otel = [ # --- Bundle features (composition shortcuts) --- -# Everything except testcontainers and geoip. What `forgex` activates by default. -# `geoip` is intentionally excluded: it requires a build-time network fetch to -# download the bundled IP-to-country DB. Enable it explicitly with -# `--features forge-runtime/geoip` when you need IP enrichment and have network -# access at build time. +# Everything except testcontainers and geoip-embedded. What `forgex` activates +# by default. The offline-safe `geoip` (runtime MMDB) is included; only +# `geoip-embedded` is excluded, since its bundled DB needs a build-time network +# fetch. Enable it explicitly with `--features forge-runtime/geoip-embedded`. full = [ "gateway", "mcp-oauth", @@ -151,6 +156,7 @@ full = [ "workflows", "cron", "daemons", + "geoip", "otel", ] diff --git a/crates/forge-runtime/src/signals/geoip.rs b/crates/forge-runtime/src/signals/geoip.rs index b46449e2..ac0ae370 100644 --- a/crates/forge-runtime/src/signals/geoip.rs +++ b/crates/forge-runtime/src/signals/geoip.rs @@ -1,11 +1,16 @@ //! IP geolocation resolution. //! -//! With the `geoip` feature: ships an embedded DB-IP Country Lite database for -//! zero-config country resolution, plus optional MaxMind GeoLite2-City MMDB for -//! city-level granularity. +//! Two independent, additive backends, selected by cargo feature: //! -//! Without the `geoip` feature: provides a stub `GeoIpResolver` that returns -//! empty results, so signals callers compile without conditional code paths. +//! - `geoip` (offline-safe): a runtime MaxMind MMDB reader. Load a +//! GeoLite2-City database from a file via [`GeoIpResolver::from_mmdb`] for +//! city-level granularity. Pulls only the pure-Rust `maxminddb` crate. +//! - `geoip-embedded`: additionally bakes a DB-IP Country Lite database into +//! the binary for zero-config country resolution. This is the only option +//! that needs a build-time network fetch (for the `db_ip` database). +//! +//! With neither feature, [`GeoIpResolver`] is a stub that returns empty results, +//! so signals callers compile without conditional code paths. use forge_core::ForgeError; use std::net::IpAddr; @@ -21,19 +26,22 @@ pub struct GeoInfo { pub city: Option, } -#[cfg(feature = "geoip")] enum Backend { + /// Bundled DB-IP country database, compiled in via `geoip-embedded`. + #[cfg(feature = "geoip-embedded")] Embedded(db_ip::DbIpDatabase), + /// Runtime-loaded MaxMind MMDB reader (`geoip` + [`GeoIpResolver::from_mmdb`]). + #[cfg(feature = "geoip")] Mmdb(maxminddb::Reader>), + /// No data source: `geoip` enabled without an MMDB loaded, or the geoip + /// features disabled entirely. Lookups return empty results. + #[cfg(not(feature = "geoip-embedded"))] + Empty, } -#[cfg(not(feature = "geoip"))] -enum Backend { - Stub, -} - -/// Thread-safe GeoIP resolver. Uses the embedded DB-IP database by default, -/// or a MaxMind MMDB file when configured for city-level resolution. +/// Thread-safe GeoIP resolver. Backed by a runtime MaxMind MMDB file (the +/// `geoip` feature) for city-level resolution, the bundled DB-IP country +/// database (the `geoip-embedded` feature), or nothing when neither is enabled. #[derive(Clone)] pub struct GeoIpResolver { backend: Arc, @@ -46,19 +54,20 @@ impl Default for GeoIpResolver { } impl GeoIpResolver { - /// Create a resolver backed by the embedded DB-IP Country Lite database - /// (or a no-op stub when the `geoip` feature is disabled). + /// Create a resolver backed by the bundled DB-IP Country Lite database when + /// `geoip-embedded` is enabled, otherwise a no-data resolver (use + /// [`Self::from_mmdb`] with the `geoip` feature for runtime city data). pub fn new() -> Self { - #[cfg(feature = "geoip")] + #[cfg(feature = "geoip-embedded")] { Self { backend: Arc::new(Backend::Embedded(db_ip::include_country_code_database!())), } } - #[cfg(not(feature = "geoip"))] + #[cfg(not(feature = "geoip-embedded"))] { Self { - backend: Arc::new(Backend::Stub), + backend: Arc::new(Backend::Empty), } } } @@ -95,7 +104,7 @@ impl GeoIpResolver { }; match self.backend.as_ref() { - #[cfg(feature = "geoip")] + #[cfg(feature = "geoip-embedded")] Backend::Embedded(db) => GeoInfo { country: db.get(&_ip).map(|c| c.as_str().to_string()), city: None, @@ -111,8 +120,8 @@ impl GeoIpResolver { }, Err(_) => GeoInfo::default(), }, - #[cfg(not(feature = "geoip"))] - Backend::Stub => GeoInfo::default(), + #[cfg(not(feature = "geoip-embedded"))] + Backend::Empty => GeoInfo::default(), } } } diff --git a/crates/forge/Cargo.toml b/crates/forge/Cargo.toml index b85e9c42..064dec7c 100644 --- a/crates/forge/Cargo.toml +++ b/crates/forge/Cargo.toml @@ -100,9 +100,14 @@ daemons = ["forge-runtime/daemons"] # OAuth 2.1 + PKCE for MCP authentication. mcp-oauth = ["gateway", "forge-runtime/mcp-oauth"] -# GeoIP enrichment for signals (heavy build-time download). +# GeoIP enrichment for signals. Offline-safe: runtime MaxMind MMDB reader. +# Point `signals.geoip_db_path` at a GeoLite2-City MMDB for city-level data. geoip = ["gateway", "forge-runtime/geoip"] +# Zero-config bundled country DB. The ONLY geoip option needing a build-time +# network fetch; opt in with `--features forgex/geoip-embedded`. +geoip-embedded = ["geoip", "forge-runtime/geoip-embedded"] + # OpenTelemetry exporters (heavy crate deps). otel = [ "forge-runtime/otel", @@ -114,10 +119,10 @@ otel = [ # --- Bundle presets --- -# Everything except geoip. `geoip` requires a build-time network fetch for the -# bundled IP-to-country DB and is excluded from the default preset so that -# `cargo build` succeeds in offline/CI environments. Enable it explicitly with -# `--features forgex/geoip` when needed. +# Everything except geoip-embedded. The offline-safe `geoip` (runtime MMDB) is +# included; only `geoip-embedded` is excluded, since its bundled country DB +# needs a build-time network fetch (so `cargo build` stays offline/CI friendly). +# Enable it explicitly with `--features forgex/geoip-embedded` when needed. full = [ "gateway", "mcp-oauth", @@ -125,6 +130,7 @@ full = [ "workflows", "cron", "daemons", + "geoip", "otel", ] diff --git a/docs/docs/scale/binary-size.mdx b/docs/docs/scale/binary-size.mdx index e2136c82..0c14ebc7 100644 --- a/docs/docs/scale/binary-size.mdx +++ b/docs/docs/scale/binary-size.mdx @@ -36,7 +36,8 @@ If the presets don't fit, compose your own from primitives: | `workflows` | Durable workflow executor | | `cron` | Cron scheduler (leader-elected) | | `daemons` | Long-running daemon runner | -| `geoip` | Bundled IP-to-country DB + MaxMind reader (heavy) | +| `geoip` | Runtime MaxMind MMDB reader (offline-safe; in `full`) | +| `geoip-embedded` | Bundled IP-to-country DB baked into the binary (build-time download) | | `otel` | OpenTelemetry trace/metric/log exporters | Example: @@ -58,18 +59,22 @@ on a clean checkout: | `minimal` | -65% | -75% | The biggest individual savings come from disabling `otel` (skips ~5 OTel -crates + protobuf) and `geoip` (skips a build-time database download — also -unblocks builds in air-gapped environments). +crates + protobuf). `geoip` itself is offline-safe (pure-Rust MaxMind reader); +only the opt-in `geoip-embedded` triggers a build-time download. ## Air-gapped / offline builds -The `db_ip` crate behind the `geoip` feature downloads its database at -build time. If your CI lacks network access, disable `geoip`: +The default `full` preset is offline-safe. Only `geoip-embedded` needs network +at build time — the `db_ip` crate behind it downloads its bundled IP-to-country +database during the build. Don't enable `geoip-embedded` in air-gapped CI; use +the runtime `geoip` MMDB path instead (set `signals.geoip_db_path`). If you've +opted into it, drop it: ```toml -forge = { version = "0.10.2", default-features = false, features = [ - "gateway", "jobs", "workflows", "cron", "daemons", "otel" -] } +# offline-safe: runtime geoip is included in `full` +forge = { version = "0.10.2" } +# only this needs build-time network: +# forge = { version = "0.10.2", features = ["geoip-embedded"] } ``` ## Compile-time errors when a macro doesn't match the feature set diff --git a/docs/docs/ship/signals.mdx b/docs/docs/ship/signals.mdx index 814a38eb..c4fb9280 100644 --- a/docs/docs/ship/signals.mdx +++ b/docs/docs/ship/signals.mdx @@ -62,15 +62,23 @@ excluded_functions = [] # function names to skip (exact match) bot_detection = true # tag bot traffic via UA patterns ``` -Forge ships with an embedded [DB-IP](https://db-ip.com) Country Lite database for IP-to-country resolution. Every signal event is automatically enriched with an ISO 3166-1 alpha-2 country code in the `country` column. No configuration or external files needed. +GeoIP enrichment has two backends, selected by cargo feature: -For city-level resolution, point `geoip_db_path` at a MaxMind GeoLite2-City MMDB file (free with a [MaxMind account](https://dev.maxmind.com/geoip/geolite2-free-geolocation-data)). This populates the `city` column alongside `country`. A bad path fails startup — if you explicitly ask for city-level data, Forge doesn't silently downgrade. +- **`geoip`** (offline-safe, included in the default `full` preset): a runtime MaxMind MMDB reader. Point `geoip_db_path` at a GeoLite2-City MMDB file (free with a [MaxMind account](https://dev.maxmind.com/geoip/geolite2-free-geolocation-data)) to populate both the `country` and `city` columns. Without a path, no enrichment happens. A bad path fails startup — if you explicitly ask for city-level data, Forge doesn't silently downgrade. +- **`geoip-embedded`** (opt-in): additionally bakes a [DB-IP](https://db-ip.com) Country Lite database into the binary for zero-config `country` enrichment with no external file. This is the only geoip option that needs network at build time (the `db_ip` database is downloaded during the build), so it's excluded from `full`. ```toml [signals] +# city-level: requires the `geoip` feature (default) + an MMDB file geoip_db_path = "/etc/forge/GeoLite2-City.mmdb" ``` +For zero-config country data without an MMDB file, build with `geoip-embedded`: + +```toml +forge = { version = "0.10.2", features = ["geoip-embedded"] } +``` + To disable signals entirely: ```toml @@ -358,12 +366,13 @@ a tenant-scoped layer. ### GeoIP attribution -The default build embeds the **DB-IP IP-to-Country Lite** database. If -you ship a binary that uses the embedded data (i.e., you don't override -`signals.geoip_db_path`), include the DB-IP attribution required by -their CC BY 4.0 license: *"IP geolocation by [DB-IP](https://db-ip.com)"*. -Setting `geoip_db_path` to a MaxMind MMDB file uses your own licensed -data instead and removes the attribution requirement. +Building with the `geoip-embedded` feature bakes in the **DB-IP IP-to-Country +Lite** database. If you ship such a binary and rely on its embedded data (no +`signals.geoip_db_path` set), include the DB-IP attribution required by their +CC BY 4.0 license: *"IP geolocation by [DB-IP](https://db-ip.com)"*. The +default build (runtime `geoip` only) carries no embedded data; pointing +`geoip_db_path` at a MaxMind MMDB file uses your own licensed data and removes +the attribution requirement. ## Limitations diff --git a/docs/skills/forge-idiomatic-engineer/references/api.md b/docs/skills/forge-idiomatic-engineer/references/api.md index 83e9ff78..de500a4b 100644 --- a/docs/skills/forge-idiomatic-engineer/references/api.md +++ b/docs/skills/forge-idiomatic-engineer/references/api.md @@ -581,7 +581,8 @@ Subsystems are feature-gated; default is `full`. Opt out with `default-features | `cron` | yes | Leader-only cron scheduler | — | | `daemons` | yes | Long-running daemon runner | — | | `mcp-oauth` | yes | OAuth 2.1 + PKCE for MCP (req. `gateway`) | — | -| `geoip` | yes | IP→country enrichment for signals (req. `gateway`) | db_ip (build-time download), maxminddb | +| `geoip` | yes (in `full`) | Runtime MaxMind MMDB reader for signals; offline-safe (req. `gateway`) | maxminddb | +| `geoip-embedded` | no | Bundled DB-IP country DB baked in (req. `geoip`); build-time download | db_ip | | `otel` | yes | OTel trace/metric/log exporters | opentelemetry ×5, protobuf stubs | | `testcontainers` | no | Test context helpers that spin up a real PG container | testcontainers | | `embedded-frontend` | no | Embeds compiled frontend into binary at build time | rust-embed | @@ -590,7 +591,7 @@ Subsystems are feature-gated; default is `full`. Opt out with `default-features forgex = { version = "0.10.2", default-features = false, features = ["worker"] } ``` -`#[forge::job/cron/workflow/daemon/webhook/mcp_tool]` without the matching feature errors at the generated `forge::AutoHandler` reference. Without `otel`, `tracing-subscriber` still logs to stderr. `geoip` fetches a ~10 MB DB at compile time — disable for air-gapped builds or when not using signals. +`#[forge::job/cron/workflow/daemon/webhook/mcp_tool]` without the matching feature errors at the generated `forge::AutoHandler` reference. Without `otel`, `tracing-subscriber` still logs to stderr. `geoip` (in `full`) is offline-safe — it only adds the runtime MMDB reader; set `signals.geoip_db_path` to a GeoLite2-City MMDB for enrichment. Only `geoip-embedded` fetches a ~10 MB DB at compile time, so keep it off for air-gapped builds. ## Build Profiles diff --git a/docs/skills/forge-idiomatic-engineer/references/frontend.md b/docs/skills/forge-idiomatic-engineer/references/frontend.md index 39625829..756b0163 100644 --- a/docs/skills/forge-idiomatic-engineer/references/frontend.md +++ b/docs/skills/forge-idiomatic-engineer/references/frontend.md @@ -126,7 +126,7 @@ Daily-rotating hashed visitor IDs, no cookies. Set `anonymize_ip = true` in `[si ### GeoIP -Country resolution is automatic. Forge ships an embedded DB-IP Country Lite database that resolves client IPs to ISO 3166-1 alpha-2 country codes stored in the `country` column of `forge_signals_events`. No configuration needed. For city-level resolution, set `geoip_db_path` in `[signals]` to a MaxMind GeoLite2-City MMDB file. +GeoIP has two backends. The default build (`geoip` feature, in `full`) is a runtime MaxMind MMDB reader: set `geoip_db_path` in `[signals]` to a GeoLite2-City MMDB to populate the `country` and `city` columns of `forge_signals_events`. For zero-config country resolution with no MMDB file, build with the `geoip-embedded` feature, which bakes in a DB-IP Country Lite database (the only geoip option needing a build-time download). ### Client config