diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cfb8f59..16e7592e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,38 @@ All notable changes to the Firefly Framework for Rust. +## v26.6.27 — 2026-06-16 + +A Spring Boot **parity** increment: declarative rollback rules on +`#[transactional]`. Chosen from a 16-area parity-gap analysis as the best +value-to-effort gap (the transaction runtime already supported it). + +### Added + +- **`#[transactional(no_rollback_for = "", rollback_only_for = "")]`** + — declarative transaction rollback rules. Spring names exception *types*; + because Rust's `Result` already separates failure from success, the Firefly + analog names an error **pattern**. By default every `Err` rolls back; then: + - `no_rollback_for = "P"` — **Spring's `@Transactional(noRollbackFor = …)`**: + an `Err` matching pattern `P` **commits** instead of rolling back; + - `rollback_only_for = "P"`: roll back **only** when the `Err` matches `P`, + committing the rest; + - with both, `no_rollback_for` wins on overlap. + + The pattern is any Rust match pattern valid for the fn's error type (no `if` + guard), alternatives included (`"Error::A | Error::B"`). The macro lowers to + the already-present `transactional_with` / `transactional_with_on` runtime + entry points (which take a `should_rollback(&E) -> bool` predicate), composes + with `manager = "…"`, and the generated predicate is `matches!`-based, so a + pattern that does not fit the error type is a compile error. + + `rollback_only_for` is **not** named `rollback_for`: Spring's `rollbackFor` is + *additive* (it widens the set of exceptions that roll back), but Rust has no + checked/unchecked split — every `Err` already rolls back — so the faithful + rule here is *restrictive*. Writing `rollback_for` is a friendly compile error + pointing at the two rules above, so a Spring port can't be silently inverted. + No runtime or API changes elsewhere. + ## v26.6.26 — 2026-06-16 A correctness release. Every one of the **74 per-crate `README.md`** files was diff --git a/Cargo.lock b/Cargo.lock index e700c2e1..74dc0ef8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1436,7 +1436,7 @@ checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "firefly" -version = "26.6.26" +version = "26.6.27" dependencies = [ "axum", "firefly-actuator", @@ -1480,7 +1480,7 @@ dependencies = [ [[package]] name = "firefly-actuator" -version = "26.6.26" +version = "26.6.27" dependencies = [ "async-trait", "axum", @@ -1497,7 +1497,7 @@ dependencies = [ [[package]] name = "firefly-admin" -version = "26.6.26" +version = "26.6.27" dependencies = [ "async-trait", "axum", @@ -1526,7 +1526,7 @@ dependencies = [ [[package]] name = "firefly-aop" -version = "26.6.26" +version = "26.6.27" dependencies = [ "async-trait", "inventory", @@ -1536,7 +1536,7 @@ dependencies = [ [[package]] name = "firefly-backoffice" -version = "26.6.26" +version = "26.6.27" dependencies = [ "async-trait", "axum", @@ -1555,7 +1555,7 @@ dependencies = [ [[package]] name = "firefly-cache" -version = "26.6.26" +version = "26.6.27" dependencies = [ "async-trait", "firefly-observability", @@ -1567,7 +1567,7 @@ dependencies = [ [[package]] name = "firefly-cache-postgres" -version = "26.6.26" +version = "26.6.27" dependencies = [ "async-trait", "chrono", @@ -1579,7 +1579,7 @@ dependencies = [ [[package]] name = "firefly-cache-redis" -version = "26.6.26" +version = "26.6.27" dependencies = [ "async-trait", "firefly-cache", @@ -1591,7 +1591,7 @@ dependencies = [ [[package]] name = "firefly-callbacks" -version = "26.6.26" +version = "26.6.27" dependencies = [ "async-trait", "axum", @@ -1617,7 +1617,7 @@ dependencies = [ [[package]] name = "firefly-cli" -version = "26.6.26" +version = "26.6.27" dependencies = [ "axum", "chrono", @@ -1638,7 +1638,7 @@ dependencies = [ [[package]] name = "firefly-client" -version = "26.6.26" +version = "26.6.27" dependencies = [ "async-stream", "axum", @@ -1660,7 +1660,7 @@ dependencies = [ [[package]] name = "firefly-config" -version = "26.6.26" +version = "26.6.27" dependencies = [ "axum", "regex", @@ -1675,7 +1675,7 @@ dependencies = [ [[package]] name = "firefly-config-server" -version = "26.6.26" +version = "26.6.27" dependencies = [ "async-trait", "axum", @@ -1691,7 +1691,7 @@ dependencies = [ [[package]] name = "firefly-container" -version = "26.6.26" +version = "26.6.27" dependencies = [ "futures", "inventory", @@ -1702,7 +1702,7 @@ dependencies = [ [[package]] name = "firefly-cqrs" -version = "26.6.26" +version = "26.6.27" dependencies = [ "async-trait", "chrono", @@ -1725,7 +1725,7 @@ dependencies = [ [[package]] name = "firefly-data" -version = "26.6.26" +version = "26.6.27" dependencies = [ "async-stream", "async-trait", @@ -1742,7 +1742,7 @@ dependencies = [ [[package]] name = "firefly-data-mongodb" -version = "26.6.26" +version = "26.6.27" dependencies = [ "async-stream", "async-trait", @@ -1760,7 +1760,7 @@ dependencies = [ [[package]] name = "firefly-data-sqlx" -version = "26.6.26" +version = "26.6.27" dependencies = [ "async-stream", "async-trait", @@ -1782,7 +1782,7 @@ dependencies = [ [[package]] name = "firefly-ecm" -version = "26.6.26" +version = "26.6.27" dependencies = [ "async-trait", "chrono", @@ -1798,7 +1798,7 @@ dependencies = [ [[package]] name = "firefly-ecm-esignature-adobe-sign" -version = "26.6.26" +version = "26.6.27" dependencies = [ "async-trait", "axum", @@ -1815,7 +1815,7 @@ dependencies = [ [[package]] name = "firefly-ecm-esignature-docusign" -version = "26.6.26" +version = "26.6.27" dependencies = [ "async-trait", "axum", @@ -1832,7 +1832,7 @@ dependencies = [ [[package]] name = "firefly-ecm-esignature-logalty" -version = "26.6.26" +version = "26.6.27" dependencies = [ "async-trait", "axum", @@ -1849,7 +1849,7 @@ dependencies = [ [[package]] name = "firefly-ecm-storage-aws" -version = "26.6.26" +version = "26.6.27" dependencies = [ "async-trait", "axum", @@ -1869,7 +1869,7 @@ dependencies = [ [[package]] name = "firefly-ecm-storage-azure" -version = "26.6.26" +version = "26.6.27" dependencies = [ "async-trait", "axum", @@ -1889,7 +1889,7 @@ dependencies = [ [[package]] name = "firefly-eda" -version = "26.6.26" +version = "26.6.27" dependencies = [ "async-trait", "base64", @@ -1911,7 +1911,7 @@ dependencies = [ [[package]] name = "firefly-eda-kafka" -version = "26.6.26" +version = "26.6.27" dependencies = [ "async-trait", "firefly-eda", @@ -1926,7 +1926,7 @@ dependencies = [ [[package]] name = "firefly-eda-postgres" -version = "26.6.26" +version = "26.6.27" dependencies = [ "async-trait", "chrono", @@ -1944,7 +1944,7 @@ dependencies = [ [[package]] name = "firefly-eda-rabbitmq" -version = "26.6.26" +version = "26.6.27" dependencies = [ "async-trait", "firefly-eda", @@ -1959,7 +1959,7 @@ dependencies = [ [[package]] name = "firefly-eda-redis" -version = "26.6.26" +version = "26.6.27" dependencies = [ "async-trait", "firefly-eda", @@ -1975,7 +1975,7 @@ dependencies = [ [[package]] name = "firefly-eventsourcing" -version = "26.6.26" +version = "26.6.27" dependencies = [ "async-trait", "base64", @@ -1993,7 +1993,7 @@ dependencies = [ [[package]] name = "firefly-i18n" -version = "26.6.26" +version = "26.6.27" dependencies = [ "axum", "http", @@ -2008,7 +2008,7 @@ dependencies = [ [[package]] name = "firefly-idp" -version = "26.6.26" +version = "26.6.27" dependencies = [ "async-trait", "axum", @@ -2024,7 +2024,7 @@ dependencies = [ [[package]] name = "firefly-idp-aws-cognito" -version = "26.6.26" +version = "26.6.27" dependencies = [ "async-trait", "axum", @@ -2045,7 +2045,7 @@ dependencies = [ [[package]] name = "firefly-idp-azure-ad" -version = "26.6.26" +version = "26.6.27" dependencies = [ "async-trait", "axum", @@ -2062,7 +2062,7 @@ dependencies = [ [[package]] name = "firefly-idp-internal-db" -version = "26.6.26" +version = "26.6.27" dependencies = [ "async-trait", "axum", @@ -2084,7 +2084,7 @@ dependencies = [ [[package]] name = "firefly-idp-keycloak" -version = "26.6.26" +version = "26.6.27" dependencies = [ "async-trait", "axum", @@ -2101,7 +2101,7 @@ dependencies = [ [[package]] name = "firefly-integration-tests" -version = "26.6.26" +version = "26.6.27" dependencies = [ "async-trait", "axum", @@ -2136,7 +2136,7 @@ dependencies = [ [[package]] name = "firefly-kernel" -version = "26.6.26" +version = "26.6.27" dependencies = [ "chrono", "serde", @@ -2148,7 +2148,7 @@ dependencies = [ [[package]] name = "firefly-lifecycle" -version = "26.6.26" +version = "26.6.27" dependencies = [ "thiserror 1.0.69", "tokio", @@ -2157,7 +2157,7 @@ dependencies = [ [[package]] name = "firefly-macros" -version = "26.6.26" +version = "26.6.27" dependencies = [ "async-trait", "axum", @@ -2179,7 +2179,7 @@ dependencies = [ [[package]] name = "firefly-migrations" -version = "26.6.26" +version = "26.6.27" dependencies = [ "chrono", "hex", @@ -2192,7 +2192,7 @@ dependencies = [ [[package]] name = "firefly-notifications" -version = "26.6.26" +version = "26.6.27" dependencies = [ "async-trait", "chrono", @@ -2207,7 +2207,7 @@ dependencies = [ [[package]] name = "firefly-notifications-firebase" -version = "26.6.26" +version = "26.6.27" dependencies = [ "async-trait", "axum", @@ -2223,7 +2223,7 @@ dependencies = [ [[package]] name = "firefly-notifications-resend" -version = "26.6.26" +version = "26.6.27" dependencies = [ "async-trait", "axum", @@ -2240,7 +2240,7 @@ dependencies = [ [[package]] name = "firefly-notifications-sendgrid" -version = "26.6.26" +version = "26.6.27" dependencies = [ "async-trait", "axum", @@ -2257,7 +2257,7 @@ dependencies = [ [[package]] name = "firefly-notifications-smtp" -version = "26.6.26" +version = "26.6.27" dependencies = [ "async-trait", "base64", @@ -2274,7 +2274,7 @@ dependencies = [ [[package]] name = "firefly-notifications-twilio" -version = "26.6.26" +version = "26.6.27" dependencies = [ "async-trait", "axum", @@ -2290,7 +2290,7 @@ dependencies = [ [[package]] name = "firefly-observability" -version = "26.6.26" +version = "26.6.27" dependencies = [ "async-trait", "chrono", @@ -2314,7 +2314,7 @@ dependencies = [ [[package]] name = "firefly-openapi" -version = "26.6.26" +version = "26.6.27" dependencies = [ "axum", "chrono", @@ -2330,7 +2330,7 @@ dependencies = [ [[package]] name = "firefly-orchestration" -version = "26.6.26" +version = "26.6.27" dependencies = [ "async-trait", "axum", @@ -2354,7 +2354,7 @@ dependencies = [ [[package]] name = "firefly-plugins" -version = "26.6.26" +version = "26.6.27" dependencies = [ "async-trait", "chrono", @@ -2364,7 +2364,7 @@ dependencies = [ [[package]] name = "firefly-reactive" -version = "26.6.26" +version = "26.6.27" dependencies = [ "async-stream", "firefly-kernel", @@ -2376,7 +2376,7 @@ dependencies = [ [[package]] name = "firefly-resilience" -version = "26.6.26" +version = "26.6.27" dependencies = [ "async-trait", "firefly-config", @@ -2388,7 +2388,7 @@ dependencies = [ [[package]] name = "firefly-rule-engine" -version = "26.6.26" +version = "26.6.27" dependencies = [ "async-trait", "axum", @@ -2408,7 +2408,7 @@ dependencies = [ [[package]] name = "firefly-sample-lumen" -version = "26.6.26" +version = "26.6.27" dependencies = [ "async-trait", "axum", @@ -2425,7 +2425,7 @@ dependencies = [ [[package]] name = "firefly-sample-lumen-ledger-core" -version = "26.6.26" +version = "26.6.27" dependencies = [ "async-trait", "chrono", @@ -2440,7 +2440,7 @@ dependencies = [ [[package]] name = "firefly-sample-lumen-ledger-interfaces" -version = "26.6.26" +version = "26.6.27" dependencies = [ "chrono", "firefly", @@ -2451,7 +2451,7 @@ dependencies = [ [[package]] name = "firefly-sample-lumen-ledger-models" -version = "26.6.26" +version = "26.6.27" dependencies = [ "chrono", "firefly", @@ -2464,7 +2464,7 @@ dependencies = [ [[package]] name = "firefly-sample-lumen-ledger-sdk" -version = "26.6.26" +version = "26.6.27" dependencies = [ "firefly-client", "firefly-sample-lumen-ledger-interfaces", @@ -2476,7 +2476,7 @@ dependencies = [ [[package]] name = "firefly-sample-lumen-ledger-web" -version = "26.6.26" +version = "26.6.27" dependencies = [ "axum", "firefly", @@ -2494,7 +2494,7 @@ dependencies = [ [[package]] name = "firefly-sample-macro-quickstart" -version = "26.6.26" +version = "26.6.27" dependencies = [ "axum", "firefly", @@ -2507,7 +2507,7 @@ dependencies = [ [[package]] name = "firefly-sample-orders" -version = "26.6.26" +version = "26.6.27" dependencies = [ "async-trait", "axum", @@ -2530,7 +2530,7 @@ dependencies = [ [[package]] name = "firefly-sample-reactive-banking" -version = "26.6.26" +version = "26.6.27" dependencies = [ "async-stream", "async-trait", @@ -2570,7 +2570,7 @@ dependencies = [ [[package]] name = "firefly-scheduling" -version = "26.6.26" +version = "26.6.27" dependencies = [ "async-trait", "chrono", @@ -2591,7 +2591,7 @@ dependencies = [ [[package]] name = "firefly-security" -version = "26.6.26" +version = "26.6.27" dependencies = [ "argon2", "async-trait", @@ -2617,7 +2617,7 @@ dependencies = [ [[package]] name = "firefly-session" -version = "26.6.26" +version = "26.6.27" dependencies = [ "async-trait", "axum", @@ -2642,7 +2642,7 @@ dependencies = [ [[package]] name = "firefly-session-mongodb" -version = "26.6.26" +version = "26.6.27" dependencies = [ "async-trait", "firefly-session", @@ -2655,7 +2655,7 @@ dependencies = [ [[package]] name = "firefly-session-postgres" -version = "26.6.26" +version = "26.6.27" dependencies = [ "async-trait", "firefly-session", @@ -2667,7 +2667,7 @@ dependencies = [ [[package]] name = "firefly-session-redis" -version = "26.6.26" +version = "26.6.27" dependencies = [ "async-trait", "firefly-session", @@ -2679,7 +2679,7 @@ dependencies = [ [[package]] name = "firefly-shell" -version = "26.6.26" +version = "26.6.27" dependencies = [ "async-trait", "futures", @@ -2689,7 +2689,7 @@ dependencies = [ [[package]] name = "firefly-spike-linkspike-core" -version = "26.6.26" +version = "26.6.27" dependencies = [ "axum", "firefly", @@ -2697,7 +2697,7 @@ dependencies = [ [[package]] name = "firefly-spike-linkspike-web" -version = "26.6.26" +version = "26.6.27" dependencies = [ "axum", "firefly", @@ -2709,7 +2709,7 @@ dependencies = [ [[package]] name = "firefly-sse" -version = "26.6.26" +version = "26.6.27" dependencies = [ "axum", "bytes", @@ -2725,7 +2725,7 @@ dependencies = [ [[package]] name = "firefly-starter-application" -version = "26.6.26" +version = "26.6.27" dependencies = [ "async-trait", "firefly-cqrs", @@ -2739,7 +2739,7 @@ dependencies = [ [[package]] name = "firefly-starter-core" -version = "26.6.26" +version = "26.6.27" dependencies = [ "async-trait", "axum", @@ -2765,7 +2765,7 @@ dependencies = [ [[package]] name = "firefly-starter-data" -version = "26.6.26" +version = "26.6.27" dependencies = [ "axum", "firefly-cqrs", @@ -2778,7 +2778,7 @@ dependencies = [ [[package]] name = "firefly-starter-domain" -version = "26.6.26" +version = "26.6.27" dependencies = [ "async-trait", "firefly-eventsourcing", @@ -2790,7 +2790,7 @@ dependencies = [ [[package]] name = "firefly-starter-experience" -version = "26.6.26" +version = "26.6.27" dependencies = [ "async-trait", "axum", @@ -2811,7 +2811,7 @@ dependencies = [ [[package]] name = "firefly-starter-web" -version = "26.6.26" +version = "26.6.27" dependencies = [ "axum", "firefly-kernel", @@ -2826,7 +2826,7 @@ dependencies = [ [[package]] name = "firefly-testkit" -version = "26.6.26" +version = "26.6.27" dependencies = [ "axum", "base64", @@ -2845,7 +2845,7 @@ dependencies = [ [[package]] name = "firefly-transactional" -version = "26.6.26" +version = "26.6.27" dependencies = [ "async-trait", "inventory", @@ -2856,7 +2856,7 @@ dependencies = [ [[package]] name = "firefly-utils" -version = "26.6.26" +version = "26.6.27" dependencies = [ "aes-gcm", "base64", @@ -2872,7 +2872,7 @@ dependencies = [ [[package]] name = "firefly-validators" -version = "26.6.26" +version = "26.6.27" dependencies = [ "chrono", "firefly-kernel", @@ -2883,7 +2883,7 @@ dependencies = [ [[package]] name = "firefly-web" -version = "26.6.26" +version = "26.6.27" dependencies = [ "async-trait", "axum", @@ -2922,7 +2922,7 @@ dependencies = [ [[package]] name = "firefly-webhooks" -version = "26.6.26" +version = "26.6.27" dependencies = [ "async-trait", "axum", @@ -2950,7 +2950,7 @@ dependencies = [ [[package]] name = "firefly-websocket" -version = "26.6.26" +version = "26.6.27" dependencies = [ "async-trait", "axum", diff --git a/Cargo.toml b/Cargo.toml index 0f49d915..ce843f5c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -90,7 +90,7 @@ members = [ ] [workspace.package] -version = "26.6.26" +version = "26.6.27" edition = "2021" license = "Apache-2.0" repository = "https://github.com/fireflyframework/fireflyframework-rust" @@ -99,80 +99,80 @@ rust-version = "1.88" [workspace.dependencies] # ---- internal crates ---- -firefly-reactive = { path = "crates/reactive", version = "26.6.26" } -firefly-kernel = { path = "crates/kernel", version = "26.6.26" } -firefly-utils = { path = "crates/utils", version = "26.6.26" } -firefly-validators = { path = "crates/validators", version = "26.6.26" } -firefly-web = { path = "crates/web", version = "26.6.26" } -firefly-config = { path = "crates/config", version = "26.6.26" } -firefly-i18n = { path = "crates/i18n", version = "26.6.26" } -firefly-cache = { path = "crates/cache", version = "26.6.26" } -firefly-observability = { path = "crates/observability", version = "26.6.26" } -firefly-data = { path = "crates/data", version = "26.6.26" } -firefly-cqrs = { path = "crates/cqrs", version = "26.6.26" } -firefly-eda = { path = "crates/eda", version = "26.6.26" } -firefly-eventsourcing = { path = "crates/eventsourcing", version = "26.6.26" } -firefly-orchestration = { path = "crates/orchestration", version = "26.6.26" } -firefly-rule-engine = { path = "crates/rule-engine", version = "26.6.26" } -firefly-plugins = { path = "crates/plugins", version = "26.6.26" } -firefly-lifecycle = { path = "crates/lifecycle", version = "26.6.26" } -firefly-actuator = { path = "crates/actuator", version = "26.6.26" } -firefly-scheduling = { path = "crates/scheduling", version = "26.6.26" } -firefly-resilience = { path = "crates/resilience", version = "26.6.26" } -firefly-security = { path = "crates/security", version = "26.6.26" } -firefly-migrations = { path = "crates/migrations", version = "26.6.26" } -firefly-openapi = { path = "crates/openapi", version = "26.6.26" } -firefly-sse = { path = "crates/sse", version = "26.6.26" } -firefly-transactional = { path = "crates/transactional", version = "26.6.26" } -firefly-testkit = { path = "crates/testkit", version = "26.6.26" } -firefly-client = { path = "crates/client", version = "26.6.26" } -firefly-config-server = { path = "crates/config-server", version = "26.6.26" } -firefly-idp = { path = "crates/idp", version = "26.6.26" } -firefly-idp-internal-db = { path = "crates/idp-internal-db", version = "26.6.26" } -firefly-idp-keycloak = { path = "crates/idp-keycloak", version = "26.6.26" } -firefly-idp-azure-ad = { path = "crates/idp-azure-ad", version = "26.6.26" } -firefly-idp-aws-cognito = { path = "crates/idp-aws-cognito", version = "26.6.26" } -firefly-ecm = { path = "crates/ecm", version = "26.6.26" } -firefly-ecm-storage-aws = { path = "crates/ecm-storage-aws", version = "26.6.26" } -firefly-ecm-storage-azure = { path = "crates/ecm-storage-azure", version = "26.6.26" } -firefly-ecm-esignature-docusign = { path = "crates/ecm-esignature-docusign", version = "26.6.26" } -firefly-ecm-esignature-adobe-sign = { path = "crates/ecm-esignature-adobe-sign", version = "26.6.26" } -firefly-ecm-esignature-logalty = { path = "crates/ecm-esignature-logalty", version = "26.6.26" } -firefly-notifications = { path = "crates/notifications", version = "26.6.26" } -firefly-notifications-sendgrid = { path = "crates/notifications-sendgrid", version = "26.6.26" } -firefly-notifications-resend = { path = "crates/notifications-resend", version = "26.6.26" } -firefly-notifications-twilio = { path = "crates/notifications-twilio", version = "26.6.26" } -firefly-notifications-firebase = { path = "crates/notifications-firebase", version = "26.6.26" } -firefly-callbacks = { path = "crates/callbacks", version = "26.6.26" } -firefly-webhooks = { path = "crates/webhooks", version = "26.6.26" } -firefly-starter-core = { path = "crates/starter-core", version = "26.6.26" } -firefly-starter-application = { path = "crates/starter-application", version = "26.6.26" } -firefly-starter-domain = { path = "crates/starter-domain", version = "26.6.26" } -firefly-starter-data = { path = "crates/starter-data", version = "26.6.26" } -firefly-backoffice = { path = "crates/backoffice", version = "26.6.26" } -firefly-admin = { path = "crates/admin", version = "26.6.26" } -firefly-aop = { path = "crates/aop", version = "26.6.26" } -firefly-cli = { path = "crates/cli", version = "26.6.26" } -firefly-container = { path = "crates/container", version = "26.6.26" } -firefly-session = { path = "crates/session", version = "26.6.26" } -firefly-shell = { path = "crates/shell", version = "26.6.26" } -firefly-websocket = { path = "crates/websocket", version = "26.6.26" } -firefly-notifications-smtp = { path = "crates/notifications-smtp", version = "26.6.26" } -firefly-cache-redis = { path = "crates/cache-redis", version = "26.6.26" } -firefly-eda-kafka = { path = "crates/eda-kafka", version = "26.6.26" } -firefly-eda-rabbitmq = { path = "crates/eda-rabbitmq", version = "26.6.26" } -firefly-eda-postgres = { path = "crates/eda-postgres", version = "26.6.26" } -firefly-eda-redis = { path = "crates/eda-redis", version = "26.6.26" } -firefly-cache-postgres = { path = "crates/cache-postgres", version = "26.6.26" } -firefly-starter-web = { path = "crates/starter-web", version = "26.6.26" } -firefly = { path = "crates/firefly", version = "26.6.26" } -firefly-macros = { path = "crates/macros", version = "26.6.26" } -firefly-data-sqlx = { path = "crates/data-sqlx", version = "26.6.26" } -firefly-data-mongodb = { path = "crates/data-mongodb", version = "26.6.26" } -firefly-session-redis = { path = "crates/session-redis", version = "26.6.26" } -firefly-session-postgres = { path = "crates/session-postgres", version = "26.6.26" } -firefly-session-mongodb = { path = "crates/session-mongodb", version = "26.6.26" } -firefly-starter-experience = { path = "crates/starter-experience", version = "26.6.26" } +firefly-reactive = { path = "crates/reactive", version = "26.6.27" } +firefly-kernel = { path = "crates/kernel", version = "26.6.27" } +firefly-utils = { path = "crates/utils", version = "26.6.27" } +firefly-validators = { path = "crates/validators", version = "26.6.27" } +firefly-web = { path = "crates/web", version = "26.6.27" } +firefly-config = { path = "crates/config", version = "26.6.27" } +firefly-i18n = { path = "crates/i18n", version = "26.6.27" } +firefly-cache = { path = "crates/cache", version = "26.6.27" } +firefly-observability = { path = "crates/observability", version = "26.6.27" } +firefly-data = { path = "crates/data", version = "26.6.27" } +firefly-cqrs = { path = "crates/cqrs", version = "26.6.27" } +firefly-eda = { path = "crates/eda", version = "26.6.27" } +firefly-eventsourcing = { path = "crates/eventsourcing", version = "26.6.27" } +firefly-orchestration = { path = "crates/orchestration", version = "26.6.27" } +firefly-rule-engine = { path = "crates/rule-engine", version = "26.6.27" } +firefly-plugins = { path = "crates/plugins", version = "26.6.27" } +firefly-lifecycle = { path = "crates/lifecycle", version = "26.6.27" } +firefly-actuator = { path = "crates/actuator", version = "26.6.27" } +firefly-scheduling = { path = "crates/scheduling", version = "26.6.27" } +firefly-resilience = { path = "crates/resilience", version = "26.6.27" } +firefly-security = { path = "crates/security", version = "26.6.27" } +firefly-migrations = { path = "crates/migrations", version = "26.6.27" } +firefly-openapi = { path = "crates/openapi", version = "26.6.27" } +firefly-sse = { path = "crates/sse", version = "26.6.27" } +firefly-transactional = { path = "crates/transactional", version = "26.6.27" } +firefly-testkit = { path = "crates/testkit", version = "26.6.27" } +firefly-client = { path = "crates/client", version = "26.6.27" } +firefly-config-server = { path = "crates/config-server", version = "26.6.27" } +firefly-idp = { path = "crates/idp", version = "26.6.27" } +firefly-idp-internal-db = { path = "crates/idp-internal-db", version = "26.6.27" } +firefly-idp-keycloak = { path = "crates/idp-keycloak", version = "26.6.27" } +firefly-idp-azure-ad = { path = "crates/idp-azure-ad", version = "26.6.27" } +firefly-idp-aws-cognito = { path = "crates/idp-aws-cognito", version = "26.6.27" } +firefly-ecm = { path = "crates/ecm", version = "26.6.27" } +firefly-ecm-storage-aws = { path = "crates/ecm-storage-aws", version = "26.6.27" } +firefly-ecm-storage-azure = { path = "crates/ecm-storage-azure", version = "26.6.27" } +firefly-ecm-esignature-docusign = { path = "crates/ecm-esignature-docusign", version = "26.6.27" } +firefly-ecm-esignature-adobe-sign = { path = "crates/ecm-esignature-adobe-sign", version = "26.6.27" } +firefly-ecm-esignature-logalty = { path = "crates/ecm-esignature-logalty", version = "26.6.27" } +firefly-notifications = { path = "crates/notifications", version = "26.6.27" } +firefly-notifications-sendgrid = { path = "crates/notifications-sendgrid", version = "26.6.27" } +firefly-notifications-resend = { path = "crates/notifications-resend", version = "26.6.27" } +firefly-notifications-twilio = { path = "crates/notifications-twilio", version = "26.6.27" } +firefly-notifications-firebase = { path = "crates/notifications-firebase", version = "26.6.27" } +firefly-callbacks = { path = "crates/callbacks", version = "26.6.27" } +firefly-webhooks = { path = "crates/webhooks", version = "26.6.27" } +firefly-starter-core = { path = "crates/starter-core", version = "26.6.27" } +firefly-starter-application = { path = "crates/starter-application", version = "26.6.27" } +firefly-starter-domain = { path = "crates/starter-domain", version = "26.6.27" } +firefly-starter-data = { path = "crates/starter-data", version = "26.6.27" } +firefly-backoffice = { path = "crates/backoffice", version = "26.6.27" } +firefly-admin = { path = "crates/admin", version = "26.6.27" } +firefly-aop = { path = "crates/aop", version = "26.6.27" } +firefly-cli = { path = "crates/cli", version = "26.6.27" } +firefly-container = { path = "crates/container", version = "26.6.27" } +firefly-session = { path = "crates/session", version = "26.6.27" } +firefly-shell = { path = "crates/shell", version = "26.6.27" } +firefly-websocket = { path = "crates/websocket", version = "26.6.27" } +firefly-notifications-smtp = { path = "crates/notifications-smtp", version = "26.6.27" } +firefly-cache-redis = { path = "crates/cache-redis", version = "26.6.27" } +firefly-eda-kafka = { path = "crates/eda-kafka", version = "26.6.27" } +firefly-eda-rabbitmq = { path = "crates/eda-rabbitmq", version = "26.6.27" } +firefly-eda-postgres = { path = "crates/eda-postgres", version = "26.6.27" } +firefly-eda-redis = { path = "crates/eda-redis", version = "26.6.27" } +firefly-cache-postgres = { path = "crates/cache-postgres", version = "26.6.27" } +firefly-starter-web = { path = "crates/starter-web", version = "26.6.27" } +firefly = { path = "crates/firefly", version = "26.6.27" } +firefly-macros = { path = "crates/macros", version = "26.6.27" } +firefly-data-sqlx = { path = "crates/data-sqlx", version = "26.6.27" } +firefly-data-mongodb = { path = "crates/data-mongodb", version = "26.6.27" } +firefly-session-redis = { path = "crates/session-redis", version = "26.6.27" } +firefly-session-postgres = { path = "crates/session-postgres", version = "26.6.27" } +firefly-session-mongodb = { path = "crates/session-mongodb", version = "26.6.27" } +firefly-starter-experience = { path = "crates/starter-experience", version = "26.6.27" } # ---- async runtime + web ---- tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "signal", "io-util", "net", "fs"] } diff --git a/crates/macros/src/transactional.rs b/crates/macros/src/transactional.rs index 6ff2f211..b9b73a98 100644 --- a/crates/macros/src/transactional.rs +++ b/crates/macros/src/transactional.rs @@ -28,9 +28,32 @@ //! (e.g. `self.tx_manager()`) yields a value `m` with //! `&m: &Arc`. This keeps a multi-datasource service, or //! a per-test-isolated one, off the process-global registry. +//! +//! `#[transactional(no_rollback_for = "", rollback_only_for = "")]` +//! control which returned errors roll back. Because `Result`'s `Err(E)` already +//! separates failure from success, the rule is expressed as an error **pattern** +//! (Spring names exception *types*) and decides, per error, whether a returned +//! `Err` rolls back or commits anyway: +//! +//! - default (no rule): roll back on **every** `Err` (Rust has no +//! checked/unchecked split, so all errors roll back); +//! - `no_rollback_for = "P"` — **Spring's `@Transactional(noRollbackFor = …)`**: +//! an `Err` matching pattern `P` **commits** instead of rolling back (e.g. a +//! domain "already-applied" that should still persist its side effect); +//! - `rollback_only_for = "P"`: roll back **only** when the `Err` matches `P`, +//! committing every other error. This is a Rust-native *restrictive* rule — +//! deliberately **not** named `rollback_for`, because Spring's `rollbackFor` +//! is *additive* (it widens an always-rollback set that does not exist here, +//! since every Rust `Err` already rolls back). The distinct name keeps a +//! Spring port from being silently inverted; +//! - with both, `no_rollback_for` wins on overlap. +//! +//! Each pattern is any Rust match pattern (no `if` guard) valid for the fn's +//! error type, alternatives included: `no_rollback_for = "Error::A | Error::B"`. -use proc_macro2::TokenStream; +use proc_macro2::{Span, TokenStream}; use quote::quote; +use syn::parse::Parser as _; use syn::{ItemFn, ReturnType}; use crate::common::facade_from_override; @@ -50,6 +73,17 @@ struct TxAttr { /// `&m: &Arc`. Use this for a multi-datasource /// service, or to keep per-test isolation (each instance owns its manager). manager: Option, + /// `#[transactional(rollback_only_for = "")]` — roll back **only** when + /// the returned `Err` matches this pattern (commit every other error). A + /// Rust-native *restrictive* rule: **not** Spring's `rollbackFor`, which is + /// *additive*. Named distinctly so a Spring port is never silently inverted. + /// A match pattern valid for the fn's error type, e.g. `"Error::Backend(_)"`. + rollback_only_for: Option, + /// `#[transactional(no_rollback_for = "")]` — Spring's + /// `@Transactional(noRollbackFor = …)`. An `Err` matching this pattern + /// **commits** instead of rolling back. Wins over `rollback_only_for` on + /// overlap. + no_rollback_for: Option, } /// Expands `#[transactional]` / `#[transactional(propagation = "...", …)]` on an @@ -97,32 +131,45 @@ pub(crate) fn transactional_impl(args: TokenStream, mut func: ItemFn) -> syn::Re } }; - // With `manager = "..."`, drive an explicit manager via `transactional_on` - // (Spring's `@Transactional("txManager")`); otherwise use the process-global - // registry via `transactional`. - let driver = match attr + // A `rollback_for` / `no_rollback_for` rule, if any, becomes a + // `should_rollback(&E) -> bool` predicate; its presence selects the + // `transactional_with` / `transactional_with_on` runtime variants. + let predicate = rollback_predicate(&attr)?; + + let manager = match attr .manager .as_deref() .map(str::trim) .filter(|s| !s.is_empty()) { - Some(expr) => { - let manager: syn::Expr = syn::parse_str(expr).map_err(|e| { - syn::Error::new_spanned( - &func.sig, - format!( - "#[transactional] `manager` must be a Rust expression yielding a value \ - `m` with `&m: &Arc` (e.g. \ - manager = \"self.tx_manager()\"): {e}" - ), - ) - })?; - quote! { - #tx::transactional_on(&(#manager), #options, move || async move #block).await - } - } - None => quote! { - #tx::transactional(#options, move || async move #block).await + Some(expr) => Some(syn::parse_str::(expr).map_err(|e| { + syn::Error::new_spanned( + &func.sig, + format!( + "#[transactional] `manager` must be a Rust expression yielding a value \ + `m` with `&m: &Arc` (e.g. \ + manager = \"self.tx_manager()\"): {e}" + ), + ) + })?), + None => None, + }; + + // Four runtime entry points, picked by (explicit manager?) × (rollback rule?): + // Spring's `@Transactional` / `@Transactional("mgr")` / `@Transactional(rollbackFor=…)`. + let op = quote!(move || async move #block); + let driver = match (manager, predicate) { + (Some(manager), Some(pred)) => quote! { + #tx::transactional_with_on(&(#manager), #options, #pred, #op).await + }, + (Some(manager), None) => quote! { + #tx::transactional_on(&(#manager), #options, #op).await + }, + (None, Some(pred)) => quote! { + #tx::transactional_with(#options, #pred, #op).await + }, + (None, None) => quote! { + #tx::transactional(#options, #op).await }, }; @@ -132,6 +179,57 @@ pub(crate) fn transactional_impl(args: TokenStream, mut func: ItemFn) -> syn::Re Ok(quote!(#func)) } +/// Builds the `should_rollback(&E) -> bool` predicate from the +/// `rollback_only_for` / `no_rollback_for` patterns, or `None` when neither is +/// set (the default "rollback on every `Err`" needs no predicate). Precedence: +/// `no_rollback_for` is checked first and wins; then `rollback_only_for` (when +/// set) restricts the rollback set; otherwise everything else rolls back. +fn rollback_predicate(attr: &TxAttr) -> syn::Result> { + let rollback = attr + .rollback_only_for + .as_deref() + .map(|s| parse_pattern(s, "rollback_only_for")) + .transpose()?; + let no_rollback = attr + .no_rollback_for + .as_deref() + .map(|s| parse_pattern(s, "no_rollback_for")) + .transpose()?; + if rollback.is_none() && no_rollback.is_none() { + return Ok(None); + } + // `__tx_err: &E`; match ergonomics let a value pattern match through the + // reference, so `matches!(__tx_err, Error::Variant)` is correct. + let no_rollback_guard = no_rollback.map(|pat| { + quote! { if ::core::matches!(__tx_err, #pat) { return false; } } + }); + let rollback_decision = match rollback { + Some(pat) => quote! { ::core::matches!(__tx_err, #pat) }, + None => quote! { true }, + }; + Ok(Some(quote! { + |__tx_err: &_| -> bool { + #no_rollback_guard + #rollback_decision + } + })) +} + +/// Parses a `rollback_only_for` / `no_rollback_for` string into a match pattern +/// (allowing `A | B` alternatives; no `if` guard — `matches!` rejects guards), +/// with an error message naming the argument. +fn parse_pattern(src: &str, arg: &str) -> syn::Result { + syn::Pat::parse_multi.parse_str(src).map_err(|e| { + syn::Error::new( + Span::call_site(), + format!( + "#[transactional] `{arg}` must be a match pattern valid for the fn's error type \ + (e.g. {arg} = \"Error::NotFound\" or \"Error::A | Error::B\"): {e}" + ), + ) + }) +} + fn propagation_tokens(tx: &TokenStream, value: Option<&str>) -> syn::Result { let variant = match value.map(normalize).as_deref() { None | Some("required") => "Required", @@ -203,10 +301,25 @@ fn parse_attr(args: TokenStream) -> syn::Result { attr.timeout_ms = Some(meta.value()?.parse::()?.base10_parse()?); } else if meta.path.is_ident("manager") { attr.manager = Some(meta.value()?.parse::()?.value()); + } else if meta.path.is_ident("rollback_only_for") { + attr.rollback_only_for = Some(meta.value()?.parse::()?.value()); + } else if meta.path.is_ident("no_rollback_for") { + attr.no_rollback_for = Some(meta.value()?.parse::()?.value()); + } else if meta.path.is_ident("rollback_for") { + // A Spring migrant's reflex — but Spring's `rollbackFor` is *additive* + // and has no faithful Rust analog (every `Err` already rolls back). + // Reject with a pointer to the two correct rules rather than silently + // inverting their transaction's rollback behaviour. + return Err(meta.error( + "#[transactional] has no `rollback_for`: Spring's `rollbackFor` is additive and \ + every Rust `Err` already rolls back. Use `no_rollback_for = \"\"` (Spring's \ + noRollbackFor — commit a matching error) or `rollback_only_for = \"\"` \ + (roll back only matching errors)", + )); } else { return Err(meta.error( "unknown #[transactional] argument; use propagation, isolation, read_only, \ - timeout_ms, manager, or crate", + timeout_ms, manager, no_rollback_for, rollback_only_for, or crate", )); } Ok(()) diff --git a/crates/macros/tests/transactional_rollback_rules.rs b/crates/macros/tests/transactional_rollback_rules.rs new file mode 100644 index 00000000..8c2758a9 --- /dev/null +++ b/crates/macros/tests/transactional_rollback_rules.rs @@ -0,0 +1,277 @@ +// Copyright 2026 Firefly Software Foundation. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! `#[transactional(no_rollback_for = …, rollback_only_for = …)]` — the +//! transaction rollback rules (`no_rollback_for` is Spring's `noRollbackFor`; +//! `rollback_only_for` is a Rust-native restrictive rule, not Spring's additive +//! `rollbackFor`). +//! +//! Spring names exception *types*; the Rust analog is an error *pattern*. These +//! tests drive each method through a spy `TransactionManager` that records, per +//! call, the **rollback decision** the generated `should_rollback` predicate +//! made — the `TxOutcome::rolled_back` flag, i.e. whether the returned `Err` +//! commits or rolls back — proving: +//! - default: every `Err` rolls back; +//! - `no_rollback_for`: a matching `Err` commits instead; +//! - `rollback_only_for`: only a matching `Err` rolls back; +//! - both: `no_rollback_for` wins on overlap; +//! - the rules apply on both the explicit-manager and process-global paths. + +use std::sync::atomic::{AtomicUsize, Ordering::SeqCst}; +use std::sync::Arc; + +use async_trait::async_trait; +use firefly::transactional::{BoxedTxOp, TransactionManager, TxError, TxOptions, TxOutcome}; + +/// The service's error type — the `rollback_only_for` / `no_rollback_for` +/// patterns name its variants, the way `@Transactional(noRollbackFor = …)` names +/// exception classes. +#[derive(Debug)] +enum SvcError { + /// A domain "not found" — a caller may want it to *commit* a side effect. + NotFound, + /// A validation failure carrying a message. + Validation(#[allow(dead_code)] String), + /// An infrastructure failure that must always roll back. + Backend(#[allow(dead_code)] String), + /// Transaction-infrastructure failure (begin/commit), via `From`. + Tx(#[allow(dead_code)] String), +} + +impl From for SvcError { + fn from(e: TxError) -> Self { + SvcError::Tx(e.to_string()) + } +} + +/// Records each governed call's *decision* — commit vs rollback — by reading the +/// `TxOutcome::rolled_back` flag the generated predicate produced. +#[derive(Default)] +struct DecisionSpy { + commits: AtomicUsize, + rollbacks: AtomicUsize, +} + +struct SpyManager { + spy: Arc, +} + +#[async_trait] +impl TransactionManager for SpyManager { + async fn execute<'a>(&self, _opts: TxOptions, op: BoxedTxOp<'a>) -> Result { + let outcome = op.await?; + if outcome.rolled_back { + self.spy.rollbacks.fetch_add(1, SeqCst); + } else { + self.spy.commits.fetch_add(1, SeqCst); + } + Ok(outcome) + } +} + +/// A service owning its manager (so the tests stay off the process-global +/// registry and run in parallel) — the rollback rules ride on the same +/// `manager = "…"` path, exercising `transactional_with_on`. +struct Ledger { + manager: Arc, +} + +impl Ledger { + fn tx_manager(&self) -> Arc { + Arc::clone(&self.manager) + } + + /// Default rule: every `Err` rolls back. + #[firefly::transactional(manager = "self.tx_manager()")] + async fn default_rule(&self, err: Option) -> Result { + match err { + Some(e) => Err(e), + None => Ok(1), + } + } + + /// `no_rollback_for`: a `NotFound` commits; everything else still rolls back. + #[firefly::transactional(manager = "self.tx_manager()", no_rollback_for = "SvcError::NotFound")] + async fn lenient_on_not_found(&self, err: Option) -> Result { + match err { + Some(e) => Err(e), + None => Ok(1), + } + } + + /// `rollback_only_for`: only a `Backend` rolls back; other errors commit. + #[firefly::transactional( + manager = "self.tx_manager()", + rollback_only_for = "SvcError::Backend(_)" + )] + async fn rollback_only_backend(&self, err: Option) -> Result { + match err { + Some(e) => Err(e), + None => Ok(1), + } + } + + /// Both rules with overlap on `Validation`: `no_rollback_for` wins, so a + /// `Validation` commits even though it is in the `rollback_only_for` set. + #[firefly::transactional( + manager = "self.tx_manager()", + rollback_only_for = "SvcError::Backend(_) | SvcError::Validation(_)", + no_rollback_for = "SvcError::Validation(_)" + )] + async fn both_rules(&self, err: Option) -> Result { + match err { + Some(e) => Err(e), + None => Ok(1), + } + } +} + +/// No `manager` attribute — routes through the **process-global** registry +/// (`transactional_with`), so the rollback rule is exercised on that path too, +/// not only the explicit-manager `transactional_with_on` path the methods above +/// use. +#[firefly::transactional(rollback_only_for = "SvcError::Backend(_)")] +async fn global_path(err: Option) -> Result { + match err { + Some(e) => Err(e), + None => Ok(1), + } +} + +fn ledger() -> (Ledger, Arc) { + let spy = Arc::new(DecisionSpy::default()); + let ledger = Ledger { + manager: Arc::new(SpyManager { + spy: Arc::clone(&spy), + }), + }; + (ledger, spy) +} + +#[tokio::test] +async fn default_rule_rolls_back_on_any_error() { + let (svc, spy) = ledger(); + svc.default_rule(None).await.expect("Ok commits"); + assert_eq!(spy.commits.load(SeqCst), 1); + + let _ = svc + .default_rule(Some(SvcError::NotFound)) + .await + .unwrap_err(); + let _ = svc + .default_rule(Some(SvcError::Backend("io".into()))) + .await + .unwrap_err(); + assert_eq!(spy.rollbacks.load(SeqCst), 2, "every Err rolls back"); + assert_eq!(spy.commits.load(SeqCst), 1, "only the Ok committed"); +} + +#[tokio::test] +async fn no_rollback_for_commits_the_matching_error() { + let (svc, spy) = ledger(); + + // NotFound matches no_rollback_for -> commits despite being an Err. + let err = svc + .lenient_on_not_found(Some(SvcError::NotFound)) + .await + .expect_err("the Err is still returned to the caller"); + assert!(matches!(err, SvcError::NotFound)); + assert_eq!(spy.commits.load(SeqCst), 1, "NotFound committed"); + assert_eq!(spy.rollbacks.load(SeqCst), 0); + + // A different error still rolls back. + let _ = svc + .lenient_on_not_found(Some(SvcError::Backend("io".into()))) + .await + .unwrap_err(); + assert_eq!(spy.rollbacks.load(SeqCst), 1, "Backend still rolls back"); + assert_eq!(spy.commits.load(SeqCst), 1); +} + +#[tokio::test] +async fn rollback_only_for_restricts_rollback_to_the_matching_error() { + let (svc, spy) = ledger(); + + // Backend matches rollback_only_for -> rolls back. + let _ = svc + .rollback_only_backend(Some(SvcError::Backend("io".into()))) + .await + .unwrap_err(); + assert_eq!(spy.rollbacks.load(SeqCst), 1); + + // Validation is NOT in rollback_only_for -> commits despite being an Err. + let _ = svc + .rollback_only_backend(Some(SvcError::Validation("bad".into()))) + .await + .unwrap_err(); + assert_eq!(spy.commits.load(SeqCst), 1, "non-Backend error commits"); + assert_eq!(spy.rollbacks.load(SeqCst), 1); +} + +#[tokio::test] +async fn no_rollback_for_wins_over_rollback_for_on_overlap() { + let (svc, spy) = ledger(); + + // Validation is in BOTH sets; no_rollback_for wins -> commits. + let _ = svc + .both_rules(Some(SvcError::Validation("bad".into()))) + .await + .unwrap_err(); + assert_eq!(spy.commits.load(SeqCst), 1, "overlap resolves to commit"); + assert_eq!(spy.rollbacks.load(SeqCst), 0); + + // Backend is only in rollback_only_for -> rolls back. + let _ = svc + .both_rules(Some(SvcError::Backend("io".into()))) + .await + .unwrap_err(); + assert_eq!(spy.rollbacks.load(SeqCst), 1); + + // NotFound is in neither set -> not in rollback_only_for -> commits. + let _ = svc.both_rules(Some(SvcError::NotFound)).await.unwrap_err(); + assert_eq!( + spy.commits.load(SeqCst), + 2, + "error outside both sets commits" + ); + assert_eq!(spy.rollbacks.load(SeqCst), 1); +} + +#[tokio::test] +async fn rollback_rules_apply_on_the_process_global_path() { + // Register a process-global spy manager (first-wins). This is the only test + // that touches the global registry; the explicit-manager methods above route + // through `transactional_on` and never consult it, so there is no contention. + let spy = Arc::new(DecisionSpy::default()); + let registered = firefly::transactional::register_transaction_manager(Arc::new(SpyManager { + spy: Arc::clone(&spy), + })); + assert!( + registered, + "no global manager was registered before this test" + ); + + // Backend matches rollback_only_for -> rolls back, on the global path. + let _ = global_path(Some(SvcError::Backend("io".into()))) + .await + .unwrap_err(); + assert_eq!(spy.rollbacks.load(SeqCst), 1); + + // Validation is not matched -> commits, proving the predicate rides the + // process-global `transactional_with` entry point as well. + let _ = global_path(Some(SvcError::Validation("bad".into()))) + .await + .unwrap_err(); + assert_eq!(spy.commits.load(SeqCst), 1); +} diff --git a/docs/book/src/07-persistence.md b/docs/book/src/07-persistence.md index 46a28af5..dad2a2df 100644 --- a/docs/book/src/07-persistence.md +++ b/docs/book/src/07-persistence.md @@ -829,8 +829,40 @@ async fn record(accounts: &Accounts, ledger: &Ledger) -> Result<(), TransferErro The attributes are `propagation` (`required` / `requires_new` / `nested` / `supports` / `not_supported` / `mandatory` / `never`), `isolation` -(`read_committed` / `repeatable_read` / `serializable` / …), `read_only`, and -`timeout_ms`. +(`read_committed` / `repeatable_read` / `serializable` / …), `read_only`, +`timeout_ms`, `manager = ""` (Spring's `@Transactional("txManager")` — run +against an explicit `TransactionManager`, e.g. `self.tx_manager()`, instead of +the process-global registry), and the **rollback rules** `no_rollback_for` / +`rollback_only_for`. + +Spring names exception *types*; because Rust's `Result` already separates +failure from success, the Firefly analog names an error **pattern** (any match +pattern for the fn's error type, no `if` guard, alternatives `A | B` included). +By default every `Err` rolls back. Then: + +- `no_rollback_for = "P"` — **Spring's `noRollbackFor`**: an `Err` matching `P` + **commits** instead of rolling back; +- `rollback_only_for = "P"` — roll back **only** for errors matching `P`, + committing the rest; +- with both, `no_rollback_for` wins on overlap. + +```rust,ignore +// Persist the audit row even when the domain rejects the charge, but still roll +// back on any infrastructure failure — @Transactional(noRollbackFor = …). +#[firefly::transactional(no_rollback_for = "BillingError::Rejected(_)")] +async fn charge(&self, req: Charge) -> Result { + self.audit.save(/* … */).await?; // committed even on a Rejected error + self.gateway.settle(req).await // a Backend error still rolls back +} +``` + +> **Not `rollback_for`.** Spring's `rollbackFor` is *additive* — it adds +> exception types to the runtime-exceptions that already roll back. Rust has no +> checked/unchecked split (every `Err` rolls back by default), so an additive +> rule would be a no-op. `rollback_only_for` is therefore deliberately named to +> signal that it *restricts* (rather than widens) the rollback set, so a Spring +> port is never silently inverted. Writing `rollback_for` is a friendly compile +> error pointing you at the two rules above. **Ambient enlistment** is what makes this seamless. The manager opens a sqlx transaction and stows it in a task-local stack; while that scope is active,