diff --git a/.DS_Store b/.DS_Store index 1fff729f1..db6ff3570 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.cursor/prompts/docs.md b/.cursor/prompts/docs.md index b2d704f5a..544c9a025 100644 --- a/.cursor/prompts/docs.md +++ b/.cursor/prompts/docs.md @@ -1,4 +1,6 @@ -Update the file in context using the following guidelines: +You are a senior Ruby developer with expert knowledge of CMDx and writing documentation. + +Update the active tab using the following guidelines: - Follow best practices and implementation - Use a consistent professional voice @@ -7,3 +9,4 @@ Update the file in context using the following guidelines: - Examples should not cross boundaries or focus - Docs must cover both typical use cases, including invalid inputs and error conditions - Use GitHub flavored markdown, including alerts to emphasize critical information (https://github.com/orgs/community/discussions/16925) +- Optimize for LLM's including coding and AI agents diff --git a/.cursor/prompts/rspec.md b/.cursor/prompts/rspec.md index 1039cccae..b8733c5e0 100644 --- a/.cursor/prompts/rspec.md +++ b/.cursor/prompts/rspec.md @@ -1,4 +1,6 @@ -Add tests for the file in context using the following guidelines: +You are a senior Ruby developer with expert knowledge of RSpec. + +Add tests for the active tab using the following guidelines: - Expectations should be concise, non-repetitive, and realistic (how it would be used in the real world) - Follow best practices and implementation @@ -18,4 +20,5 @@ Add tests for the file in context using the following guidelines: - Keep test code concise; avoid unnecessary complexity or duplication - Tests must cover both typical cases and edge cases, including invalid inputs and error conditions - Consider all possible scenarios for each method or behavior and ensure they are tested +- Do NOT include integration or real world examples - Verify all specs are passing diff --git a/.cursor/prompts/yardoc.md b/.cursor/prompts/yardoc.md index 7815b3993..c42d7517a 100644 --- a/.cursor/prompts/yardoc.md +++ b/.cursor/prompts/yardoc.md @@ -1,10 +1,11 @@ -Add yardoc to the file in context using the following guidelines: +You are a senior Ruby developer with expert knowledge of YARDoc. + +Add yardoc to the active tab using the following guidelines: - Follow best practices and implementation - New documentation should be consistent with current `lib/cmdx` documentation - Examples should be concise, non-repetitive, and realistic - Avoid unnecessary complexity or duplication -- Consider all possible scenarios for each method or behavior and ensure they are tested. - Update any pre-existing documentation to match stated rules - Do NOT include `CMDx` module level docs - Module level docs description should NOT include `@example` diff --git a/.cursor/rules/cursor-instructions.mdc b/.cursor/rules/cursor-instructions.mdc index 7bb391632..751b89683 100644 --- a/.cursor/rules/cursor-instructions.mdc +++ b/.cursor/rules/cursor-instructions.mdc @@ -3,4 +3,59 @@ description: globs: alwaysApply: true --- -Use instructions found in [copilot-instructions.md](mdc:.github/copilot-instructions.md) + +# Ruby Coding Standards + +Follow the official Ruby gem guides for best practices. +Reference the guides outlined in https://guides.rubygems.org + +## Project Context +CMDx provides a framework for designing and executing complex +business logic within service/command objects. + +## Technology Stack +- Ruby 3.4+ +- RSpec 3.1+ + +## Code Style and Structure +- Write concise, idiomatic Ruby code with accurate examples +- Follow Ruby conventions and best practices +- Use object-oriented and functional programming patterns as appropriate +- Prefer iteration and modularization over code duplication +- Use descriptive variable and method names (e.g., user_signed_in?, calculate_total) +- Write comprehensive code documentation using the Yardoc format + +## Naming Conventions +- Use snake_case for file names, method names, and variables +- Use CamelCase for class and module names + +## Syntax and Formatting +- Follow the Ruby Style Guide (https://rubystyle.guide/) +- Follow Ruby style conventions (2-space indentation, snake_case methods) +- Use Ruby's expressive syntax (e.g., unless, ||=, &.) +- Prefer double quotes for strings +- Respect my Rubocop options + +## Performance Optimization +- Use memoization for expensive operations + +## Testing +- Follow the RSpec Style Guide (https://rspec.rubystyle.guide/) +- Write comprehensive tests using RSpec +- It's ok to put multiple assertions in the same example +- Include both BDD and TDD based tests +- Create test objects to share across tests +- Do NOT make tests for obvious or reflective expectations +- Prefer real objects over mocks. Use `instance_double` if necessary; never `double` +- Don't test declarative configuration +- Use appropriate matchers +- Update tests and update Yardocs after you write code + +## Documentation +- Utilize the YARDoc format when documenting Ruby code +- Follow these best practices: + - Avoid redundant comments that merely restate the code + - Keep comments up-to-date with code changes + - Keep documentation consistent +- Update CHANGELOG.md with any changes + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dfcb08df1..a7d86b1fb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,10 +10,10 @@ jobs: strategy: fail-fast: false matrix: - ruby-version: ['3.4'] + ruby-version: ['3.2', '3.3', '3.4'] steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up Ruby ${{ matrix.ruby-version }} uses: ruby/setup-ruby@v1 with: diff --git a/.irbrc b/.irbrc index 222309101..740e244b3 100644 --- a/.irbrc +++ b/.irbrc @@ -1,3 +1,6 @@ # frozen_string_literal: true -require "cmdx" +require "pp" + +# To reload the gem, you must exit and restart the IRB session +require_relative "lib/cmdx" unless defined?(CMDx) diff --git a/.rubocop.yml b/.rubocop.yml index a63c160e8..f0366d1b3 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -6,6 +6,7 @@ AllCops: NewCops: enable DisplayCopNames: true DisplayStyleGuide: true + TargetRubyVersion: 3.1 Gemspec/DevelopmentDependencies: EnforcedStyle: gemspec Layout/EmptyLinesAroundAttributeAccessor: @@ -14,22 +15,25 @@ Layout/EmptyLinesAroundClassBody: EnforcedStyle: empty_lines_except_namespace Layout/EmptyLinesAroundModuleBody: EnforcedStyle: empty_lines_except_namespace -Layout/ExtraSpacing: - Exclude: - - 'lib/generators/cmdx/templates/install.rb' +Layout/FirstHashElementIndentation: + EnforcedStyle: consistent Layout/LineLength: Enabled: false Lint/MissingSuper: Exclude: - - 'lib/cmdx/middlewares/**/*' - 'spec/**/**/*' +Lint/ShadowedException: + Enabled: false +Lint/UnusedMethodArgument: + Exclude: + - 'lib/cmdx/coercions/**/*' + - 'lib/cmdx/log_formatters/**/*' + - 'lib/cmdx/middlewares/**/*' + - 'lib/cmdx/validators/**/*' Metrics/AbcSize: Enabled: false Metrics/BlockLength: - Exclude: - - 'lib/cmdx/rspec/**/*' - - 'spec/**/**/*' - - '*.gemspec' + Enabled: false Metrics/ClassLength: Enabled: false Metrics/CyclomaticComplexity: @@ -42,43 +46,50 @@ Metrics/PerceivedComplexity: Enabled: false Naming/MethodParameterName: Enabled: false -RSpec/AnyInstance: - Enabled: false RSpec/DescribeClass: Exclude: - 'spec/integrations/**/*' RSpec/ExampleLength: Enabled: false +RSpec/IndexedLet: + Enabled: false RSpec/MessageSpies: + EnforcedStyle: receive +RSpec/MultipleExpectations: Enabled: false RSpec/MultipleMemoizedHelpers: Enabled: false -RSpec/MultipleExpectations: - Enabled: false RSpec/NestedGroups: Enabled: false RSpec/SpecFilePathFormat: CustomTransform: CMDx: cmdx -RSpec/StubbedMock: - Enabled: false RSpec/SubjectStub: Enabled: false -RSpec/VerifiedDoubles: +RSpec/StubbedMock: Enabled: false RSpec/VerifiedDoubleReference: Enabled: false +Style/ArgumentsForwarding: + Exclude: + - 'lib/cmdx/utils/call.rb' +Style/CaseEquality: + Enabled: false +Style/DocumentDynamicEvalDefinition: + Enabled: false Style/Documentation: Enabled: false -Style/FormatStringToken: +Style/DoubleNegation: Enabled: false Style/FrozenStringLiteralComment: Enabled: true EnforcedStyle: always_true SafeAutoCorrect: true -Style/OpenStructUse: - Enabled: false +Style/ModuleFunction: + EnforcedStyle: extend_self Style/OptionalBooleanParameter: Enabled: false +Style/StringConcatenation: + Enabled: false Style/StringLiterals: EnforcedStyle: double_quotes diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e065804c..a6b913ce8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,143 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [TODO] -- Rebuild parameters to use a less magical approach -- Revert deprecator to use old options -- Validators to add errors directly instead of raising errors -- Coercions to add errors directly instead of raising errors -- Update procs to call with object as first item -- Move I18n to own ruby gem +### Required +- Create `cmdx-i18n` gem with all locales +- Create `cmdx-rspec` gem with RSpec matchers +- Create `cmdx-minitest` gem with Minitest matchers -## [Unreleased] +### Optional +- Create `cmdx-jobs` gem with background job integration +- Create `cmdx-parallel` gem with parallel workflow task execution -## [1.1.2] - 2025-07-20 - -### Added -- Add `UnknownDeprecationError` for unknown deprecation type - -## [1.1.1] - 2025-07-20 - -### Changed -- Updated all docs and specs -- Update deprecation key words - -## [1.1.0] - 2025-07-17 - -### Added -- Added `CoercionRegistry` class for managing parameter coercions with support for custom type registration -- Added `ValidatorRegistry` class for managing parameter validators with support for custom validator registration -- Added `CallbackRegistry` class to take uninstantiated callback classes -- Added `Validator` and `Coercion` classes to build their respective handlers -- Added deprecation setting - -### Changed -- Moved `Task::CALLBACKS` constant to `CallbackRegistry::TYPES` -- Updated `ParameterRegistry` class to not inherit from `Hash` -- Updated `MiddlewareRegistry` class to not inherit from `Hash` -- Updated `CallbackRegistry` class to not inherit from `Hash` - -### Removed -- Removed task `register` class method -- Removed `Custom` validator since users can create and register a custom one - -## [1.0.1] - 2025-07-07 - -### Added -- Added comprehensive internationalization support with 24 language locales - - Arabic, Chinese, Czech, Danish, Dutch, English, Finnish, French, German, Greek - - Hebrew, Hindi, Italian, Japanese, Korean, Norwegian, Polish, Portuguese - - Russian, Spanish, Swedish, Thai, Turkish, Vietnamese -- Added TLDR sections to documentation for improved accessibility - -### Changed -- Improved configuration template with better defaults and examples - -## [1.0.0] - 2025-07-03 - -### Added -- Added `Hook` class for flexible callback management -- Added `perform!` and `perform` method aliases for class-level `call!` and `call` methods -- Added comprehensive YARDoc documentation throughout codebase -- Added configuration-based hook registration system -- Added Cursor and GitHub Copilot configuration files for enhanced IDE support -- Added middleware support for tasks enabling extensible request/response processing -- Added pattern matching support for result objects -- Added support for direct instantiation of Task and Workflow objects -- Added Zeitwerk-based gem loading for improved performance and reliability - -### Changed -- Changed `ArgumentError` to `TypeError` for type validation consistency -- Changed configuration from hash-based to PORO (Plain Old Ruby Object) class structure -- Improved documentation readability, consistency, and completeness -- Improved test suite readability, consistency, and coverage -- Renamed `Batch` to `Workflow` to better reflect functionality -- Renamed `Hook` to `Callback` for naming consistency -- Renamed `Parameters` to `ParameterRegistry` for clarity -- Renamed `Run` and associated components to `Chain` for better semantic meaning -- Updated `Chain` to use thread-based execution instead of context passing -- Updated `Immutator` to use `SKIP_CMDX_FREEZING` environment variable instead of `RACK_ENV`/`RAILS_ENV` -- Updated hooks from a hash structure to registry pattern +## [1.5.0] - 2025-08-20 -### Removed -- Removed deprecated `task_timeout` and `batch_timeout` configuration settings +### Changes +- BREAKING Rebuild CMDx to be simpler, less magical, and more performant -## [0.5.0] - 2025-03-21 - -### Added -- Added `on_[state]` and `on_[status]` based result callback handlers -- Added `on_executed` state hook for task completion tracking -- Added `on_good` and `on_bad` status hooks for success/failure handling -- Added `state`, `status`, `outcome`, and `runtime` fields to run serializer -- Added `to_a` alias for array of hashes serializers - -### Changed -- Reordered status and state hook execution for more predictable behavior - -## [0.4.0] - 2025-03-17 - -### Added -- Added ANSI color utility for enhanced terminal output -- Added JSON string parsing support in array coercion -- Added JSON string parsing support in hash coercion - -### Changed -- Improved ANSI escape sequence handling -- Improved run inspector output formatting - -### Fixed -- Fixed log settings assignment when logger is nil to prevent errors - -## [0.3.0] - 2025-03-14 - -### Added -- Added `LoggerSerializer` for standardized log output formatting -- Added `progname` support for logger instances - -### Changed -- Removed `pid` (process ID) from result serializer output -- Reverted default log formatter from `PrettyLine` back to `Line` - -### Fixed -- Fixed `call!` method not properly marking failure state as interrupted -- Fixed serialization issues with frozen run objects - -## [0.2.0] - 2025-03-12 - -### Added -- Added `PrettyJson` log formatter for structured JSON output -- Added `PrettyKeyValue` log formatter for key-value pair output -- Added `PrettyLine` log formatter for enhanced line-based output +## [1.1.2] - 2025-07-20 ### Changed -- Renamed `DatetimeFormatter` utility to `LogTimestamp` for better clarity -- Renamed `MethodName` utility to `NameAffix` for better clarity -- Renamed `Runtime` utility to `MonotonicRuntime` for better clarity -- Updated `PrettyLine` to be the default log formatter -- Updated result logger to be wrapped in `Logger#with_logger` block for better context - -### Fixed -- Fixed error when logging non-hash values -- Fixed fault bubbling behavior with nested halted calls +- All items between versions `0.1.0` and `1.1.2` should be reviewed within its own tag ## [0.1.0] - 2025-03-07 diff --git a/Gemfile.lock b/Gemfile.lock index dce641001..2b320fda7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,9 +1,8 @@ PATH remote: . specs: - cmdx (1.1.2) + cmdx (1.5.0) bigdecimal - i18n logger zeitwerk @@ -16,20 +15,19 @@ GEM diff-lcs (1.6.2) i18n (1.14.7) concurrent-ruby (~> 1.0) - json (2.13.0) + json (2.13.2) language_server-protocol (3.17.0.5) lint_roller (1.1.0) logger (1.7.0) - ostruct (0.6.3) parallel (1.27.0) - parser (3.3.8.0) + parser (3.3.9.0) ast (~> 2.4.1) racc prism (1.4.0) racc (1.8.1) rainbow (3.1.1) rake (13.3.0) - regexp_parser (2.10.0) + regexp_parser (2.11.2) rspec (3.13.1) rspec-core (~> 3.13.0) rspec-expectations (~> 3.13.0) @@ -42,8 +40,8 @@ GEM rspec-mocks (3.13.5) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-support (3.13.4) - rubocop (1.78.0) + rspec-support (3.13.5) + rubocop (1.79.2) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) @@ -51,7 +49,7 @@ GEM parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 2.9.3, < 3.0) - rubocop-ast (>= 1.45.1, < 2.0) + rubocop-ast (>= 1.46.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 4.0) rubocop-ast (1.46.0) @@ -68,18 +66,19 @@ GEM lint_roller (~> 1.1) rubocop (~> 1.72, >= 1.72.1) ruby-progressbar (1.13.0) - unicode-display_width (3.1.4) + unicode-display_width (3.1.5) unicode-emoji (~> 4.0, >= 4.0.4) unicode-emoji (4.0.4) zeitwerk (2.7.3) PLATFORMS + arm64-darwin-24 ruby DEPENDENCIES bundler cmdx! - ostruct + i18n rake rspec rubocop @@ -88,4 +87,4 @@ DEPENDENCIES rubocop-rspec BUNDLED WITH - 2.6.9 + 2.7.1 diff --git a/README.md b/README.md index fae597121..5f6b5a09a 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,14 @@ # CMDx [![forthebadge](http://forthebadge.com/images/badges/made-with-ruby.svg)](http://forthebadge.com) - [![Gem Version](https://badge.fury.io/rb/cmdx.svg?icon=si%3Arubygems)](https://badge.fury.io/rb/cmdx) [![CI](https://github.com/drexed/cmdx/actions/workflows/ci.yml/badge.svg)](https://github.com/drexed/cmdx/actions/workflows/ci.yml) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=shields)](http://makeapullrequest.com) -`CMDx` is a Ruby framework for building maintainable, observable business logic through composable command objects. Design robust workflows with automatic parameter validation, structured error handling, comprehensive logging, and intelligent execution flow control that scales from simple tasks to complex multi-step processes. +`CMDx` is a Ruby framework for building maintainable, observable business logic through composable command objects. Design robust workflows with automatic attribute validation, structured error handling, comprehensive logging, and intelligent execution flow control that scales from simple tasks to complex multi-step processes. ## Installation -**Prerequisites:** This gem requires Ruby `>= 3.1` to be installed. - Add this line to your application's Gemfile: ```ruby @@ -30,19 +27,19 @@ Or install it yourself as: ```ruby # Setup task -class SendWelcomeEmailTask < CMDx::Task - use :middleware, CMDx::Middlewares::Timeout, seconds: 5 +class SendWelcomeEmail < CMDx::Task + register :middleware, CMDx::Middlewares::Correlate, id: -> { request.request_id } on_success :track_email_delivery! required :user_id, type: :integer, numeric: { min: 1 } - optional :template, type: :string, default: "welcome" + optional :template, default: "customer" - def call + def work if user.nil? - fail!(reason: "User not found", code: 404) + fail!("User not found", code: 404) elsif user.unconfirmed? - skip!(reason: "Email not verified") + skip!("Email not verified") else response = UserMailer.welcome(user, template).deliver_now context.message_id = response.message_id @@ -56,55 +53,52 @@ class SendWelcomeEmailTask < CMDx::Task end def track_email_delivery! - user.touch(:welcomed_at) + user.touch(:welcome_email_sent_at) end end # Execute task -result = SendWelcomeEmailTask.call(user_id: 123, template: "premium_welcome") +result = SendWelcomeEmail.execute(user_id: 123, template: "admin") # Handle result if result.success? puts "Welcome email sent " elsif result.skipped? - puts "Skipped: #{result.metadata[:reason]}" + puts "Skipped: #{result.reason}" elsif result.failed? - puts "Failed: #{result.metadata[:reason]} with code: #{result.metadata[:code]}" + puts "Failed: #{result.reason} with code: #{result.metadata[:code]}" end ``` ## Table of contents - [Getting Started](docs/getting_started.md) -- [Configuration](docs/configuration.md) - Basics - [Setup](docs/basics/setup.md) - - [Call](docs/basics/call.md) + - [Execution](docs/basics/execution.md) - [Context](docs/basics/context.md) - [Chain](docs/basics/chain.md) - Interruptions - [Halt](docs/interruptions/halt.md) - [Faults](docs/interruptions/faults.md) - [Exceptions](docs/interruptions/exceptions.md) -- Parameters - - [Definitions](docs/parameters/definitions.md) - - [Namespacing](docs/parameters/namespacing.md) - - [Coercions](docs/parameters/coercions.md) - - [Validations](docs/parameters/validations.md) - - [Defaults](docs/parameters/defaults.md) - Outcomes - - [Result](#results) + - [Result](docs/outcomes/result.md) - [States](docs/outcomes/states.md) - [Statuses](docs/outcomes/statuses.md) +- Attributes + - [Definitions](docs/attributes/definitions.md) + - [Naming](docs/attributes/naming.md) + - [Coercions](docs/attributes/coercions.md) + - [Validations](docs/attributes/validations.md) + - [Defaults](docs/attributes/defaults.md) - [Callbacks](docs/callbacks.md) - [Middlewares](docs/middlewares.md) -- [Workflows](docs/workflows.md) - [Logging](docs/logging.md) - [Internationalization (i18n)](docs/internationalization.md) -- [Testing](docs/testing.md) - [Deprecation](docs/deprecation.md) -- [AI Prompts](docs/ai_prompts.md) -- [Tips & Tricks](docs/tips_and_tricks.md) +- [Workflows](docs/workflows.md) +- [Tips and Tricks](docs/tips_and_tricks.md) ## Development diff --git a/cmdx.gemspec b/cmdx.gemspec index c7d64740b..c3b1b8dfe 100644 --- a/cmdx.gemspec +++ b/cmdx.gemspec @@ -35,12 +35,11 @@ Gem::Specification.new do |spec| spec.require_paths = ["lib"] spec.add_dependency "bigdecimal" - spec.add_dependency "i18n" spec.add_dependency "logger" spec.add_dependency "zeitwerk" spec.add_development_dependency "bundler" - spec.add_development_dependency "ostruct" + spec.add_development_dependency "i18n" spec.add_development_dependency "rake" spec.add_development_dependency "rspec" spec.add_development_dependency "rubocop" diff --git a/docs/ai_prompts.md b/docs/ai_prompts.md deleted file mode 100644 index 6c18dc008..000000000 --- a/docs/ai_prompts.md +++ /dev/null @@ -1,393 +0,0 @@ -# AI Prompt Templates - -AI prompt templates provide structured guidance for generating production-ready CMDx Task and Workflow objects. These templates ensure consistent code quality, proper framework usage, and comprehensive testing coverage when working with AI assistants. - -## Table of Contents - -- [TLDR](#tldr) -- [Framework Context Template](#framework-context-template) -- [Task Generation Templates](#task-generation-templates) -- [Workflow Generation Templates](#workflow-generation-templates) -- [Testing Templates](#testing-templates) -- [Error Handling and Edge Cases](#error-handling-and-edge-cases) -- [Best Practices](#best-practices) - -## TLDR - -> [!NOTE] -> Pre-written prompts help AI assistants generate well-structured CMDx code with proper validations, error handling, and comprehensive tests. - -```ruby -# Task generation pattern -"Create a CMDx task that [ACTION] with parameters [PARAMS] and validation [RULES]" - -# Workflow orchestration pattern -"Create a CMDx workflow that orchestrates [PROCESS] with steps [TASKS] and error handling [STRATEGY]" - -# Testing pattern -"Generate RSpec tests with CMDx matchers for success, failure, and edge cases" -``` - -## Framework Context Template - -> [!IMPORTANT] -> Always include this context when working with AI assistants to ensure proper CMDx code generation and adherence to framework conventions. - -``` -I'm working with CMDx, a Ruby framework for designing and executing business logic within service/command objects. - -CORE CONCEPTS: -- Tasks inherit from CMDx::Task with business logic in `call` method -- Workflows inherit from CMDx::Workflow to orchestrate multiple tasks -- Parameters support type coercion, validation, defaults, and nesting -- Results contain status (success/failed/skipped), state, context, metadata -- Callbacks execute at lifecycle points (before_validation, on_success, etc.) -- Middlewares wrap execution (authentication, logging, timeouts, correlation) -- Chains link tasks with shared context and failure propagation - -CODING STANDARDS: -- Ruby 3.4+ syntax and conventions -- snake_case methods/variables, CamelCase classes -- Double quotes for strings, proper indentation -- YARD documentation with @param, @return, @example -- RSpec tests using CMDx custom matchers -- Task naming: VerbNounTask (ProcessOrderTask) -- Workflow naming: NounVerbWorkflow (OrderProcessingWorkflow) - -REQUIREMENTS: -- Production-ready code with comprehensive error handling -- Parameter validation with meaningful error messages -- Proper context management and metadata usage -- Full test coverage including edge cases and failure scenarios -``` - -## Task Generation Templates - -### Standard Task Template - -``` -Create a CMDx task that [SPECIFIC_ACTION] with these requirements: - -PARAMETERS: -- [name]: [type] - [description] - [required/optional] - [validation_rules] -- [name]: [type] - [description] - [default_value] - [constraints] - -BUSINESS LOGIC: -1. [Validation step with error conditions] -2. [Core processing with success criteria] -3. [Side effects and external calls] -4. [Context updates and metadata] - -ERROR HANDLING: -- [Specific error condition] → [failure response with metadata] -- [Edge case] → [appropriate handling strategy] - -CONTEXT UPDATES: -- [key]: [description of data added] -- [key]: [metadata or tracking information] - -OUTPUT: -- Complete task implementation with YARD docs -- RSpec test file with success/failure/edge cases -- Parameter validation tests -- Integration tests for external dependencies -``` - -### Practical Example - -``` -Create a CMDx task that processes user profile updates with these requirements: - -PARAMETERS: -- user_id: integer - User identifier - required - positive, exists in database -- profile_data: hash - Profile information - required - non-empty hash -- send_notification: boolean - Email update notification - optional - defaults to true -- audit_reason: string - Reason for update - optional - 3-255 characters when provided - -BUSINESS LOGIC: -1. Validate user exists and is active (error if not found or inactive) -2. Sanitize and validate profile data fields (reject invalid formats) -3. Update user profile in database (handle transaction failures) -4. Send notification email if enabled (log failures, don't fail task) -5. Create audit log entry with before/after values - -ERROR HANDLING: -- User not found → failed with metadata {error_code: 'USER_NOT_FOUND'} -- Invalid profile data → failed with metadata {invalid_fields: [...]} -- Database failure → failed with metadata {error_code: 'DB_ERROR', retryable: true} - -CONTEXT UPDATES: -- updated_user: User object with new profile data -- profile_changes: Hash with {field: [old_value, new_value]} -- notification_sent: Boolean indicating email delivery status -``` - -## Workflow Generation Templates - -### Standard Workflow Template - -``` -Create a CMDx workflow that orchestrates [BUSINESS_PROCESS] with these requirements: - -WORKFLOW STEPS: -1. [TaskName]: [Purpose and responsibilities] -2. [TaskName]: [Dependencies and data requirements] -3. [TaskName]: [Conditional execution criteria] - -DATA FLOW: -- [Context key]: Flows from [Task A] to [Task B] for [purpose] -- [Shared state]: Available to [tasks] for [coordination] - -ERROR STRATEGY: -- [Task failure] → [recovery action or compensation] -- [Critical failure] → [rollback requirements] -- [Partial failure] → [continuation strategy] - -CONDITIONAL LOGIC: -- Skip [task] when [condition] is [value] -- Execute [alternative_task] if [criteria] met -- Branch execution based on [context_data] - -OUTPUT: -- Complete workflow with task orchestration -- Individual task implementations -- Integration tests covering success/failure paths -- Error handling and rollback mechanisms -``` - -### Practical Example - -``` -Create a CMDx workflow that orchestrates user account deactivation with these requirements: - -WORKFLOW STEPS: -1. ValidateDeactivationRequestTask: Verify user permissions and business rules -2. BackupUserDataTask: Archive user data before deactivation -3. DeactivateAccountTask: Update account status and revoke access -4. NotifyStakeholdersTask: Send notifications to relevant parties -5. UpdateAnalyticsTask: Record deactivation metrics - -DATA FLOW: -- user_id: Required input, flows through all tasks -- deactivation_reason: Used by validation, backup, and analytics -- backup_reference: Created by backup, used by analytics -- stakeholder_list: Determined by validation, used by notification - -ERROR STRATEGY: -- Validation failure → halt workflow, return validation errors -- Backup failure → critical error, do not proceed with deactivation -- Account deactivation failure → rollback backup, restore previous state -- Notification failure → log error, continue workflow (non-critical) -- Analytics failure → log error, workflow succeeds (tracking only) - -CONDITIONAL LOGIC: -- Skip stakeholder notification if user is internal test account -- Execute priority backup for premium users -- Send different notifications based on deactivation reason -``` - -## Testing Templates - -### Task Testing Template - -> [!TIP] -> Use CMDx custom matchers for cleaner, more expressive tests that follow framework conventions. - -``` -Generate comprehensive RSpec tests for [TASK_NAME] including: - -PARAMETER VALIDATION: -- Required parameters missing → proper error messages -- Type coercion edge cases → successful conversion or clear failures -- Validation rules → boundary conditions and invalid inputs -- Default values → proper application and override behavior - -EXECUTION SCENARIOS: -- Happy path → successful execution with expected context updates -- Business rule violations → appropriate failure states with metadata -- External service failures → error handling and retry logic -- Edge cases → boundary conditions and unusual inputs - -INTEGRATION POINTS: -- Database operations → transaction handling and rollback -- External APIs → network failures and response validation -- File system → permissions and storage errors -- Email/messaging → delivery failures and formatting - -Use CMDx matchers: -- expect(result).to be_successful_task -- expect(result).to be_failed_task.with_metadata(hash_including(...)) -- expect(result).to have_context(key: value) -- expect(TaskClass).to have_parameter(:name).with_type(:integer) -``` - -### Workflow Testing Template - -``` -Generate comprehensive RSpec tests for [WORKFLOW_NAME] including: - -INTEGRATION SCENARIOS: -- Complete success path → all tasks execute with proper data flow -- Early failure → workflow halts at appropriate point -- Mid-workflow failure → proper error propagation and cleanup -- Recovery scenarios → compensation and rollback behavior - -TASK COORDINATION: -- Context passing → data flows correctly between tasks -- Conditional execution → tasks skip/execute based on conditions -- Parallel execution → concurrent tasks complete properly -- Sequential dependencies → tasks wait for predecessors - -ERROR PROPAGATION: -- Individual task failures → workflow response and metadata -- Critical vs non-critical failures → appropriate handling -- Rollback mechanisms → state restoration and cleanup -- Error aggregation → multiple failure consolidation - -EDGE CASES: -- Empty context → proper initialization and defaults -- Malformed inputs → validation and sanitization -- Resource constraints → timeout and resource management -- Concurrent execution → race conditions and locking -``` - -## Error Handling and Edge Cases - -> [!WARNING] -> Always include comprehensive error handling in your prompts to ensure robust, production-ready code generation. - -### Common Error Scenarios - -```ruby -# Parameter validation failures -expect(result).to be_failed_task - .with_metadata( - reason: "user_id is required", - messages: { user_id: ["can't be blank"] } - ) - -# Business rule violations -expect(result).to be_failed_task - .with_metadata( - error_code: "INSUFFICIENT_BALANCE", - retryable: false, - balance: current_balance, - required: requested_amount - ) - -# External service failures -expect(result).to be_failed_task - .with_metadata( - error_code: "SERVICE_UNAVAILABLE", - retryable: true, - retry_after: 30, - service: "payment_processor" - ) -``` - -### Edge Case Coverage - -Include these scenarios in your prompts: - -| Scenario | Test Coverage | Expected Behavior | -|----------|---------------|-------------------| -| Empty inputs | Nil, empty strings, empty arrays | Validation errors or defaults | -| Boundary values | Min/max limits, zero, negative | Proper validation and coercion | -| Malformed data | Invalid JSON, corrupt files | Clear error messages | -| Resource limits | Memory, timeout, rate limits | Graceful degradation | -| Concurrent access | Race conditions, locks | Proper synchronization | - -## Best Practices - -### 1. Specific Requirements - -> [!NOTE] -> Provide detailed, actionable requirements rather than vague descriptions to get better code generation. - -**Effective:** -``` -Create a task that validates payment information including: -- Credit card number validation using Luhn algorithm -- Expiry date validation (not expired, within 10 years) -- CVV validation (3 digits for Visa/MC, 4 for Amex) -- Amount validation (positive, max $10,000, 2 decimal places) -- Return structured validation errors with field-specific messages -``` - -**Ineffective:** -``` -Create a payment validation task -``` - -### 2. Complete Context Flow - -**Effective:** -``` -Task receives user_id and order_data, validates inventory, processes payment, -updates order status, and adds to context: -- order: Order object with updated status -- payment_reference: Payment processor transaction ID -- inventory_reserved: Array of reserved item IDs -- processing_time: Duration in milliseconds -``` - -**Ineffective:** -``` -Process an order and update context -``` - -### 3. Explicit Error Conditions - -**Effective:** -``` -Handle these specific errors: -- Invalid card → failed with {error_code: 'INVALID_CARD', field: 'number'} -- Expired card → failed with {error_code: 'EXPIRED', retry_date: Date} -- Declined → failed with {error_code: 'DECLINED', retryable: false} -- Timeout → failed with {error_code: 'TIMEOUT', retryable: true, delay: 30} -``` - -**Ineffective:** -``` -Handle payment errors appropriately -``` - -### 4. Framework-Specific Patterns - -**Effective:** -``` -Follow CMDx conventions: -- Use present tense task names (ProcessPaymentTask, not PaymentProcessor) -- Include detailed metadata for failures -- Use callbacks for cross-cutting concerns (audit, logging) -- Leverage parameter coercion for input flexibility -- Return rich context updates for downstream tasks -``` - -**Ineffective:** -``` -Use good Ruby practices -``` - -### 5. Comprehensive Test Coverage - -**Effective:** -``` -Generate tests including: -- All parameter combinations and edge cases -- Success scenarios with various input types -- Each failure mode with proper error metadata -- Integration with external services (mocked) -- Performance characteristics and timeouts -- Callback execution and order -``` - -**Ineffective:** -``` -Include basic tests -``` - ---- - -- **Prev:** [Deprecation](deprecation.md) -- **Next:** [Tips and Tricks](tips_and_tricks.md) diff --git a/docs/attributes/coercions.md b/docs/attributes/coercions.md new file mode 100644 index 000000000..0709da9e2 --- /dev/null +++ b/docs/attributes/coercions.md @@ -0,0 +1,162 @@ +# Attributes - Coercions + +Attribute coercions automatically convert task arguments to expected types, ensuring type safety while providing flexible input handling. Coercions transform raw input values into the specified types, supporting simple conversions like string-to-integer and complex operations like JSON parsing. + +## Table of Contents + +- [Usage](#usage) +- [Built-in Coercions](#built-in-coercions) +- [Declarations](#declarations) + - [Proc or Lambda](#proc-or-lambda) + - [Class or Module](#class-or-module) +- [Removals](#removals) +- [Error Handling](#error-handling) + +## Usage + +Define attribute types to enable automatic coercion: + +```ruby +class ProcessPayment < CMDx::Task + # Coerce into a date + attribute :paid_with, type: :symbol + + # Coerce into a float fallback to big decimal + attribute :total, type: [:float, :big_decimal] + + # Coerce with options + attribute :paid_on, type: :date, strptime: "%m-%d-%Y" + + def work + paid_with #=> :amex + paid_on #=> + total #=> 34.99 (Float) + end +end + +ProcessPayment.execute( + paid_with: "amex", + paid_on: "01-23-2020", + total: "34.99" +) +``` + +> [!TIP] +> Specify multiple types for fallback coercion. CMDx attempts each type in order until one succeeds. + +## Built-in Coercions + +| Type | Options | Description | Examples | +|------|---------|-------------|----------| +| `:array` | | Array conversion with JSON support | `"val"` → `["val"]`
`"[1,2,3]"` → `[1, 2, 3]` | +| `:big_decimal` | `:precision` | High-precision decimal | `"123.456"` → `BigDecimal("123.456")` | +| `:boolean` | | Boolean with text patterns | `"yes"` → `true`, `"no"` → `false` | +| `:complex` | | Complex numbers | `"1+2i"` → `Complex(1, 2)` | +| `:date` | `:strptime` | Date objects | `"2024-01-23"` → `Date.new(2024, 1, 23)` | +| `:datetime` | `:strptime` | DateTime objects | `"2024-01-23 10:30"` → `DateTime.new(2024, 1, 23, 10, 30)` | +| `:float` | | Floating-point numbers | `"123.45"` → `123.45` | +| `:hash` | | Hash conversion with JSON support | `'{"a":1}'` → `{"a" => 1}` | +| `:integer` | | Integer with hex/octal support | `"0xFF"` → `255`, `"077"` → `63` | +| `:rational` | | Rational numbers | `"1/2"` → `Rational(1, 2)` | +| `:string` | | String conversion | `123` → `"123"` | +| `:symbol` | | Symbol conversion | `"abc"` → `:abc` | +| `:time` | `:strptime` | Time objects | `"10:30:00"` → `Time.new(2024, 1, 23, 10, 30)` | + +## Declarations + +> [!IMPORTANT] +> Coercions must raise a CMDx::CoercionError and its message is used as part of the fault reason and metadata. + +### Proc or Lambda + +Use anonymous functions for simple coercion logic: + +```ruby +class FindLocation < CMDx::Task + # Proc + register :callback, :point, proc do |value, options = {}| + begin + Point(value) + rescue StandardError + raise CMDx::CoercionError, "could not convert into a point" + end + end + + # Lambda + register :callback, :point, ->(value, options = {}) { + begin + Point(value) + rescue StandardError + raise CMDx::CoercionError, "could not convert into a point" + end + } +end +``` + +### Class or Module + +Register custom coercion logic for specialized type handling: + +```ruby +class PointCoercion + def self.call(value, options = {}) + Point(value) + rescue StandardError + raise CMDx::CoercionError, "could not convert into a point" + end +end + +class FindLocation < CMDx::Task + register :coercion, :point, PointCoercion + + attribute :longitude, type: :point +end +``` + +## Removals + +Remove custom coercions when no longer needed: + +```ruby +class ProcessOrder < CMDx::Task + deregister :coercion, :point +end +``` + +> [!IMPORTANT] +> Only one removal operation is allowed per `deregister` call. Multiple removals require separate calls. + +## Error Handling + +Coercion failures provide detailed error information including attribute paths, attempted types, and specific failure reasons: + +```ruby +class ProcessData < CMDx::Task + attribute :count, type: :integer + attribute :amount, type: [:float, :big_decimal] + + def work + # Your logic here... + end +end + +result = ProcessData.execute( + count: "not-a-number", + amount: "invalid-float" +) + +result.state #=> "interrupted" +result.status #=> "failed" +result.reason #=> "count could not coerce into an integer. amount could not coerce into one of: float, big_decimal." +result.metadata #=> { + # messages: { + # count: ["could not coerce into an integer"], + # amount: ["could not coerce into one of: float, big_decimal"] + # } + # } +``` + +--- + +- **Prev:** [Attributes - Naming](naming.md) +- **Next:** [Attributes - Validations](validations.md) diff --git a/docs/attributes/defaults.md b/docs/attributes/defaults.md new file mode 100644 index 000000000..cddefdfab --- /dev/null +++ b/docs/attributes/defaults.md @@ -0,0 +1,90 @@ +# Attributes - Defaults + +Attribute defaults provide fallback values when arguments are not provided or resolve to `nil`. Defaults ensure tasks have sensible values for optional attributes while maintaining flexibility for callers to override when needed. + +## Table of Contents + +- [Declarations](#declarations) + - [Static Values](#static-values) + - [Symbol References](#symbol-references) + - [Proc or Lambda](#proc-or-lambda) +- [Coercions and Validations](#coercions-and-validations) + +## Declarations + +Defaults apply when attributes are not provided or resolve to `nil`. They work seamlessly with coercion, validation, and nested attributes. + +### Static Values + +```ruby +class ProcessOrder < CMDx::Task + attribute :charge_type, default: :credit_card + attribute :priority, default: "standard" + attribute :send_email, default: true + attribute :max_retries, default: 3 + attribute :tags, default: [] + attribute :data, default: {} + + def work + charge_type #=> :credit_card + priority #=> "standard" + send_email #=> true + max_retries #=> 3 + tags #=> [] + data #=> {} + end +end +``` + +### Symbol References + +Reference instance methods by symbol for dynamic default values: + +```ruby +class ProcessOrder < CMDx::Task + attribute :priority, default: :default_priority + + def work + # Your logic here... + end + + private + + def default_priority + Current.account.pro? ? "priority" : "standard" + end +end +``` + +### Proc or Lambda + +Use anonymous functions for dynamic default values: + +```ruby +class ProcessOrder < CMDx::Task + # Proc + attribute :send_email, default: proc { Current.account.email_api_key? } + + # Lambda + attribute :priority, default: -> { Current.account.pro? ? "priority" : "standard" } +end +``` + +## Coercions and Validations + +Defaults are subject to the same coercion and validation rules as provided values, ensuring consistency and catching configuration errors early. + +```ruby +class ConfigureService < CMDx::Task + # Coercions + attribute :retry_count, default: "3", type: :integer + + # Validations + optional :priority, default: "medium", inclusion: { in: %w[low medium high urgent] } +end +``` + +--- + +- **Prev:** [Attributes - Validations](validations.md) +- **Next:** [Callbacks](../callbacks.md) diff --git a/docs/attributes/definitions.md b/docs/attributes/definitions.md new file mode 100644 index 000000000..1466a4faf --- /dev/null +++ b/docs/attributes/definitions.md @@ -0,0 +1,281 @@ +# Attributes - Definitions + +Attributes define the interface between task callers and implementation, enabling automatic validation, type coercion, and method generation. They provide a contract to verify that task execution arguments match expected requirements and structure. + +## Table of Contents + +- [Declarations](#declarations) + - [Optional](#optional) + - [Required](#required) +- [Sources](#sources) + - [Context](#context) + - [Symbol References](#symbol-references) + - [Proc or Lambda](#proc-or-lambda) + - [Class or Module](#class-or-module) +- [Nesting](#nesting) +- [Error Handling](#error-handling) + +## Declarations + +> [!TIP] +> Prefer using the `required` and `optional` alias for `attributes` for brevity and to clearly signal intent. + +### Optional + +Optional attributes return `nil` when not provided. + +```ruby +class CreateUser < CMDx::Task + attribute :email + attributes :age, :ssn + + # Alias for attributes (preferred) + optional :phone + optional :sex, :tags + + def work + email #=> "user@example.com" + age #=> 25 + ssn #=> nil + phone #=> nil + sex #=> nil + tags #=> ["premium", "beta"] + end +end + +# Attributes passed as keyword arguments +CreateUser.execute( + email: "user@example.com", + age: 25, + tags: ["premium", "beta"] +) +``` + +### Required + +Required attributes must be provided in call arguments or task execution will fail. + +```ruby +class CreateUser < CMDx::Task + attribute :email, required: true + attributes :age, :ssn, required: true + + # Alias for attributes => required: true (preferred) + required :phone + required :sex, :tags + + def work + email #=> "user@example.com" + age #=> 25 + ssn #=> "123-456" + phone #=> "888-9909" + sex #=> :male + tags #=> ["premium", "beta"] + end +end + +# Attributes passed as keyword arguments +CreateUser.execute( + email: "user@example.com", + age: 25, + ssn: "123-456", + phone: "888-9909", + sex: :male, + tags: ["premium", "beta"] +) +``` + +## Sources + +Attributes delegate to accessible objects within the task. The default source is `:context`, but any accessible method or object can serve as an attribute source. + +### Context + +```ruby +class UpdateProfile < CMDx::Task + # Default source is :context + required :user_id + optional :avatar_url + + # Explicitly specify context source + attribute :email, source: :context + + def work + user_id #=> context.user_id + email #=> context.email + avatar_url #=> context.avatar_url + end +end +``` + +### Symbol References + +Reference instance methods by symbol for dynamic source values: + +```ruby +class UpdateProfile < CMDx::Task + attributes :email, :settings, source: :user + + # Access from declared attributes + attribute :email_token, source: :settings + + def work + # Your logic here... + end + + private + + def user + @user ||= User.find(1) + end +end +``` + +### Proc or Lambda + +Use anonymous functions for dynamic source values: + +```ruby +class UpdateProfile < CMDx::Task + # Proc + attribute :email, source: proc { Current.user } + + # Lambda + attribute :email, source: -> { Current.user } +end +``` + +### Class or Module + +For complex source logic, use classes or modules: + +```ruby +class UserSourcer + def self.call(task) + User.find(task.context.user_id) + end +end + +class UpdateProfile < CMDx::Task + # Class or Module + attribute :email, source: UserSourcer + + # Instance + attribute :email, source: UserSourcer.new +end +``` + +## Nesting + +Nested attributes enable complex attribute structures where child attributes automatically inherit their parent as the source. This allows validation and access of structured data. + +> [!IMPORTANT] +> All options available to top-level attributes are available to nested attributes, eg: naming, coercions, and validations + +```ruby +class CreateShipment < CMDx::Task + # Required parent with required children + required :shipping_address do + required :street, :city, :state, :zip + optional :apartment + attribute :instructions + end + + # Optional parent with conditional children + optional :billing_address do + required :street, :city # Only required if billing_address provided + optional :same_as_shipping, prefix: true + end + + # Multi-level nesting + attribute :special_handling do + required :type + + optional :insurance do + required :coverage_amount + optional :carrier + end + end + + def work + shipping_address #=> { street: "123 Main St" ... } + street #=> "123 Main St" + apartment #=> nil + end +end + +CreateShipment.execute( + order_id: 123, + shipping_address: { + street: "123 Main St", + city: "Miami", + state: "FL", + zip: "33101", + instructions: "Leave at door" + }, + special_handling: { + type: "fragile", + insurance: { + coverage_amount: 500.00, + carrier: "FedEx" + } + } +) +``` + +> [!TIP] +> Child attributes are only required when their parent attribute is provided, enabling flexible optional structures. + +## Error Handling + +Attribute validation failures result in structured error information with details about each failed attribute. + +> [!IMPORTANT] +> Nested attributes are only ever evaluated when the parent attribute is available and valid. + +```ruby +class ProcessOrder < CMDx::Task + required :user_id, :order_id + required :shipping_address do + required :street, :city + end + + def work + # Your logic here... + end +end + +# Missing required top-level attributes +result = ProcessOrder.execute(user_id: 123) + +result.state #=> "interrupted" +result.status #=> "failed" +result.reason #=> "order_id is required. shipping_address is required." +result.metadata #=> { + # messages: { + # order_id: ["is required"], + # shipping_address: ["is required"] + # } + # } + +# Missing required nested attributes +result = ProcessOrder.execute( + user_id: 123, + order_id: 456, + shipping_address: { street: "123 Main St" } # Missing city +) + +result.state #=> "interrupted" +result.status #=> "failed" +result.reason #=> "city is required." +result.metadata #=> { + # messages: { + # city: ["is required"] + # } + # } +``` + +--- + +- **Prev:** [Outcomes - States](../outcomes/states.md) +- **Next:** [Attributes - Naming](naming.md) diff --git a/docs/attributes/naming.md b/docs/attributes/naming.md new file mode 100644 index 000000000..55be4756c --- /dev/null +++ b/docs/attributes/naming.md @@ -0,0 +1,78 @@ +# Attributes - Naming + +Attribute naming provides method name customization to prevent conflicts and enable flexible attribute access patterns. When attributes share names with existing methods or when multiple attributes from different sources have the same name, affixing ensures clean method resolution within tasks. + +> [!IMPORTANT] +> Affixing modifies only the generated accessor method names within tasks. + +## Table of Contents + +- [Prefix](#prefix) +- [Suffix](#suffix) +- [As](#as) + +## Prefix + +Adds a prefix to the generated accessor method name. + +```ruby +class UpdateCustomer < CMDx::Task + # Dynamic from attribute source + attribute :id, prefix: true + + # Static + attribute :name, prefix: "customer_" + + def work + context_id #=> 123 + customer_name #=> "Jane Smith" + end +end + +# Attributes passed as original attribute names +UpdateCustomer.execute(id: 123, name: "Jane Smith") +``` + +## Suffix + +Adds a suffix to the generated accessor method name. + +```ruby +class UpdateCustomer < CMDx::Task + # Dynamic from attribute source + attribute :email, suffix: true + + # Static + attribute :phone, suffix: "_number" + + def work + email_context #=> "jane@example.com" + phone_number #=> "555-0123" + end +end + +# Attributes passed as original attribute names +UpdateCustomer.execute(email: "jane@example.com", phone: "555-0123") +``` + +## As + +Completely renames the generated accessor method. + +```ruby +class UpdateCustomer < CMDx::Task + attribute :birthday, as: :bday + + def work + bday #=> + end +end + +# Attributes passed as original attribute names +UpdateCustomer.execute(birthday: Date.new(2020, 10, 31)) +``` + +--- + +- **Prev:** [Attributes - Definitions](definitions.md) +- **Next:** [Attributes - Coercions](coercions.md) diff --git a/docs/attributes/validations.md b/docs/attributes/validations.md new file mode 100644 index 000000000..28121a113 --- /dev/null +++ b/docs/attributes/validations.md @@ -0,0 +1,309 @@ +# Attributes - Validations + +Attribute validations ensure task arguments meet specified requirements before execution begins. Validations run after coercions and provide declarative rules for data integrity, supporting both built-in validators and custom validation logic. + +## Table of Contents + +- [Usage](#usage) +- [Built-in Validators](#built-in-validators) + - [Common Options](#common-options) + - [Exclusion](#exclusion) + - [Format](#format) + - [Inclusion](#inclusion) + - [Length](#length) + - [Numeric](#numeric) + - [Presence](#presence) +- [Declarations](#declarations) + - [Proc or Lambda](#proc-or-lambda) + - [Class or Module](#class-or-module) +- [Removals](#removals) +- [Error Handling](#error-handling) + +## Usage + +Define validation rules on attributes to enforce data requirements: + +```ruby +class ProcessOrder < CMDx::Task + # Required field with presence validation + attribute :customer_id, presence: true + + # String with length constraints + attribute :notes, length: { minimum: 10, maximum: 500 } + + # Numeric range validation + attribute :quantity, inclusion: { in: 1..100 } + + # Format validation for email + attribute :email, format: /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i + + def work + customer_id #=> "12345" + notes #=> "Please deliver to front door" + quantity #=> 5 + email #=> "customer@example.com" + end +end + +ProcessOrder.execute( + customer_id: "12345", + notes: "Please deliver to front door", + quantity: 5, + email: "customer@example.com" +) +``` + +> [!TIP] +> Validations run after coercions, so you can validate the final coerced values rather than raw input. + +## Built-in Validators + +### Common Options + +This list of options is available to all validators: + +| Option | Description | +|--------|-------------| +| `:allow_nil` | Skip validation when value is `nil` | +| `:if` | Symbol, proc, lambda, or callable determining when to validate | +| `:unless` | Symbol, proc, lambda, or callable determining when to skip validation | +| `:message` | Custom error message for validation failures | + +### Exclusion + +```ruby +class ProcessOrder < CMDx::Task + attribute :status, exclusion: { in: %w[out_of_stock discontinued] } + + def work + # Your logic here... + end +end +``` + +| Options | Description | +|---------|-------------| +| `:in` | The collection of forbidden values or range | +| `:within` | Alias for :in option | +| `:of_message` | Custom message for discrete value exclusions | +| `:in_message` | Custom message for range-based exclusions | +| `:within_message` | Alias for :in_message option | + +### Format + +```ruby +class ProcessOrder < CMDx::Task + attribute :email, exclusion: /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i + + attribute :email, exclusion: { with: /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i } + + def work + # Your logic here... + end +end +``` + +| Options | Description | +|---------|-------------| +| `regexp` | Alias for :with option | +| `:with` | Regex pattern that the value must match | +| `:without` | Regex pattern that the value must not match | + +### Inclusion + +```ruby +class ProcessOrder < CMDx::Task + attribute :status, inclusion: { in: %w[preorder in_stock] } + + def work + # Your logic here... + end +end +``` + +| Options | Description | +|---------|-------------| +| `:in` | The collection of allowed values or range | +| `:within` | Alias for :in option | +| `:of_message` | Custom message for discrete value inclusions | +| `:in_message` | Custom message for range-based inclusions | +| `:within_message` | Alias for :in_message option | + +### Length + +```ruby +class CreateUser < CMDx::Task + attribute :username, length: { within: 1..30 } + + def work + # Your logic here... + end +end +``` + +| Options | Description | +|---------|-------------| +| `:within` | Range that the length must fall within (inclusive) | +| `:not_within` | Range that the length must not fall within | +| `:in` | Alias for :within | +| `:not_in` | Range that the length must not fall within | +| `:min` | Minimum allowed length | +| `:max` | Maximum allowed length | +| `:is` | Exact required length | +| `:is_not` | Length that is not allowed | +| `:within_message` | Custom message for within/range validations | +| `:in_message` | Custom message for :in validation | +| `:not_within_message` | Custom message for not_within validation | +| `:not_in_message` | Custom message for not_in validation | +| `:min_message` | Custom message for minimum length validation | +| `:max_message` | Custom message for maximum length validation | +| `:is_message` | Custom message for exact length validation | +| `:is_not_message` | Custom message for is_not validation | + +### Numeric + +```ruby +class CreateUser < CMDx::Task + attribute :age, length: { min: 13 } + + def work + # Your logic here... + end +end +``` + +| Options | Description | +|---------|-------------| +| `:within` | Range that the value must fall within (inclusive) | +| `:not_within` | Range that the value must not fall within | +| `:in` | Alias for :within option | +| `:not_in` | Alias for :not_within option | +| `:min` | Minimum allowed value (inclusive, >=) | +| `:max` | Maximum allowed value (inclusive, <=) | +| `:is` | Exact value that must match | +| `:is_not` | Value that must not match | +| `:within_message` | Custom message for range validations | +| `:not_within_message` | Custom message for exclusion validations | +| `:min_message` | Custom message for minimum validation | +| `:max_message` | Custom message for maximum validation | +| `:is_message` | Custom message for exact match validation | +| `:is_not_message` | Custom message for exclusion validation | + +### Presence + +```ruby +class CreateUser < CMDx::Task + attribute :accept_tos, presence: true + + attribute :accept_tos, presence: { message: "needs to be accepted" } + + def work + # Your logic here... + end +end +``` + +| Options | Description | +|---------|-------------| +| `true` | Ensures value is not nil, empty string, or whitespace | + +## Declarations + +> [!IMPORTANT] +> Custom validators must raise a CMDx::ValidationError and its message is used as part of the fault reason and metadata. + +### Proc or Lambda + +Use anonymous functions for simple validation logic: + +```ruby +class CreateWebsite < CMDx::Task + # Proc + register :validator, :domain, proc do |value, options = {}| + unless value.match?(/\A[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,}\z/) + raise CMDx::ValidationError, "invalid domain format" + end + end + + # Lambda + register :validator, :domain, ->(value, options = {}) { + unless value.match?(/\A[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,}\z/) + raise CMDx::ValidationError, "invalid domain format" + end + } +end +``` + +### Class or Module + +Register custom validation logic for specialized requirements: + +```ruby +class DomainValidator + def self.call(value, options = {}) + unless value.match?(/\A[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,}\z/) + raise CMDx::ValidationError, "invalid domain format" + end + end +end + +class CreateWebsite < CMDx::Task + register :validator, :domain, DomainValidator + + attribute :domain_name, domain: true +end +``` + +## Removals + +Remove custom validators when no longer needed: + +```ruby +class CreateWebsite < CMDx::Task + deregister :validator, :domain +end +``` + +> [!IMPORTANT] +> Only one removal operation is allowed per `deregister` call. Multiple removals require separate calls. + +## Error Handling + +Validation failures provide detailed error information including attribute paths, validation rules, and specific failure reasons: + +```ruby +class CreateUser < CMDx::Task + attribute :username, presence: true, length: { minimum: 3, maximum: 20 } + attribute :age, numeric: { greater_than: 13, less_than: 120 } + attribute :role, inclusion: { in: [:user, :moderator, :admin] } + attribute :email, format: /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i + + def work + # Your logic here... + end +end + +result = CreateUser.execute( + username: "ab", # Too short + age: 10, # Too young + role: :superuser, # Not in allowed list + email: "invalid-email" # Invalid format +) + +result.state #=> "interrupted" +result.status #=> "failed" +result.reason #=> "username is too short (minimum is 3 characters). age must be greater than 13. role is not included in the list. email is invalid." +result.metadata #=> { + # messages: { + # username: ["is too short (minimum is 3 characters)"], + # age: ["must be greater than 13"], + # role: ["is not included in the list"], + # email: ["is invalid"] + # } + # } +``` + +--- + +- **Prev:** [Attributes - Coercions](coercions.md) +- **Next:** [Attributes - Defaults](defaults.md) diff --git a/docs/basics/call.md b/docs/basics/call.md deleted file mode 100644 index b693886ed..000000000 --- a/docs/basics/call.md +++ /dev/null @@ -1,317 +0,0 @@ -# Basics - Call - -Task execution in CMDx provides two distinct methods that handle success and failure scenarios differently. Understanding when to use each method is crucial for proper error handling and control flow in your application workflows. - -## Table of Contents - -- [TLDR](#tldr) -- [Execution Methods Overview](#execution-methods-overview) -- [Non-bang Call (`call`)](#non-bang-call-call) -- [Bang Call (`call!`)](#bang-call-call) -- [Direct Instantiation](#direct-instantiation) -- [Parameter Passing](#parameter-passing) -- [Result Propagation (`throw!`)](#result-propagation-throw) -- [Result Callbacks](#result-callbacks) -- [Task State Lifecycle](#task-state-lifecycle) -- [Error Handling](#error-handling) -- [Return Value Details](#return-value-details) - -## TLDR - -```ruby -# Standard execution (preferred) -result = ProcessOrderTask.call(order_id: 12345) -result.success? # → true/false - -# Exception-based execution -begin - result = ProcessOrderTask.call!(order_id: 12345) - # Handle success -rescue CMDx::Failed => e - # Handle failure -end - -# Result callbacks -ProcessOrderTask.call(order_id: 12345) - .on_success { |result| notify_customer(result) } - .on_failed { |result| handle_error(result) } - -# Propagate failures -throw!(validation_result) if validation_result.failed? -``` - -## Execution Methods Overview - -> [!NOTE] -> Tasks are single-use objects. Once executed, they are frozen and cannot be called again. Create a new instance for subsequent executions. - -| Method | Returns | Exceptions | Use Case | -|--------|---------|------------|----------| -| `call` | Always returns `CMDx::Result` | Never raises | Predictable result handling | -| `call!` | Returns `CMDx::Result` on success | Raises `CMDx::Fault` on failure/skip | Exception-based control flow | - -## Non-bang Call (`call`) - -The `call` method always returns a `CMDx::Result` object regardless of execution outcome. This is the preferred method for most use cases. - -```ruby -result = ProcessOrderTask.call(order_id: 12345) - -# Check execution state -result.success? # → true/false -result.failed? # → true/false -result.skipped? # → true/false - -# Access result data -result.context.order_id # → 12345 -result.runtime # → 0.05 (seconds) -result.state # → "complete" -result.status # → "success" -``` - -### Handling Different Outcomes - -```ruby -result = ProcessOrderTask.call(order_id: 12345) - -case result.status -when "success" - SendConfirmationTask.call(result.context) -when "skipped" - Rails.logger.info("Order skipped: #{result.metadata[:reason]}") -when "failed" - RetryOrderJob.perform_later(result.context.order_id) -end -``` - -## Bang Call (`call!`) - -The bang `call!` method raises a `CMDx::Fault` exception when tasks fail or are skipped. It returns a `CMDx::Result` object only on success. - -> [!WARNING] -> `call!` behavior depends on the `task_halt` configuration. By default, it raises exceptions for both failures and skips. - -```ruby -begin - result = ProcessOrderTask.call!(order_id: 12345) - SendConfirmationTask.call(result.context) -rescue CMDx::Failed => e - RetryOrderJob.perform_later(e.result.context.order_id) -rescue CMDx::Skipped => e - Rails.logger.info("Order skipped: #{e.result.metadata[:reason]}") -end -``` - -### Exception Types - -| Exception | Raised When | Access Result | -|-----------|-------------|---------------| -| `CMDx::Failed` | Task execution fails | `exception.result` | -| `CMDx::Skipped` | Task execution is skipped | `exception.result` | - -## Direct Instantiation - -Tasks can be instantiated directly for advanced use cases, testing, and custom execution patterns: - -```ruby -# Direct instantiation -task = ProcessOrderTask.new(order_id: 12345, notify_customer: true) - -# Access properties before execution -task.id # → "abc123..." (unique task ID) -task.context.order_id # → 12345 -task.context.notify_customer # → true -task.result.state # → "initialized" - -# Manual execution -task.process -task.result.success? # → true/false -``` - -### Execution Approaches - -| Approach | Use Case | Benefits | -|----------|----------|----------| -| `TaskClass.call(...)` | Standard execution | Simple, handles full lifecycle | -| `TaskClass.call!(...)` | Exception-based flow | Automatic fault raising | -| `TaskClass.new(...).process` | Advanced scenarios | Full control, testing flexibility | - -## Parameter Passing - -All methods accept parameters that become available in the task context: - -```ruby -# Direct parameters -result = ProcessOrderTask.call( - order_id: 12345, - notify_customer: true, - priority: "high" -) - -# From another task result -validation_result = ValidateOrderTask.call(order_id: 12345) - -# Pass Result object directly -result = ProcessOrderTask.call(validation_result) - -# Pass context from previous result -result = ProcessOrderTask.call(validation_result.context) -``` - -## Result Propagation (`throw!`) - -The `throw!` method enables result propagation, allowing tasks to bubble up failures from subtasks while preserving the original fault information: - -> [!IMPORTANT] -> Use `throw!` to maintain failure context and prevent nested error handling in complex workflows. - -```ruby -class ProcessOrderTask < CMDx::Task - def call - # Validate order - validation_result = ValidateOrderTask.call(context) - throw!(validation_result) if validation_result.failed? - - # Process payment - payment_result = ProcessPaymentTask.call(context) - throw!(payment_result) if payment_result.failed? - - # Schedule delivery - delivery_result = ScheduleDeliveryTask.call(context) - throw!(delivery_result) unless delivery_result.success? - - # Continue with main logic - finalize_order_processing - end -end -``` - -## Result Callbacks - -Results support fluent callback patterns for conditional logic: - -```ruby -ProcessOrderTask - .call(order_id: 12345) - .on_success { |result| - SendOrderConfirmationTask.call(result.context) - } - .on_failed { |result| - ErrorReportingService.notify(result.metadata[:error]) - } - .on_executed { |result| - MetricsService.timing('order.processing_time', result.runtime) - } -``` - -### Available Callbacks - -> [!TIP] -> Callbacks return the result object, enabling method chaining for complex conditional logic. - -```ruby -result = ProcessOrderTask.call(order_id: 12345) - -# State-based callbacks -result - .on_complete { |r| cleanup_resources(r) } - .on_interrupted { |r| handle_interruption(r) } - .on_executed { |r| log_execution_time(r) } - -# Status-based callbacks -result - .on_success { |r| handle_success(r) } - .on_skipped { |r| handle_skip(r) } - .on_failed { |r| handle_failure(r) } - -# Outcome-based callbacks -result - .on_good { |r| log_positive_outcome(r) } # success or skipped - .on_bad { |r| log_negative_outcome(r) } # failed only -``` - -## Task State Lifecycle - -Tasks progress through defined states during execution: - -```ruby -result = ProcessOrderTask.call(order_id: 12345) - -# Execution states -result.state # → "initialized" → "executing" → "complete"/"interrupted" - -# Outcome statuses -result.status # → "success"/"failed"/"skipped" -``` - -## Error Handling - -### Common Error Scenarios - -```ruby -# Parameter validation failure -result = ProcessOrderTask.call(order_id: nil) -result.failed? # → true -result.metadata[:reason] # → "order_id is required" - -# Business logic failure -result = ProcessOrderTask.call(order_id: 99999) -result.failed? # → true -result.metadata[:error].class # → ActiveRecord::RecordNotFound - -# Task skipped due to conditions -result = ProcessOrderTask.call(order_id: 12345, force: false) -result.skipped? # → true (if order already processed) -result.metadata[:reason] # → "Order already processed" -``` - -### Exception Handling with `call!` - -```ruby -begin - result = ProcessOrderTask.call!(order_id: 12345) -rescue CMDx::Failed => e - # Access original error details - error_type = e.result.metadata[:error].class - error_message = e.result.metadata[:reason] - - case error_type - when ActiveRecord::RecordNotFound - render json: { error: "Order not found" }, status: 404 - when PaymentError - render json: { error: "Payment failed" }, status: 402 - else - render json: { error: "Processing failed" }, status: 500 - end -end -``` - -## Return Value Details - -The `Result` object provides comprehensive execution information: - -```ruby -result = ProcessOrderTask.call(order_id: 12345) - -# Execution metadata -result.id # → "abc123..." (unique execution ID) -result.runtime # → 0.05 (execution time in seconds) -result.task # → ProcessOrderTask instance -result.chain # → Chain object for tracking executions - -# Context and metadata -result.context # → Context with all task data -result.metadata # → Hash with execution metadata - -# State checking methods -result.good? # → true for success/skipped -result.bad? # → true for failed only -result.complete? # → true when execution finished normally -result.interrupted? # → true for failed/skipped -result.executed? # → true for any completed execution -``` - ---- - -- **Prev:** [Basics - Setup](setup.md) -- **Next:** [Basics - Context](context.md) diff --git a/docs/basics/chain.md b/docs/basics/chain.md index 78c2d9e96..f8b29e2e6 100644 --- a/docs/basics/chain.md +++ b/docs/basics/chain.md @@ -4,123 +4,103 @@ Chains automatically group related task executions within a thread, providing un ## Table of Contents -- [TLDR](#tldr) -- [Thread-Local Chain Management](#thread-local-chain-management) -- [Automatic Chain Creation](#automatic-chain-creation) -- [Chain Inheritance](#chain-inheritance) -- [Chain Structure and Metadata](#chain-structure-and-metadata) -- [Correlation ID Integration](#correlation-id-integration) -- [State Delegation](#state-delegation) -- [Serialization and Logging](#serialization-and-logging) -- [Error Handling](#error-handling) +- [Management](#management) +- [Links](#links) +- [Inheritance](#inheritance) +- [Structure](#structure) -## TLDR +## Management -```ruby -# Automatic chain creation per thread -result = ProcessOrderTask.call(order_id: 123) -result.chain.id # Unique chain ID -result.chain.results.size # All tasks in this chain - -# Access current thread's chain -CMDx::Chain.current # Current chain or nil -CMDx::Chain.clear # Clear thread's chain - -# Subtasks automatically inherit chain -class ProcessOrderTask < CMDx::Task - def call - # These inherit the same chain automatically - ValidateOrderTask.call!(order_id: order_id) - ChargePaymentTask.call!(order_id: order_id) - end -end -``` - -## Thread-Local Chain Management - -> [!NOTE] -> Each thread maintains its own chain context through thread-local storage, providing automatic isolation without manual coordination. +Each thread maintains its own chain context through thread-local storage, providing automatic isolation without manual coordination. ```ruby # Thread A Thread.new do - result = ProcessOrderTask.call(order_id: 123) - result.chain.id # "018c2b95-b764-7615-a924-cc5b910ed1e5" + result = ProcessOrder.execute(order_id: 123) + result.chain.id #=> "018c2b95-b764-7615-a924-cc5b910ed1e5" end # Thread B (completely separate chain) Thread.new do - result = ProcessOrderTask.call(order_id: 456) - result.chain.id # "018c2b95-c821-7892-b156-dd7c921fe2a3" + result = ProcessOrder.execute(order_id: 456) + result.chain.id #=> "z3a42b95-c821-7892-b156-dd7c921fe2a3" end # Access current thread's chain -CMDx::Chain.current # Returns current chain or nil -CMDx::Chain.clear # Clears current thread's chain +CMDx::Chain.current #=> Returns current chain or nil +CMDx::Chain.clear #=> Clears current thread's chain ``` -## Automatic Chain Creation +> [!IMPORTANT] +> Chain operations are thread-local. Never share chain references across threads as this can lead to race conditions and data corruption. + +## Links Every task execution automatically creates or joins the current thread's chain: ```ruby -# First task creates new chain -result1 = ProcessOrderTask.call(order_id: 123) -result1.chain.id # "018c2b95-b764-7615-a924-cc5b910ed1e5" -result1.chain.results.size # 1 - -# Second task joins existing chain -result2 = SendEmailTask.call(to: "user@example.com") -result2.chain.id == result1.chain.id # true -result2.chain.results.size # 2 - -# Both results reference the same chain -result1.chain.results == result2.chain.results # true +class ProcessOrder < CMDx::Task + def work + # First task creates new chain + result1 = ProcessOrder.execute(order_id: 123) + result1.chain.id #=> "018c2b95-b764-7615-a924-cc5b910ed1e5" + result1.chain.results.size #=> 1 + + # Second task joins existing chain + result2 = SendEmail.execute(to: "user@example.com") + result2.chain.id == result1.chain.id #=> true + result2.chain.results.size #=> 2 + + # Both results reference the same chain + result1.chain.results == result2.chain.results #=> true + end +end ``` -## Chain Inheritance +> [!NOTE] +> Chain creation is automatic and transparent. You don't need to manually manage chain lifecycle. -> [!IMPORTANT] -> When tasks call subtasks within the same thread, all executions automatically inherit the current chain, creating a unified execution trail. +## Inheritance + +When tasks call subtasks within the same thread, all executions automatically inherit the current chain, creating a unified execution trail. ```ruby -class ProcessOrderTask < CMDx::Task - def call +class ProcessOrder < CMDx::Task + def work context.order = Order.find(order_id) # Subtasks automatically inherit current chain - ValidateOrderTask.call!(order_id: order_id) - ChargePaymentTask.call!(order_id: order_id) - SendConfirmationTask.call!(order_id: order_id) + ValidateOrder.execute + ChargePayment.execute!(context) + SendConfirmation.execute(order_id: order_id) end end -result = ProcessOrderTask.call(order_id: 123) +result = ProcessOrder.execute(order_id: 123) chain = result.chain # All tasks share the same chain -chain.results.size # 4 (main task + 3 subtasks) -chain.results.map(&:task).map(&:class) -# [ProcessOrderTask, ValidateOrderTask, ChargePaymentTask, SendConfirmationTask] +chain.results.size #=> 4 (main task + 3 subtasks) +chain.results.map { |r| r.task.class } +#=> [ProcessOrder, ValidateOrder, ChargePayment, SendConfirmation] ``` -## Chain Structure and Metadata +## Structure Chains provide comprehensive execution information with state delegation: ```ruby -result = ProcessOrderTask.call(order_id: 123) +result = ProcessOrder.execute(order_id: 123) chain = result.chain # Chain identification -chain.id # "018c2b95-b764-7615-a924-cc5b910ed1e5" -chain.results # Array of all results in execution order +chain.id #=> "018c2b95-b764-7615-a924-cc5b910ed1e5" +chain.results #=> Array of all results in execution order # State delegation (from first/outer-most result) -chain.state # "complete" -chain.status # "success" -chain.outcome # "success" -chain.runtime # 1.2 (total execution time) +chain.state #=> "complete" +chain.status #=> "success" +chain.outcome #=> "success" # Access individual results chain.results.each_with_index do |result, index| @@ -128,182 +108,9 @@ chain.results.each_with_index do |result, index| end ``` -## Correlation ID Integration - -> [!TIP] -> Chain IDs serve as correlation identifiers, enabling request tracing across distributed systems and complex workflows. - -### Automatic Correlation - -Chains integrate with the correlation system using hierarchical precedence: - -```ruby -# 1. Existing chain ID takes precedence -CMDx::Chain.current = CMDx::Chain.new(id: "request-123") -result = ProcessOrderTask.call(order_id: 456) -result.chain.id # "request-123" - -# 2. Thread-local correlation used if no chain exists -CMDx::Chain.clear -CMDx::Correlator.id = "session-456" -result = ProcessOrderTask.call(order_id: 789) -result.chain.id # "session-456" - -# 3. Generated UUID when no correlation exists -CMDx::Correlator.clear -result = ProcessOrderTask.call(order_id: 101) -result.chain.id # "018c2b95-b764-7615-a924-cc5b910ed1e5" (generated) -``` - -### Custom Chain IDs - -```ruby -# Create chain with specific correlation ID -chain = CMDx::Chain.new(id: "api-request-789") -CMDx::Chain.current = chain - -result = ProcessApiRequestTask.call(data: payload) -result.chain.id # "api-request-789" - -# All subtasks inherit the same correlation ID -result.chain.results.all? { |r| r.chain.id == "api-request-789" } # true -``` - -### Correlation Context Management - -```ruby -# Scoped correlation context -CMDx::Correlator.use("user-session-123") do - result = ProcessUserActionTask.call(action: "purchase") - result.chain.id # "user-session-123" - - # Nested operations inherit correlation - AuditLogTask.call(event: "purchase_completed") -end - -# Outside block, correlation context restored -result = OtherTask.call -result.chain.id # Different correlation ID -``` - -## State Delegation - -> [!WARNING] +> [!NOTE] > Chain state always reflects the first (outer-most) task result, not individual subtask outcomes. Subtasks maintain their own success/failure states. -```ruby -class ProcessOrderTask < CMDx::Task - def call - ValidateOrderTask.call!(order_id: order_id) # Success - ChargePaymentTask.call!(order_id: order_id) # Failure - end -end - -result = ProcessOrderTask.call(order_id: 123) -chain = result.chain - -# Chain delegates to main task (first result) -chain.status # "failed" (ProcessOrderTask failed due to subtask) -chain.state # "interrupted" - -# Individual results maintain their own state -chain.results[0].status # "failed" (ProcessOrderTask - main) -chain.results[1].status # "success" (ValidateOrderTask) -chain.results[2].status # "failed" (ChargePaymentTask) -``` - -## Serialization and Logging - -Chains provide comprehensive serialization for monitoring and debugging: - -```ruby -result = ProcessOrderTask.call(order_id: 123) -chain = result.chain - -# Structured data representation -chain.to_h -# { -# id: "018c2b95-b764-7615-a924-cc5b910ed1e5", -# state: "complete", -# status: "success", -# outcome: "success", -# runtime: 0.8, -# results: [ -# { class: "ProcessOrderTask", state: "complete", status: "success", ... }, -# { class: "ValidateOrderTask", state: "complete", status: "success", ... }, -# { class: "ChargePaymentTask", state: "complete", status: "success", ... } -# ] -# } - -# Human-readable execution summary -puts chain.to_s -# chain: 018c2b95-b764-7615-a924-cc5b910ed1e5 -# ================================================ -# -# ProcessOrderTask: index=0 state=complete status=success runtime=0.8 -# ValidateOrderTask: index=1 state=complete status=success runtime=0.1 -# ChargePaymentTask: index=2 state=complete status=success runtime=0.5 -# -# ================================================ -# state: complete | status: success | outcome: success | runtime: 0.8 -``` - -## Error Handling - -### Chain Access Patterns - -```ruby -# Safe chain access -result = ProcessOrderTask.call(order_id: 123) - -if result.chain - correlation_id = result.chain.id - execution_count = result.chain.results.size -else - # Handle missing chain (shouldn't happen in normal execution) - correlation_id = "unknown" -end -``` - -### Thread Safety - -> [!IMPORTANT] -> Chain operations are thread-safe within individual threads but chains should not be shared across threads. Each thread maintains its own isolated chain context. - -```ruby -# Safe: Each thread has its own chain -threads = 3.times.map do |i| - Thread.new do - result = ProcessOrderTask.call(order_id: 100 + i) - result.chain.id # Unique per thread - end -end - -# Collect results safely -chain_ids = threads.map(&:value) -chain_ids.uniq.size # 3 (all different) -``` - -### Chain State Validation - -```ruby -result = ProcessOrderTask.call(order_id: 123) -chain = result.chain - -# Validate chain integrity -case chain.state -when "complete" - # All tasks finished normally - process_successful_chain(chain) -when "interrupted" - # Task was halted or failed - handle_chain_interruption(chain) -else - # Unexpected state - log_chain_anomaly(chain) -end -``` - --- - **Prev:** [Basics - Context](context.md) diff --git a/docs/basics/context.md b/docs/basics/context.md index 740d67a75..9f543578b 100644 --- a/docs/basics/context.md +++ b/docs/basics/context.md @@ -1,88 +1,41 @@ # Basics - Context -Task context provides flexible data storage and sharing for task execution. Built on `LazyStruct`, context enables dynamic attribute access, parameter validation, and seamless data flow between related tasks. +Task context provides flexible data storage, access, and sharing within task execution. It serves as the primary data container for all task inputs, intermediate results, and outputs. ## Table of Contents -- [TLDR](#tldr) -- [Context Fundamentals](#context-fundamentals) +- [Assigning Data](#assigning-data) - [Accessing Data](#accessing-data) - [Modifying Context](#modifying-context) -- [Data Sharing Between Tasks](#data-sharing-between-tasks) -- [Result Object Context Passing](#result-object-context-passing) -- [Context Inspection and Debugging](#context-inspection-and-debugging) -- [Error Handling](#error-handling) +- [Data Sharing](#data-sharing) -## TLDR +## Assigning Data -```ruby -# Automatic parameter loading -ProcessOrderTask.call(user_id: 123, amount: 99.99) - -# Dynamic attribute access -context.user_id # → 123 -context[:amount] # → 99.99 - -# Safe modification -context.total = amount * 1.08 -context.merge!(status: "processed", completed_at: Time.now) - -# Task chaining with context preservation -result = ValidateOrderTask.call(context) -ProcessPaymentTask.call(result) if result.success? -``` - -## Context Fundamentals - -> [!IMPORTANT] -> Context is automatically populated with all parameters passed to a task. Parameters become accessible as dynamic attributes using both method and hash-style access patterns. - -### Automatic Parameter Loading - -When calling tasks, all parameters automatically become context attributes: +Context is automatically populated with all inputs passed to a task. All keys are normalized to symbols for consistent access: ```ruby -class ProcessOrderTask < CMDx::Task - required :user_id, type: :integer - required :amount, type: :float - optional :currency, default: "USD" - - def call - # Parameters automatically available in context - context.user_id # → 123 - context.amount # → 99.99 - context.currency # → "USD" - end -end +# Direct execution +ProcessOrder.execute(user_id: 123, currency: "USD") -ProcessOrderTask.call(user_id: 123, amount: 99.99) +# Instance creation +ProcessOrder.new(user_id: 123, "currency" => "USD") ``` -### Key Normalization - -All keys are automatically normalized to symbols for consistent access: - -```ruby -# String and symbol keys both work -ProcessOrderTask.call("user_id" => 123, :amount => 99.99) - -# Both accessible as symbols -context.user_id # → 123 -context.amount # → 99.99 -``` +> [!NOTE] +> String keys are automatically converted to symbols. Use symbols for consistency in your code. ## Accessing Data Context provides multiple access patterns with automatic nil safety: ```ruby -class ProcessOrderTask < CMDx::Task - def call - # Method-style access (preferred) +class ProcessOrder < CMDx::Task + def work + # Method style access (preferred) user_id = context.user_id amount = context.amount - # Hash-style access + # Hash style access order_id = context[:order_id] metadata = context["metadata"] @@ -97,28 +50,15 @@ end ``` > [!NOTE] -> Accessing undefined attributes returns `nil` instead of raising errors, enabling graceful handling of optional parameters. - -### Type Safety - -Context accepts any data type without restrictions: - -```ruby -context.string_value = "Order #12345" -context.numeric_value = 42 -context.array_value = [1, 2, 3] -context.hash_value = { total: 99.99, tax: 8.99 } -context.object_value = User.find(123) -context.timestamp = Time.now -``` +> Accessing undefined context attributes returns `nil` instead of raising errors, enabling graceful handling of optional attributes. ## Modifying Context Context supports dynamic modification during task execution: ```ruby -class ProcessOrderTask < CMDx::Task - def call +class ProcessOrder < CMDx::Task + def work # Direct assignment context.user = User.find(context.user_id) context.order = Order.find(context.order_id) @@ -151,213 +91,39 @@ end ``` > [!TIP] -> Use context for both input parameters and intermediate results. This creates natural data flow through your task execution pipeline. +> Use context for both input attributes and intermediate results. This creates natural data flow through your task execution pipeline. -## Data Sharing Between Tasks +## Data Sharing Context enables seamless data flow between related tasks in complex workflows: -### Task Composition - ```ruby -class ProcessOrderWorkflowTask < CMDx::Task - def call +# During execution +class ProcessOrder < CMDx::Task + def work # Validate order data - validation_result = ValidateOrderTask.call(context) - throw!(validation_result) unless validation_result.success? + validation_result = ValidateOrder.execute(context) - # Process payment with enriched context - payment_result = ProcessPaymentTask.call(context) - throw!(payment_result) unless payment_result.success? + # Via context + ProcessPayment.execute(context) - # Send notifications with complete context - NotifyOrderProcessedTask.call(context) + # Via result + NotifyOrderProcessed.execute(validation_result) # Context now contains accumulated data from all tasks - context.order_validated # → true (from validation) - context.payment_processed # → true (from payment) - context.notification_sent # → true (from notification) - end -end -``` - -### Workflow Chains - -```ruby -# Initialize workflow context -initial_data = { user_id: 123, product_ids: [1, 2, 3] } - -# Chain tasks with context flow -validation_result = ValidateCartTask.call(initial_data) - -if validation_result.success? - # Context accumulates data through the chain - inventory_result = CheckInventoryTask.call(validation_result.context) - payment_result = ProcessPaymentTask.call(inventory_result.context) - shipping_result = CreateShipmentTask.call(payment_result.context) -end -``` - -## Result Object Context Passing - -> [!IMPORTANT] -> CMDx automatically extracts context when Result objects are passed to task methods, enabling powerful workflow compositions where task output becomes the next task's input. - -```ruby -# Seamless task chaining -extraction_result = ExtractDataTask.call(source_id: 123) -processing_result = ProcessDataTask.call(extraction_result) - -# Context flows automatically between tasks -processing_result.context.source_id # → 123 (from first task) -processing_result.context.extracted_records # → [...] (from first task) -processing_result.context.processed_count # → 50 (from second task) -``` - -### Error Propagation in Chains - -```ruby -# Non-raising chain with error handling -extraction_result = ExtractDataTask.call(source_id: 123) - -if extraction_result.failed? - # Context preserved even in failure scenarios - error_handler_result = HandleExtractionErrorTask.call(extraction_result) - return error_handler_result -end - -# Continue processing with successful result -ProcessDataTask.call(extraction_result) -``` - -### Exception-Based Chains - -```ruby -begin - # Raising version propagates exceptions while preserving context - extraction_result = ExtractDataTask.call!(source_id: 123) - processing_result = ProcessDataTask.call!(extraction_result) - notification_result = NotifyCompletionTask.call!(processing_result) -rescue CMDx::Failed => e - # Access failed task's context for error analysis - ErrorReportingTask.call( - error: e.message, - failed_context: e.result.context, - user_id: e.result.context.user_id - ) -end -``` - -## Context Inspection and Debugging - -Context provides comprehensive inspection capabilities for debugging and logging: - -```ruby -class DebuggableTask < CMDx::Task - def call - # Log current context state - Rails.logger.info "Context: #{context.inspect}" - - # Convert to hash for serialization - context_data = context.to_h - # → { user_id: 123, amount: 99.99, status: "processing" } - - # Iterate over context data - context.each_pair do |key, value| - puts "#{key}: #{value.class} = #{value}" - end - - # Check for specific keys - has_user = context.key?(:user_id) # → true - has_admin = context.key?(:admin_mode) # → false - end -end -``` - -### Production Logging - -```ruby -class OrderProcessingTask < CMDx::Task - def call - log_context_snapshot("start") - - process_order - - log_context_snapshot("complete") - end - - private - - def log_context_snapshot(stage) - Rails.logger.info({ - stage: stage, - task: self.class.name, - context: context.to_h.except(:sensitive_data) - }.to_json) - end -end -``` - -## Error Handling - -> [!WARNING] -> Context operations are generally safe, but understanding error scenarios helps build robust applications. - -### Safe Access Patterns - -```ruby -class RobustTask < CMDx::Task - def call - # Safe: returns nil for missing attributes - user_id = context.user_id || 'anonymous' - - # Safe: fetch with default - timeout = context.fetch!(:timeout, 30) - - # Safe: deep access with nil protection - api_key = context.dig(:credentials, :api_key) - - # Safe: conditional assignment - context.processed_at ||= Time.now - end -end -``` - -### Common Error Scenarios - -```ruby -# Missing required context data -class PaymentTask < CMDx::Task - def call - # Check for required context before proceeding - unless context.user_id && context.amount - context.error_message = "Missing required payment data" - fail!(reason: "Cannot process payment") - end - - process_payment + context.order_validated #=> true (from validation) + context.payment_processed #=> true (from payment) + context.notification_sent #=> true (from notification) end end -# Invalid context modifications -class ValidationTask < CMDx::Task - def call - # Context cannot be replaced entirely - # context = {} # This won't work as expected +# After execution +result = ProcessOrder.execute(order_number: 123) - # Instead, clear individual keys or use merge! - context.delete!(:temporary_data) - context.merge!(validation_status: "complete") - end -end +ShipOrder.execute(result) ``` -> [!TIP] -> Use context inspection methods liberally during development and testing. The `to_h` method is particularly useful for logging and debugging complex workflows. - -[Learn more](../../lib/cmdx/lazy_struct.rb) about the `LazyStruct` implementation that powers context functionality. - --- -- **Prev:** [Basics - Call](call.md) +- **Prev:** [Basics - Execution](execution.md) - **Next:** [Basics - Chain](chain.md) diff --git a/docs/basics/execution.md b/docs/basics/execution.md new file mode 100644 index 000000000..7e048523d --- /dev/null +++ b/docs/basics/execution.md @@ -0,0 +1,115 @@ +# Basics - Execution + +Task execution in CMDx provides two distinct methods that handle success and halt scenarios differently. Understanding when to use each method is crucial for proper error handling and control flow in your application workflows. + +## Table of Contents + +- [Methods Overview](#methods-overview) +- [Non-bang Execution](#non-bang-execution) +- [Bang Execution](#bang-execution) +- [Direct Instantiation](#direct-instantiation) +- [Result Details](#result-details) + +## Methods Overview + +Tasks are single-use objects. Once executed, they are frozen and cannot be executed again. +Create a new instance for subsequent executions. + +| Method | Returns | Exceptions | Use Case | +|--------|---------|------------|----------| +| `execute` | Always returns `CMDx::Result` | Never raises | Predictable result handling | +| `execute!` | Returns `CMDx::Result` on success | Raises `CMDx::Fault` when skipped or failed | Exception-based control flow | + +## Non-bang Execution + +The `execute` method always returns a `CMDx::Result` object regardless of execution outcome. +This is the preferred method for most use cases. + +Any unhandled exceptions will be caught and returned as a task failure. + +```ruby +result = ProcessOrder.execute(order_id: 12345) + +# Check execution state +result.success? #=> true/false +result.failed? #=> true/false +result.skipped? #=> true/false + +# Access result data +result.context.order_id #=> 12345 +result.state #=> "complete" +result.status #=> "success" +``` + +## Bang Execution + +The bang `execute!` method raises a `CMDx::Fault` based exception when tasks fail or are skipped, and returns a `CMDx::Result` object only on success. + +It raises any unhandled non-fault exceptions caused during execution. + +| Exception | Raised When | +|-----------|-------------| +| `CMDx::FailFault` | Task execution fails | +| `CMDx::SkipFault` | Task execution is skipped | + +> [!WARNING] +> `execute!` behavior depends on the `task_breakpoints` or `workflow_breakpoints` configuration. +> By default, it raises exceptions only on failures. + +```ruby +begin + result = ProcessOrder.execute!(order_id: 12345) + SendConfirmation.execute(result.context) +rescue CMDx::FailFault => e + RetryOrderJob.perform_later(e.result.context.order_id) +rescue CMDx::SkipFault => e + Rails.logger.info("Order skipped: #{e.result.reason}") +rescue Exception => e + BugTracker.notify(unhandled_exception: e) +end +``` + +## Direct Instantiation + +Tasks can be instantiated directly for advanced use cases, testing, and custom execution patterns: + +```ruby +# Direct instantiation +task = ProcessOrder.new(order_id: 12345, notify_customer: true) + +# Access properties before execution +task.id #=> "abc123..." (unique task ID) +task.context.order_id #=> 12345 +task.context.notify_customer #=> true +task.result.state #=> "initialized" +task.result.status #=> "success" + +# Manual execution +task.execute +# or +task.execute! + +task.result.success? #=> true/false +``` + +## Result Details + +The `Result` object provides comprehensive execution information: + +```ruby +result = ProcessOrder.execute(order_id: 12345) + +# Execution metadata +result.id #=> "abc123..." (unique execution ID) +result.task #=> ProcessOrderTask instance (frozen) +result.chain #=> Task execution chain + +# Context and metadata +result.context #=> Context with all task data +result.metadata #=> Hash with execution metadata +``` + +--- + +- **Prev:** [Basics - Setup](setup.md) +- **Next:** [Basics - Context](context.md) diff --git a/docs/basics/setup.md b/docs/basics/setup.md index 5650fb1d2..f78e7f7f4 100644 --- a/docs/basics/setup.md +++ b/docs/basics/setup.md @@ -1,375 +1,78 @@ # Basics - Setup -A task represents a unit of work to execute. Tasks are the core building blocks of CMDx, encapsulating business logic within a structured, reusable object. While CMDx offers extensive features like parameter validation, callbacks, and state tracking, only a `call` method is required to create a functional task. +Tasks are the core building blocks of CMDx, encapsulating business logic within structured, reusable objects. Each task represents a unit of work with automatic attribute validation, error handling, and execution tracking. ## Table of Contents -- [TLDR](#tldr) -- [Basic Task Structure](#basic-task-structure) -- [Task Execution](#task-execution) -- [Inheritance and Application Tasks](#inheritance-and-application-tasks) -- [Generator](#generator) -- [Task Lifecycle](#task-lifecycle) -- [Error Handling](#error-handling) +- [Structure](#structure) +- [Inheritance](#inheritance) +- [Lifecycle](#lifecycle) -## TLDR +## Structure -```ruby -# Minimal task - only call method required -class ProcessOrderTask < CMDx::Task - def call - context.result = "Order processed" - end -end - -# Execute and access results -result = ProcessOrderTask.call(order_id: 123) -result.success? # → true -result.context.result # → "Order processed" - -# With parameters and validation -class UpdateUserTask < CMDx::Task - required :user_id, type: :integer - required :email, type: :string - - def call - user = User.find(context.user_id) - user.update!(email: context.email) - end -end - -# Generator for quick scaffolding -rails g cmdx:task ProcessPayment # Creates structured template -``` - -## Basic Task Structure - -> [!NOTE] -> Tasks are Ruby classes that inherit from `CMDx::Task`. Only the `call` method is required - all other features are optional and can be added as needed. - -### Minimal Task +Tasks inherit from `CMDx::Task` and require only a `work` method: ```ruby -class ProcessUserOrderTask < CMDx::Task - def call - # Your business logic here - context.order = Order.find(context.order_id) - context.order.process! +class ProcessUserOrder < CMDx::Task + def work + # Your logic here... end end ``` -### Complete Task Structure +An exception will be raised if a work method is not defined. ```ruby -class ProcessPaymentTask < CMDx::Task - # Parameter definitions (optional) - required :amount, type: :float - required :user_id, type: :integer - optional :currency, type: :string, default: "USD" - - # Callbacks (optional) - before_call :validate_user - after_call :send_notification - - def call - # Core business logic - user = User.find(context.user_id) - payment = Payment.create!( - user: user, - amount: context.amount, - currency: context.currency - ) - - context.payment = payment - context.success_message = "Payment processed successfully" - end - - private - - def validate_user - # Validation logic - end - - def send_notification - # Notification logic - end +class InvalidTask < CMDx::Task + # No `work` method defined end -``` - -## Task Execution - -> [!IMPORTANT] -> Tasks return a `CMDx::Result` object that contains execution state, context data, and metadata. Always check the result status before accessing context data. - -### Basic Execution - -```ruby -# Execute a task -result = ProcessUserOrderTask.call(order_id: 123) -# Check execution status -result.success? # → true/false -result.failed? # → true/false - -# Access context data -result.context.order # → - -# Access execution metadata -result.status # → :success, :failure, etc. -result.state # → :executed, :skipped, etc. -result.runtime # → 0.1234 (seconds) -``` - -### Handling Different Outcomes - -```ruby -result = ProcessPaymentTask.call( - amount: 99.99, - user_id: 12345, - currency: "EUR" -) - -case result.status -when :success - payment = result.context.payment - puts result.context.success_message -when :failure - puts "Payment failed: #{result.metadata[:reason]}" -when :halt - puts "Payment halted: #{result.metadata[:reason]}" -end +InvalidTask.execute #=> raises CMDx::UndefinedMethodError ``` -## Inheritance and Application Tasks - -> [!TIP] -> In Rails applications, create an `ApplicationTask` base class to share common configuration, middleware, and functionality across all your tasks. +## Inheritance -### Application Base Class +All configuration options are inheritable by any child classes. +Create a base class to share common configuration across tasks: ```ruby -# app/tasks/application_task.rb class ApplicationTask < CMDx::Task - # Shared configuration - use :middleware, AuthenticateUserMiddleware - use :middleware, LogExecutionMiddleware + register :middleware, AuthenticateUserMiddleware - # Common callbacks - before_call :set_correlation_id - after_call :cleanup_temp_data + before_execution :set_correlation_id - # Shared parameter definitions - optional :current_user, type: :virtual - optional :request_id, type: :string + attribute :request_id private def set_correlation_id context.correlation_id ||= SecureRandom.uuid end - - def cleanup_temp_data - # Cleanup logic - end end -``` - -### Task Implementation - -```ruby -# app/tasks/process_user_order_task.rb -class ProcessUserOrderTask < ApplicationTask - required :order_id, type: :integer - required :payment_method, type: :string - - def call - # Inherits all ApplicationTask functionality - order = Order.find(context.order_id) - - # Business logic specific to this task - process_order(order) - charge_payment(order, context.payment_method) - - context.order = order - end - - private - def process_order(order) - # Implementation - end - - def charge_payment(order, method) - # Implementation +class ProcessOrder < ApplicationTask + def work + # Your logic here... end end ``` -## Generator +## Lifecycle -> [!NOTE] -> Rails applications can use the built-in generator to create consistent task templates with proper structure and naming conventions. +Tasks follow a predictable call pattern with specific states and statuses: -### Basic Task Generation - -```bash -# Generate a basic task -rails g cmdx:task ProcessUserOrder -``` - -This creates `app/tasks/process_user_order_task.rb`: - -```ruby -class ProcessUserOrderTask < ApplicationTask - # Define required parameters - # required :param_name, type: :string - - # Define optional parameters - # optional :param_name, type: :string, default: "default_value" - - def call - # Implement your task logic here - # Access parameters via context.param_name - end - - private - - # Add private methods for supporting logic -end -``` - -### Advanced Generation Options - -```bash -# Generate with workflow -rails g cmdx:workflow ProcessOrder - -# Generate with specific namespace -rails g cmdx:task Billing::ProcessPayment -``` - -## Task Lifecycle - -> [!IMPORTANT] -> Understanding the task lifecycle is crucial for proper error handling and debugging. Tasks follow a predictable execution pattern with specific states and status transitions. - -### Lifecycle Stages - -| Stage | Description | State | Possible Statuses | -|-------|-------------|--------|-------------------| -| **Instantiation** | Task object created with context | `:initialized` | `:pending` | -| **Pre-validation** | Before callbacks and middleware run | `:executing` | `:pending` | -| **Validation** | Parameters validated against definitions | `:executing` | `:pending`, `:failure` | -| **Execution** | The `call` method runs business logic | `:executing` | `:pending`, `:halt` | -| **Post-execution** | After callbacks run | `:executing` | `:success`, `:failure` | -| **Completion** | Result finalized with final state | `:executed` | `:success`, `:failure` | -| **Freezing** | Task becomes immutable | `:executed` | Final status | - -### Lifecycle Example - -```ruby -class ExampleTask < CMDx::Task - required :data, type: :string - - before_call :log_start - after_call :log_completion - - def call - # Main logic - context.processed_data = context.data.upcase - end - - private - - def log_start - puts "Task starting with data: #{context.data}" - end - - def log_completion - puts "Task completed: #{context.processed_data}" - end -end - -# Execution trace -result = ExampleTask.call(data: "hello") -# Output: -# Task starting with data: hello -# Task completed: HELLO - -result.state # → :executed -result.status # → :success -``` +| Stage | State | Status | Description | +|-------|-------|--------|-------------| +| **Instantiation** | `initialized` | `success` | Task created with context | +| **Validation** | `executing` | `success`/`failed` | Attributes validated | +| **Execution** | `executing` | `success`/`failed`/`skipped` | `work` method runs | +| **Completion** | `executed` | `success`/`failed`/`skipped` | Result finalized | +| **Freezing** | `executed` | `success`/`failed`/`skipped` | Task becomes immutable | > [!WARNING] -> Tasks are single-use objects. Once executed, they are frozen and cannot be called again. Attempting to call a frozen task will raise an error. - -```ruby -task = ProcessOrderTask.new(order_id: 123) -result1 = task.call # ✓ Works -result2 = task.call # ✗ Raises FrozenError - -# Create new instances for each execution -result1 = ProcessOrderTask.call(order_id: 123) -result2 = ProcessOrderTask.call(order_id: 456) # ✓ Works -``` - -## Error Handling - -> [!NOTE] -> CMDx provides comprehensive error handling with detailed metadata about failures, including parameter validation errors, execution exceptions, and halt conditions. - -### Parameter Validation Errors - -```ruby -class ProcessOrderTask < CMDx::Task - required :order_id, type: :integer - required :amount, type: :float - - def call - # Task logic - end -end - -# Invalid parameters -result = ProcessOrderTask.call( - order_id: "not-a-number", - amount: "invalid" -) - -result.failed? # → true -result.status # → :failure -result.metadata -# { -# reason: "order_id could not coerce into an integer. amount could not coerce into a float.", -# messages: { -# order_id: ["could not coerce into an integer"], -# amount: ["could not coerce into a float"] -# } -# } -``` - -### Runtime Exceptions - -```ruby -class ProcessOrderTask < CMDx::Task - required :order_id, type: :integer - - def call - order = Order.find(context.order_id) # May raise ActiveRecord::RecordNotFound - order.process! - end -end - -# Order not found -result = ProcessOrderTask.call(order_id: 99999) - -result.failed? # → true -result.status # → :failure -result.metadata[:reason] # → "ActiveRecord::RecordNotFound: Couldn't find Order..." -``` +> Tasks are single-use objects. Once executed, they are frozen and cannot be executed again. --- -- **Prev:** [Configuration](../configuration.md) -- **Next:** [Basics - Call](call.md) +- **Prev:** [Getting Started](../getting_started.md) +- **Next:** [Basics - Execution](execution.md) diff --git a/docs/callbacks.md b/docs/callbacks.md index c0b23a73d..ad3875cb8 100644 --- a/docs/callbacks.md +++ b/docs/callbacks.md @@ -1,546 +1,165 @@ # Callbacks -Callbacks provide precise control over task execution lifecycle, running custom logic at specific transition points. Callback callables have access to the same context and result information as the `call` method, enabling rich integration patterns. +Callbacks provide precise control over task execution lifecycle, running custom logic at specific transition points. Callback callables have access to the same context and result information as the `execute` method, enabling rich integration patterns. + +> **Note:** Callbacks execute in the order they are declared within each hook type. Multiple callbacks of the same type execute in declaration order (FIFO: first in, first out). ## Table of Contents -- [TLDR](#tldr) -- [Callback Declaration](#callback-declaration) -- [Callback Classes](#callback-classes) - [Available Callbacks](#available-callbacks) - - [Validation Callbacks](#validation-callbacks) - - [Execution Callbacks](#execution-callbacks) - - [State Callbacks](#state-callbacks) - - [Status Callbacks](#status-callbacks) - - [Outcome Callbacks](#outcome-callbacks) -- [Execution Order](#execution-order) -- [Conditional Execution](#conditional-execution) -- [Error Handling](#error-handling) -- [Callback Inheritance](#callback-inheritance) - -## TLDR - -```ruby -# Method name callbacks -after_validation :verify_order_data -on_success :send_notification - -# Proc/lambda callbacks -on_complete -> { send_telemetry_data } - -# Callback class instances -before_execution LoggingCallback.new(:debug) - -# Conditional execution -on_failed :alert_support, if: :critical_order? -after_execution :cleanup, unless: :preserve_data? - -# Multiple callbacks for same event -on_success :increment_counter, :send_notification -``` - -> [!IMPORTANT] -> Callbacks execute in declaration order (FIFO) and are inherited by subclasses, making them ideal for application-wide patterns. +- [Declarations](#declarations) + - [Symbol References](#symbol-references) + - [Proc or Lambda](#proc-or-lambda) + - [Class or Module](#class-or-module) + - [Conditional Execution](#conditional-execution) +- [Callback Removal](#callback-removal) -## Callback Declaration - -> [!NOTE] -> Callbacks can be declared using method names, procs/lambdas, Callback class instances, or blocks. All forms have access to the task's context and result. - -### Declaration Methods +## Available Callbacks -| Method | Description | Example | -|--------|-------------|---------| -| Method name | References instance method | `on_success :send_email` | -| Proc/Lambda | Inline callable | `on_failed -> { alert_team }` | -| Callback class | Reusable class instance | `before_execution LoggerCallback.new` | -| Block | Inline block | `on_success { increment_counter }` | +Callbacks execute in precise lifecycle order. Here is the complete execution sequence: ```ruby -class ProcessOrderTask < CMDx::Task - # Method name - before_validation :load_order - after_validation :verify_inventory - - # Proc/lambda - on_executing -> { context.start_time = Time.current } - on_complete lambda { Metrics.increment('orders.processed') } - - # Callback class - before_execution AuditCallback.new(action: :process_order) - on_success NotificationCallback.new(channels: [:email, :slack]) - - # Block - on_failed do - ErrorReporter.notify( - error: result.metadata[:error], - order_id: context.order_id, - user_id: context.user_id - ) - end - - # Multiple callbacks - on_success :update_inventory, :send_confirmation, :log_success - - def call - context.order = Order.find(context.order_id) - context.order.process! - end - - private - - def load_order - context.order ||= Order.find(context.order_id) - end - - def verify_inventory - raise "Insufficient inventory" unless context.order.items_available? - end -end +1. before_validation # Pre-validation setup +2. before_execution # Setup and preparation +# Task work executed +3. on_[complete|interrupted] # Based on execution state +4. on_executed # Task finished (any outcome) +5. on_[success|skipped|failed] # Based on execution status +6. on_[good|bad] # Based on outcome classification ``` -## Callback Classes +## Declarations -> [!TIP] -> Create reusable Callback classes for complex logic or cross-cutting concerns. Callback classes inherit from `CMDx::Callback` and implement `call(task, type)`. +### Symbol References -```ruby -class AuditCallback < CMDx::Callback - def initialize(action:, level: :info) - @action = action - @level = level - end - - def call(task, type) - AuditLogger.log( - level: @level, - action: @action, - task: task.class.name, - callback_type: type, - user_id: task.context.current_user&.id, - timestamp: Time.current - ) - end -end +Reference instance methods by symbol for simple callback logic: -class NotificationCallback < CMDx::Callback - def initialize(channels:, template: nil) - @channels = Array(channels) - @template = template - end +```ruby +class ProcessOrder < CMDx::Task + before_execution :find_order - def call(task, type) - return unless should_notify?(type) + # Batch declarations (works for any type) + on_complete :notify_customer, :update_inventory - @channels.each do |channel| - NotificationService.send( - channel: channel, - template: @template || default_template(type), - data: extract_notification_data(task) - ) - end + def work + # Your logic here... end private - def should_notify?(type) - %i[on_success on_failed].include?(type) + def find_order + @order ||= Order.find(context.order_id) end - def default_template(type) - type == :on_success ? :task_success : :task_failure + def notify_customer + CustomerNotifier.call(context.user, result) end - def extract_notification_data(task) - { - task_name: task.class.name, - status: task.result.status, - runtime: task.result.runtime, - context: task.context.to_h.except(:sensitive_data) - } + def update_inventory + InventoryService.update(context.product_ids, result) end end ``` -## Available Callbacks +### Proc or Lambda -### Validation Callbacks - -Execute around parameter validation: - -| Callback | Timing | Description | -|----------|--------|-------------| -| `before_validation` | Before validation | Setup validation context | -| `after_validation` | After successful validation | Post-validation logic | +Use anonymous functions for inline callback logic: ```ruby -class CreateUserTask < CMDx::Task - before_validation :normalize_email - after_validation :check_user_limits - - required :email, type: :string - required :plan, type: :string - - def call - User.create!(email: email, plan: plan) - end - - private - - def normalize_email - context.email = email.downcase.strip - end +class ProcessOrder < CMDx::Task + # Proc + on_interrupted proc { |task| BuildLine.stop! } - def check_user_limits - current_users = User.where(plan: plan).count - plan_limit = Plan.find_by(name: plan).user_limit - - if current_users >= plan_limit - throw(:skip, reason: "Plan user limit reached") - end - end + # Lambda + on_complete -> { BuildLine.resume! } end ``` -### Execution Callbacks +### Class or Module -Execute around task logic: - -| Callback | Timing | Description | -|----------|--------|-------------| -| `before_execution` | Before `call` method | Setup and preparation | -| `after_execution` | After `call` completes | Cleanup and finalization | +Implement reusable callback logic in dedicated classes: ```ruby -class ProcessPaymentTask < CMDx::Task - before_execution :acquire_payment_lock - after_execution :release_payment_lock - - def call - Payment.process!(context.payment_data) +class SendNotificationCallback + def call(task) + if task.result.success? + EmailApi.deliver_success_email(task.context.user) + else + EmailApi.deliver_issue_email(task.context.admin) + end end +end - private +class ProcessOrder < CMDx::Task + # Class or Module + on_success SendNotificationCallback - def acquire_payment_lock - context.lock_key = "payment:#{context.payment_id}" - Redis.current.set(context.lock_key, "locked", ex: 300) - end - - def release_payment_lock - Redis.current.del(context.lock_key) if context.lock_key - end + # Instance + on_interrupted SendNotificationCallback.new end ``` -### State Callbacks - -Execute based on execution state: - -| Callback | Condition | Description | -|----------|-----------|-------------| -| `on_executing` | Task begins running | Track execution start | -| `on_complete` | Task completes successfully | Handle successful completion | -| `on_interrupted` | Task is halted (skip/failure) | Handle interruptions | -| `on_executed` | Task finishes (any outcome) | Post-execution logic | +### Conditional Execution -### Status Callbacks - -Execute based on execution status: - -| Callback | Status | Description | -|----------|--------|-------------| -| `on_success` | Task succeeds | Handle success | -| `on_skipped` | Task is skipped | Handle skips | -| `on_failed` | Task fails | Handle failures | - -### Outcome Callbacks - -Execute based on outcome classification: - -| Callback | Outcomes | Description | -|----------|----------|-------------| -| `on_good` | Success or skipped | Positive outcomes | -| `on_bad` | Failed | Negative outcomes | +Control callback execution with conditional logic: ```ruby -class EmailCampaignTask < CMDx::Task - on_executing -> { Metrics.increment('campaigns.started') } - on_complete :track_completion - on_interrupted :handle_interruption - - on_success :schedule_followup - on_skipped :log_skip_reason - on_failed :alert_marketing_team - - on_good -> { Metrics.increment('campaigns.positive_outcome') } - on_bad :create_incident_ticket - - def call - EmailService.send_campaign(context.campaign_data) - end - - private - - def track_completion - Campaign.find(context.campaign_id).update!( - sent_at: Time.current, - recipient_count: context.recipients.size - ) - end - - def handle_interruption - Campaign.find(context.campaign_id).update!(status: :interrupted) +class AbilityCheck + def call(task) + task.context.user.can?(:send_email) end end -``` -## Execution Order +class ProcessOrder < CMDx::Task + # If and/or Unless + before_execution :notify_customer, if: :email_available?, unless: :email_temporary? -> [!IMPORTANT] -> Callbacks execute in precise lifecycle order. Multiple callbacks of the same type execute in declaration order (FIFO: first in, first out). + # Proc + on_failure :increment_failure, if: ->(task) { Rails.env.production? && task.class.name.include?("Legacy") } -```ruby -1. before_execution # Setup and preparation -2. on_executing # Task begins running -3. before_validation # Pre-validation setup -4. after_validation # Post-validation logic -5. [call method] # Your business logic -6. on_[complete|interrupted] # Based on execution state -7. on_executed # Task finished (any outcome) -8. on_[success|skipped|failed] # Based on execution status -9. on_[good|bad] # Based on outcome classification -10. after_execution # Cleanup and finalization -``` + # Lambda + on_success :ping_warehouse, if: proc { |task| task.context.products_on_backorder? } -## Conditional Execution + # Class or Module + on_complete :send_notification, unless: AbilityCheck -> [!TIP] -> Use `:if` and `:unless` options for conditional callback execution. Conditions can be method names, procs, or strings. + # Instance + on_complete :send_notification, if: AbilityCheck.new -| Option | Description | Example | -|--------|-------------|---------| -| `:if` | Execute if condition is truthy | `if: :production_env?` | -| `:unless` | Execute if condition is falsy | `unless: :maintenance_mode?` | - -```ruby -class ProcessOrderTask < CMDx::Task - # Method name conditions - on_success :send_receipt, if: :email_enabled? - on_failed :retry_payment, unless: :max_retries_reached? - - # Proc conditions - after_execution :log_metrics, if: -> { Rails.env.production? } - on_success :expensive_operation, unless: -> { SystemStatus.overloaded? } - - # String conditions (evaluated as methods) - on_complete :update_analytics, if: "tracking_enabled?" - - # Multiple conditions - on_failed :escalate_to_support, if: :critical_order?, unless: :business_hours? - - # Complex conditional logic - on_success :trigger_automation, if: :automation_conditions_met? - - def call - Order.process!(context.order_data) + def work + # Your logic here... end private - def email_enabled? - context.user.email_notifications? && !context.user.email.blank? + def email_available? + context.user.email.present? end - def max_retries_reached? - context.retry_count >= 3 - end - - def critical_order? - context.order_value > 10_000 || context.priority == :high - end - - def business_hours? - Time.current.hour.between?(9, 17) && Time.current.weekday? - end - - def automation_conditions_met? - context.order_type == :subscription && - context.user.plan.automation_enabled? && - !SystemStatus.maintenance_mode? + def email_temporary? + context.user.email_service == :temporary end end ``` -## Error Handling +## Callback Removal -> [!WARNING] -> Callback errors can interrupt task execution. Use proper error handling and consider callback isolation for non-critical operations. - -### Callback Error Behavior +Remove callbacks at runtime for dynamic behavior control: ```ruby -class ProcessDataTask < CMDx::Task - before_execution :critical_setup # Error stops execution - on_success :send_notification # Error stops callback chain - after_execution :cleanup_resources # Always runs +class ProcessOrder < CMDx::Task + # Symbol + deregister :callback, :before_execution, :notify_customer - def call - ProcessingService.handle(context.data) - end - - private - - def critical_setup - # Critical callback - let errors bubble up - context.processor = ProcessorService.initialize_secure_processor - end - - def send_notification - # Non-critical callback - handle errors gracefully - NotificationService.send(context.notification_data) - rescue NotificationService::Error => e - Rails.logger.warn "Notification failed: #{e.message}" - # Don't re-raise - allow other callbacks to continue - end - - def cleanup_resources - # Cleanup callback - always handle errors - context.processor&.cleanup - rescue => e - Rails.logger.error "Cleanup failed: #{e.message}" - # Log but don't re-raise - end + # Class or Module (no instances) + deregister :callback, :on_complete, SendNotificationCallback end ``` -### Isolating Non-Critical Callbacks - -```ruby -class ResilientCallback < CMDx::Callback - def initialize(callback_proc, isolate: false) - @callback_proc = callback_proc - @isolate = isolate - end - - def call(task, type) - if @isolate - begin - @callback_proc.call(task, type) - rescue => e - Rails.logger.warn "Isolated callback failed: #{e.message}" - end - else - @callback_proc.call(task, type) - end - end -end - -class ProcessOrderTask < CMDx::Task - # Critical callback - before_execution :validate_payment_method - - # Isolated non-critical callback - on_success ResilientCallback.new( - -> (task, type) { AnalyticsService.track_order(task.context.order_id) }, - isolate: true - ) - - def call - Order.process!(context.order_data) - end -end -``` - -## Callback Inheritance - -> [!NOTE] -> Callbacks are inherited from parent classes, enabling application-wide patterns. Child classes can add additional callbacks or override inherited behavior. - -```ruby -class ApplicationTask < CMDx::Task - # Global logging - before_execution :log_task_start - after_execution :log_task_end - - # Global error handling - on_failed :report_failure - - # Global metrics - on_success :track_success_metrics - on_executed :track_execution_metrics - - private - - def log_task_start - Rails.logger.info "Starting #{self.class.name} with context: #{context.to_h.except(:sensitive_data)}" - end - - def log_task_end - Rails.logger.info "Finished #{self.class.name} in #{result.runtime}ms with status: #{result.status}" - end - - def report_failure - ErrorReporter.notify( - task: self.class.name, - error: result.metadata[:reason], - context: context.to_h.except(:sensitive_data), - backtrace: result.metadata[:backtrace] - ) - end - - def track_success_metrics - Metrics.increment("task.#{self.class.name.underscore}.success") - end - - def track_execution_metrics - Metrics.histogram("task.#{self.class.name.underscore}.runtime", result.runtime) - end -end - -class ProcessPaymentTask < ApplicationTask - # Inherits all ApplicationTask callbacks - # Plus payment-specific callbacks - - before_validation :load_payment_method - on_success :send_receipt - on_failed :refund_payment, if: :payment_captured? - - def call - # Inherits global logging, error handling, and metrics - # Plus payment-specific behavior - PaymentProcessor.charge(context.payment_data) - end - - private - - def load_payment_method - context.payment_method = PaymentMethod.find(context.payment_method_id) - end - - def send_receipt - ReceiptService.send( - user: context.user, - payment: context.payment, - template: :payment_success - ) - end - - def payment_captured? - context.payment&.status == :captured - end - - def refund_payment - RefundService.process( - payment: context.payment, - reason: :task_failure, - amount: context.payment.amount - ) - end -end -``` +> [!IMPORTANT] +> Only one removal operation is allowed per `deregister` call. Multiple removals require separate calls. --- -- **Prev:** [Parameters - Defaults](parameters/defaults.md) +- **Prev:** [Attributes - Defaults](attributes/defaults.md) - **Next:** [Middlewares](middlewares.md) diff --git a/docs/configuration.md b/docs/configuration.md deleted file mode 100644 index 63e3486b9..000000000 --- a/docs/configuration.md +++ /dev/null @@ -1,344 +0,0 @@ -# Configuration - -CMDx provides a flexible configuration system that allows customization at both global and task levels. Configuration follows a hierarchy where global settings serve as defaults that can be overridden at the task level. - -## Table of Contents - -- [TLDR](#tldr) -- [Configuration Hierarchy](#configuration-hierarchy) -- [Global Configuration](#global-configuration) - - [Configuration Options](#configuration-options) - - [Global Middlewares](#global-middlewares) - - [Global Callbacks](#global-callbacks) - - [Global Coercions](#global-coercions) - - [Global Validators](#global-validators) -- [Task Settings](#task-settings) - - [Available Task Settings](#available-task-settings) - - [Workflow Configuration](#workflow-configuration) -- [Configuration Management](#configuration-management) - - [Accessing Configuration](#accessing-configuration) - - [Resetting Configuration](#resetting-configuration) -- [Error Handling](#error-handling) - -## TLDR - -```ruby -# Generate configuration file -rails g cmdx:install - -# Global configuration -CMDx.configure do |config| - config.task_halt = ["failed", "skipped"] # Multiple halt statuses - config.logger = Rails.logger # Custom logger - config.middlewares.use TimeoutMiddleware # Global middleware - config.callbacks.register :on_failure, :log # Global callback -end - -# Task-level overrides -class PaymentTask < CMDx::Task - cmd_settings!(task_halt: "failed", tags: ["payments"]) - - def call - halt_on = cmd_setting(:task_halt) # Access settings - end -end -``` - -## Configuration Hierarchy - -CMDx follows a three-tier configuration hierarchy: - -1. **Global Configuration**: Framework-wide defaults -2. **Task Settings**: Class-level overrides via `cmd_settings!` -3. **Runtime Parameters**: Instance-specific overrides during execution - -> [!IMPORTANT] -> Task-level settings take precedence over global configuration. Settings are inherited from superclasses and can be overridden in subclasses. - -## Global Configuration - -Generate a configuration file using the Rails generator: - -```bash -rails g cmdx:install -``` - -This creates `config/initializers/cmdx.rb` with sensible defaults. - -### Configuration Options - -| Option | Type | Default | Description | -|---------------|-----------------------|----------------|-------------| -| `task_halt` | String, Array | `"failed"` | Result statuses that cause `call!` to raise faults | -| `workflow_halt` | String, Array | `"failed"` | Result statuses that halt workflow execution | -| `logger` | Logger | Line formatter | Logger instance for task execution logging | -| `middlewares` | MiddlewareRegistry | Empty registry | Global middleware registry applied to all tasks | -| `callbacks` | CallbackRegistry | Empty registry | Global callback registry applied to all tasks | -| `coercions` | CoercionRegistry | Built-in coercions | Global coercion registry for custom parameter types | -| `validators` | ValidatorRegistry | Built-in validators | Global validator registry for parameter validation | - -### Global Middlewares - -Configure middlewares that automatically apply to all tasks: - -```ruby -CMDx.configure do |config| - # Simple middleware registration - config.middlewares.use CMDx::Middlewares::Timeout - - # Middleware with configuration - config.middlewares.use CMDx::Middlewares::Timeout, seconds: 30 - - # Multiple middlewares - config.middlewares.use AuthenticationMiddleware - config.middlewares.use LoggingMiddleware, level: :debug - config.middlewares.use MetricsMiddleware, namespace: "app.tasks" -end -``` - -> [!NOTE] -> Middlewares are executed in registration order. Each middleware wraps the next, creating an execution chain around task logic. - -### Global Callbacks - -Configure callbacks that automatically apply to all tasks: - -```ruby -CMDx.configure do |config| - # Method callbacks - config.callbacks.register :before_execution, :setup_request_context - config.callbacks.register :after_execution, :cleanup_temp_files - - # Conditional callbacks - config.callbacks.register :on_failure, :notify_admin, if: :production? - config.callbacks.register :on_success, :update_metrics, unless: :test? - - # Proc callbacks with context - config.callbacks.register :on_complete, proc { |task, type| - duration = task.metadata[:runtime] - StatsD.histogram("task.duration", duration, tags: ["class:#{task.class.name}"]) - } -end -``` - -### Global Coercions - -Configure custom coercions for domain-specific types: - -```ruby -CMDx.configure do |config| - # Simple coercion classes - config.coercions.register :money, MoneyCoercion - config.coercions.register :email, EmailCoercion - - # Complex coercions with options - config.coercions.register :csv_array, proc { |value, options| - separator = options[:separator] || ',' - max_items = options[:max_items] || 100 - - items = value.to_s.split(separator).map(&:strip).reject(&:empty?) - items.first(max_items) - } -end -``` - -### Global Validators - -Configure custom validators for parameter validation: - -```ruby -CMDx.configure do |config| - # Validator classes - config.validators.register :email, EmailValidator - config.validators.register :phone, PhoneValidator - - # Proc validators with options - config.validators.register :api_key, proc { |value, options| - required_prefix = options.dig(:api_key, :prefix) || "sk_" - min_length = options.dig(:api_key, :min_length) || 32 - - value.start_with?(required_prefix) && value.length >= min_length - } -end -``` - -## Task Settings - -Override global configuration for specific tasks using `cmd_settings!`: - -```ruby -class ProcessPaymentTask < CMDx::Task - cmd_settings!( - task_halt: ["failed"], # Only halt on failures - tags: ["payments", "critical"], # Logging tags - logger: PaymentLogger.new, # Custom logger - log_level: :info, # Log level override - log_formatter: CMDx::LogFormatters::Json.new # JSON formatting - ) - - def call - # Payment processing logic - charge_customer(amount, payment_method) - end - - private - - def charge_customer(amount, method) - # Implementation details - end -end -``` - -### Available Task Settings - -| Setting | Type | Description | -|-----------------|-----------------------|-------------| -| `task_halt` | String, Array | Result statuses that cause `call!` to raise faults | -| `workflow_halt` | String, Array | Result statuses that halt workflow execution | -| `tags` | Array | Tags automatically appended to logs | -| `logger` | Logger | Custom logger instance | -| `log_level` | Symbol | Log level (`:debug`, `:info`, `:warn`, `:error`, `:fatal`) | -| `log_formatter` | LogFormatter | Custom log formatter | - -> [!TIP] -> Use task-level settings for tasks that require special handling, such as payment processing, external API calls, or critical system operations. - -### Workflow Configuration - -Configure halt behavior and logging for workflows: - -```ruby -class OrderProcessingWorkflow < CMDx::Workflow - # Halt on any non-success status - cmd_settings!( - workflow_halt: ["failed", "skipped"], - tags: ["orders", "e-commerce"], - log_level: :info - ) - - process ValidateOrderTask - process ChargePaymentTask - process UpdateInventoryTask - process SendConfirmationTask -end - -class DataMigrationWorkflow < CMDx::Workflow - # Continue on skipped tasks, halt only on failures - cmd_settings!( - workflow_halt: "failed", - tags: ["migration", "maintenance"] - ) - - process BackupDataTask - process MigrateUsersTask - process MigrateOrdersTask - process ValidateDataTask -end -``` - -## Configuration Management - -### Accessing Configuration - -```ruby -# Global configuration access -CMDx.configuration.logger #=> -CMDx.configuration.task_halt #=> "failed" -CMDx.configuration.middlewares.middlewares #=> [, ...] -CMDx.configuration.callbacks.callbacks #=> {before_execution: [...], ...} - -# Task-specific settings -class DataProcessingTask < CMDx::Task - cmd_settings!( - tags: ["data", "analytics"], - task_halt: ["failed", "skipped"] - ) - - def call - # Access current task settings - log_tags = cmd_setting(:tags) #=> ["data", "analytics"] - halt_on = cmd_setting(:task_halt) #=> ["failed", "skipped"] - logger_instance = cmd_setting(:logger) #=> Inherited from global - end -end -``` - -### Resetting Configuration - -> [!WARNING] -> Resetting configuration affects the entire application. Use primarily in test environments or during application initialization. - -```ruby -# Reset to framework defaults -CMDx.reset_configuration! - -# Verify reset -CMDx.configuration.task_halt #=> "failed" (default) -CMDx.configuration.middlewares #=> Empty registry -CMDx.configuration.callbacks #=> Empty registry - -# Commonly used in test setup -RSpec.configure do |config| - config.before(:each) do - CMDx.reset_configuration! - end -end -``` - -## Error Handling - -### Configuration Validation - -```ruby -# Invalid configuration types -CMDx.configure do |config| - config.task_halt = :invalid_type # Error: must be String or Array - config.logger = "not_a_logger" # Error: must respond to logging methods -end -``` - -### Missing Settings Access - -```ruby -class ExampleTask < CMDx::Task - def call - # Accessing non-existent setting - value = cmd_setting(:non_existent_setting) #=> nil (returns nil for undefined) - - # Check if setting exists - if cmd_setting(:custom_timeout) - timeout = cmd_setting(:custom_timeout) - else - timeout = 30 # fallback - end - end -end -``` - -### Configuration Conflicts - -```ruby -# Parent class configuration -class BaseTask < CMDx::Task - cmd_settings!(task_halt: "failed", tags: ["base"]) -end - -# Child class inherits and overrides -class SpecialTask < BaseTask - cmd_settings!(task_halt: ["failed", "skipped"]) # Overrides parent - # tags: ["base"] inherited from parent - - def call - halt_statuses = cmd_setting(:task_halt) #=> ["failed", "skipped"] - inherited_tags = cmd_setting(:tags) #=> ["base"] - end -end -``` - -> [!IMPORTANT] -> Settings inheritance follows Ruby's method resolution order. Child class settings always override parent class settings for the same key. - ---- - -- **Prev:** [Getting Started](getting_started.md) -- **Next:** [Basics - Setup](basics/setup.md) diff --git a/docs/deprecation.md b/docs/deprecation.md index f44a4e872..402bc8acf 100644 --- a/docs/deprecation.md +++ b/docs/deprecation.md @@ -4,242 +4,159 @@ Task deprecation provides a systematic approach to managing legacy tasks in CMDx ## Table of Contents -- [TLDR](#tldr) -- [Deprecation Fundamentals](#deprecation-fundamentals) -- [Deprecation Modes](#deprecation-modes) -- [Configuration Examples](#configuration-examples) -- [Migration Strategies](#migration-strategies) -- [Error Handling](#error-handling) -- [Best Practices](#best-practices) +- [Modes](#modes) + - [Raise](#raise) + - [Log](#log) + - [Warn](#warn) +- [Declarations](#declarations) + - [Symbol or String](#symbol-or-string) + - [Boolean or Nil](#boolean-or-nil) + - [Method](#method) + - [Proc or Lambda](#proc-or-lambda) + - [Class or Module](#class-or-module) -## TLDR +## Modes -```ruby -# Prevent task execution completely -class LegacyTask < CMDx::Task - cmd_setting!(deprecated: :error) -end - -# Log deprecation warnings -class OldTask < CMDx::Task - cmd_setting!(deprecated: :log) -end - -# Issue Ruby warnings -class ObsoleteTask < CMDx::Task - cmd_setting!(deprecated: :warning) -end - -# Usage triggers appropriate deprecation handling -LegacyTask.call # → raises DeprecationError -OldTask.call # → logs warning via task.logger -ObsoleteTask.call # → issues Ruby warning -``` - -## Deprecation Fundamentals +### Raise -> [!NOTE] -> Task deprecation is configured using the `cmd_setting!` declaration and processed automatically by CMDx before task execution. The deprecation system integrates seamlessly with existing logging and error handling infrastructure. - -### How It Works - -1. **Configuration**: Tasks declare deprecation mode using `cmd_setting!(deprecated: mode)` -2. **Processing**: CMDx automatically calls `TaskDeprecator.call(task)` during task lifecycle -3. **Action**: Appropriate deprecation handling occurs based on configured mode -4. **Execution**: Task proceeds normally (unless `:error` mode prevents it) - -### Available Modes - -| Mode | Behavior | Use Case | -|------|----------|----------| -| `:error` | Raises `DeprecationError` | Hard deprecation, prevent execution | -| `:log` | Logs warning via `task.logger.warn` | Soft deprecation, track usage | -| `:warning` | Issues Ruby warning | Development alerts | -| `true` | Same as `:log` | Legacy boolean support | -| `nil/false` | No deprecation handling | Default behavior | - -## Deprecation Modes - -### Error Mode (Hard Deprecation) - -> [!WARNING] -> Error mode prevents task execution entirely. Use this for tasks that should no longer be used under any circumstances. +`:raise` mode prevents task execution entirely. Use this for tasks that should no longer be used under any circumstances. ```ruby -class ProcessLegacyPaymentTask < CMDx::Task - cmd_setting!(deprecated: :error) +class ProcessLegacyPayment < CMDx::Task + settings(deprecated: :raise) - def call - # This code will never execute - charge_customer(amount) + def work + # Will never execute... end end -# Attempting to use deprecated task -result = ProcessLegacyPaymentTask.call(amount: 100) -# → raises CMDx::DeprecationError: "ProcessLegacyPaymentTask usage prohibited" +result = ProcessLegacyPayment.execute +#=> raises CMDx::DeprecationError: "ProcessLegacyPayment usage prohibited" ``` -### Log Mode (Soft Deprecation) +> [!WARNING] +> Use `:raise` mode carefully in production environments as it will break existing workflows immediately. -> [!TIP] -> Log mode allows continued usage while tracking deprecation warnings. Perfect for gradual migration scenarios where immediate replacement isn't feasible. +### Log + +`:log` mode allows continued usage while tracking deprecation warnings. Perfect for gradual migration scenarios where immediate replacement isn't feasible. ```ruby -class ProcessOldPaymentTask < CMDx::Task - cmd_setting!(deprecated: :log) +class ProcessOldPayment < CMDx::Task + settings(deprecated: :log) + + # Same + settings(deprecated: true) - def call - # Task executes normally but logs deprecation warning - charge_customer(amount) + def work + # Executes but logs deprecation warning... end end -# Task executes with logged warning -result = ProcessOldPaymentTask.call(amount: 100) -result.successful? # → true +result = ProcessOldPayment.execute +result.successful? #=> true -# Check logs for deprecation warning: -# WARN -- : DEPRECATED: migrate to replacement or discontinue use +# Deprecation warning appears in logs: +# WARN -- : DEPRECATED: ProcessOldPayment - migrate to replacement or discontinue use ``` -### Warning Mode (Development Alerts) +### Warn -> [!NOTE] -> Warning mode issues Ruby warnings visible in development and testing environments. Useful for alerting developers without affecting production logging. +`:warn` mode issues Ruby warnings visible in development and testing environments. Useful for alerting developers without affecting production logging. ```ruby -class ProcessObsoletePaymentTask < CMDx::Task - cmd_setting!(deprecated: :warning) +class ProcessObsoletePayment < CMDx::Task + settings(deprecated: :warn) - def call - # Task executes with Ruby warning - charge_customer(amount) + def work + # Executes but emits Ruby warning... end end -# Task executes with Ruby warning -result = ProcessObsoletePaymentTask.call(amount: 100) -# stderr: [ProcessObsoletePaymentTask] DEPRECATED: migrate to replacement or discontinue use +result = ProcessObsoletePayment.execute +result.successful? #=> true -result.successful? # → true +# Ruby warning appears in stderr: +# [ProcessObsoletePayment] DEPRECATED: migrate to replacement or discontinue use ``` -## Configuration Examples +## Declarations -### Environment-Specific Deprecation +### Symbol or String ```ruby -class ExperimentalFeatureTask < CMDx::Task - # Different deprecation behavior per environment - cmd_setting!( - deprecated: Rails.env.production? ? :error : :warning - ) - - def call - enable_experimental_feature - end +class LegacyIntegration < CMDx::Task + # Symbol + settings(deprecated: :raise) + + # String + settings(deprecated: "warn") end ``` -### Conditional Deprecation +### Boolean or Nil ```ruby -class LegacyIntegrationTask < CMDx::Task - # Deprecate only for specific conditions - cmd_setting!( - deprecated: -> { ENV['NEW_API_ENABLED'] == 'true' ? :log : nil } - ) - - def call - call_legacy_api - end +class LegacyIntegration < CMDx::Task + # Deprecates with default :log mode + settings(deprecated: true) + + # Skips deprecation + settings(deprecated: false) + settings(deprecated: nil) end ``` -## Migration Strategies +### Method -> [!IMPORTANT] -> When deprecating tasks, always provide clear migration paths and replacement implementations to minimize disruption. +```ruby +class LegacyIntegration < CMDx::Task + # Symbol + settings(deprecated: :deprecated?) -### Graceful Fallback + def work + # Your logic here... + end -```ruby -class NotificationTask < CMDx::Task - cmd_setting!(deprecated: :log) - - def call - # Provide fallback while encouraging migration - logger.warn "Consider migrating to NotificationServiceV2" - - # Delegate to new service but maintain compatibility - NotificationServiceV2.send_notification( - recipient: recipient, - message: message, - delivery_method: :legacy - ) + private + + def deprecated? + Time.now.year > 2020 ? :raise : false end end ``` -## Error Handling - -### Catching Deprecation Errors +### Proc or Lambda ```ruby -begin - result = LegacyTask.call(params) -rescue CMDx::DeprecationError => e - # Handle deprecation gracefully - Rails.logger.error "Attempted to use deprecated task: #{e.message}" - - # Use replacement task instead - result = ReplacementTask.call(params) -end +class LegacyIntegration < CMDx::Task + # Proc + settings(deprecated: proc { Rails.env.local? ? :raise : :log }) -if result.successful? - # Process successful result -else - # Handle task failure + # Lambda + settings(deprecated: -> { Current.user.legacy? ? :warn : :raise }) end ``` -## Best Practices - -### Documentation and Communication - -> [!TIP] -> Always document deprecation reasons, timelines, and migration paths. Clear communication prevents confusion and reduces support burden. +### Class or Module ```ruby -class LegacyReportTask < CMDx::Task - # Document deprecation clearly - cmd_setting!(deprecated: :log) - - # Class-level documentation - # @deprecated Use ReportGeneratorV2Task instead - # @see ReportGeneratorV2Task - # @note This task will be removed in v2.0.0 - # @since 1.5.0 marked as deprecated - - def call - # Add inline documentation - logger.warn <<~DEPRECATION - LegacyReportTask is deprecated and will be removed in v2.0.0. - Please migrate to ReportGeneratorV2Task which provides: - - Better performance - - Enhanced error handling - - More flexible output formats - - Migration guide: https://docs.example.com/migration/reports - DEPRECATION - - generate_legacy_report +class LegacyTaskDeprecator + def call(task) + task.class.name.include?("Legacy") end end + +class LegacyIntegration < CMDx::Task + # Class or Module + settings(deprecated: LegacyTaskDeprecator) + + # Instance + settings(deprecated: LegacyTaskDeprecator.new) +end ``` --- -- **Prev:** [Testing](testing.md) -- **Next:** [AI Prompts](ai_prompts.md) +- **Prev:** [Internationalization (i18n)](internationalization.md) +- **Next:** [Workflows](workflows.md) diff --git a/docs/getting_started.md b/docs/getting_started.md index 7e5265f61..ea4242841 100644 --- a/docs/getting_started.md +++ b/docs/getting_started.md @@ -1,47 +1,25 @@ # Getting Started -CMDx is a Ruby framework for building maintainable, observable business logic through composable command objects. Design robust workflows with automatic parameter validation, structured error handling, comprehensive logging, and intelligent execution flow control that scales from simple tasks to complex multi-step processes. +CMDx is a Ruby framework for building maintainable, observable business logic through composable command objects. Design robust workflows with automatic attribute validation, structured error handling, comprehensive logging, and intelligent execution flow control. ## Table of Contents -- [TLDR](#tldr) - [Installation](#installation) -- [Quick Setup](#quick-setup) -- [Execution](#execution) -- [Result Handling](#result-handling) -- [Exception Handling](#exception-handling) -- [Building Workflows](#building-workflows) -- [Code Generation](#code-generation) - -## TLDR - -```ruby -# Installation -gem 'cmdx' # Add to Gemfile -rails g cmdx:install # Generate config - -# Basic task -class ProcessOrderTask < CMDx::Task - required :order_id, type: :integer - optional :send_email, type: :boolean, default: true - - def call - context.order = Order.find(order_id) - fail!("Order canceled") if context.order.canceled? - skip!("Already processed") if context.order.completed? - - context.order.update!(status: 'completed') - end -end - -# Execution -result = ProcessOrderTask.call(order_id: 123) # Returns Result -result = ProcessOrderTask.call!(order_id: 123) # Raises on failure - -# Check outcomes -result.success? && result.context.order # Access data -result.failed? && result.metadata[:reason] # Error details -``` +- [Configuration Hierarchy](#configuration-hierarchy) +- [Global Configuration](#global-configuration) + - [Breakpoints](#breakpoints) + - [Logging](#logging) + - [Middlewares](#middlewares) + - [Callbacks](#callbacks) + - [Coercions](#coercions) + - [Validators](#validators) +- [Task Configuration](#task-configuration) + - [Settings](#settings) + - [Registrations](#registrations) +- [Configuration Management](#configuration-management) + - [Access](#access) + - [Resetting](#resetting) +- [Task Generator](#task-generator) ## Installation @@ -57,236 +35,273 @@ For Rails applications, generate the configuration: rails generate cmdx:install ``` -> [!NOTE] -> This creates `config/initializers/cmdx.rb` with default settings for logging, error handling, and middleware configuration. +This creates `config/initializers/cmdx.rb` file. -## Quick Setup +## Configuration Hierarchy -> [!TIP] -> Use **present tense verbs** for task names: `ProcessOrderTask`, `SendEmailTask`, `ValidatePaymentTask` +CMDx follows a two-tier configuration hierarchy: -```ruby -class ProcessOrderTask < CMDx::Task - required :order_id, type: :integer - optional :send_email, type: :boolean, default: true - - def call - context.order = Order.find(order_id) - - if context.order.canceled? - fail!(reason: "Order canceled", canceled_at: context.order.canceled_at) - elsif context.order.completed? - skip!(reason: "Already processed") - else - context.order.update!(status: 'completed', completed_at: Time.now) - EmailService.send_confirmation(context.order) if send_email - end - end -end -``` +1. **Global Configuration**: Framework-wide defaults +2. **Task Settings**: Class-level overrides via `settings` -### Parameter Definition +> [!IMPORTANT] +> Task-level settings take precedence over global configuration. +> Settings are inherited from superclasses and can be overridden in subclasses. -Parameters provide automatic type coercion and validation: +## Global Configuration -```ruby -class CreateUserTask < CMDx::Task - required :email, type: :string - required :age, type: :integer - required :active, type: :boolean, default: true - - optional :metadata, type: :hash, default: {} - optional :tags, type: :array, default: [] - - def call - context.user = User.create!( - email: email, - age: age, - active: active, - metadata: metadata, - tags: tags - ) - end -end -``` +Global configuration settings apply to all tasks inherited from `CMDx::Task`. +Globally these settings are initialized with sensible defaults. -## Execution +### Breakpoints -Execute tasks using class methods that return result objects or raise exceptions: +Breakpoints control when `execute!` raises faults. ```ruby -# Safe execution - returns Result object -result = ProcessOrderTask.call(order_id: 123) - -# Exception-based execution - raises on failure/skip -result = ProcessOrderTask.call!(order_id: 123, send_email: false) +CMDx.configure do |config| + config.task_breakpoints = "skipped" + config.workflow_breakpoints = ["skipped", "failed"] +end ``` -> [!IMPORTANT] -> Use `call` for conditional logic based on results, and `call!` for exception-based control flow where failures should halt execution. +### Logging -### Input Coercion +```ruby +CMDx.configure do |config| + config.logger = CustomLogger.new($stdout) +end +``` -Parameters automatically coerce string inputs to specified types: +### Middlewares ```ruby -# String inputs automatically converted -ProcessOrderTask.call( - order_id: "123", # → 123 (Integer) - send_email: "false" # → false (Boolean) -) +CMDx.configure do |config| + # Via callable (must respond to `call(task, options)`) + config.middlewares.register CMDx::Middlewares::Timeout + + # Via proc or lambda + config.middlewares.register proc { |task, options| + start = Time.now + result = yield + finish = Time.now + Rails.logger.debug { "task complete in #{finish - start}ms" } + result + } + + # With options + config.middlewares.register MetricsMiddleware, namespace: "app.tasks" + + # Remove middleware + config.middlewares.deregister CMDx::Middlewares::Timeout +end ``` -## Result Handling +> [!NOTE] +> Middlewares are executed in registration order. Each middleware wraps the next, +> creating an execution chain around task logic. -Results provide comprehensive execution information including status, context data, and metadata: +### Callbacks ```ruby -result = ProcessOrderTask.call(order_id: 123) +CMDx.configure do |config| + # Via method + config.callbacks.register :before_execution, :setup_request_context -case result.status -when 'success' - order = result.context.order - redirect_to order_path(order), notice: "Order processed successfully!" + # Via callable (must respond to `call(task)`) + config.callbacks.register :on_success, TrackSuccessfulPurchase -when 'skipped' - reason = result.metadata[:reason] - redirect_to order_path(123), notice: "Skipped: #{reason}" + # Via proc or lambda + config.callbacks.register :on_complete, proc { |task| + duration = task.metadata[:runtime] + StatsD.histogram("task.duration", duration, tags: ["class:#{task.class.name}"]) + } -when 'failed' - error_details = result.metadata[:reason] - redirect_to orders_path, alert: "Processing failed: #{error_details}" -end + # With options + config.callbacks.register :on_failure, :notify_admin, if: :production? -# Access execution metadata -puts "Runtime: #{result.runtime}ms" -puts "Task ID: #{result.id}" -puts "Executed at: #{result.executed_at}" + # Remove callback + config.callbacks.deregister :on_success, TrackSuccessfulPurchase +end ``` -### Result Properties +### Coercions -| Property | Description | Example | -|----------|-------------|---------| -| `status` | Execution outcome | `'success'`, `'failed'`, `'skipped'` | -| `context` | Shared data object | `result.context.order` | -| `metadata` | Additional details | `result.metadata[:reason]` | -| `runtime` | Execution time (ms) | `result.runtime` | -| `id` | Unique task execution ID | `result.id` | +```ruby +CMDx.configure do |config| + # Via callable (must respond to `call(value, options)`) + config.coercions.register :money, MoneyCoercion -## Exception Handling + # Via method (must match signature `def point_coercion(value, options)`) + config.coercions.register :point, :point_coercion -> [!WARNING] -> `call!` raises exceptions for failed or skipped tasks. Use this pattern when failures should halt program execution. + # Via proc or lambda + config.coercions.register :csv_array, proc { |value, options| + separator = options[:separator] || ',' + max_items = options[:max_items] || 100 + + items = value.to_s.split(separator).map(&:strip).reject(&:empty?) + items.first(max_items) + } + + # Remove coercion + config.coercions.deregister :money +end +``` + +### Validators ```ruby -begin - result = ProcessOrderTask.call!(order_id: 123) - redirect_to order_path(result.context.order), notice: "Order processed!" +CMDx.configure do |config| + # Via callable (must respond to `call(value, options)`) + config.validators.register :email, EmailValidator -rescue CMDx::Skipped => e - reason = e.result.metadata[:reason] - redirect_to orders_path, notice: "Skipped: #{reason}" + # Via method (must match signature `def phone_validator(value, options)`) + config.validators.register :phone, :phone_validator -rescue CMDx::Failed => e - error_details = e.result.metadata[:reason] - redirect_to order_path(123), alert: "Failed: #{error_details}" + # Via proc or lambda + config.validators.register :api_key, proc { |value, options| + required_prefix = options[:prefix] || "sk_" + min_length = options[:min_length] || 32 -rescue ActiveRecord::RecordNotFound - redirect_to orders_path, alert: "Order not found" + value.start_with?(required_prefix) && value.length >= min_length + } + + # Remove validator + config.validators.deregister :email end ``` -### Exception Types - -- **`CMDx::Skipped`** - Task was skipped intentionally -- **`CMDx::Failed`** - Task failed due to business logic or validation errors -- **Standard exceptions** - Ruby/Rails exceptions (e.g., `ActiveRecord::RecordNotFound`) +## Task Configuration -## Building Workflows +### Settings -> [!TIP] -> Workflows orchestrate multiple tasks with automatic context sharing, error handling, and execution flow control. +Override global configuration for specific tasks using `settings`: ```ruby -class OrderProcessingWorkflow < CMDx::Workflow - required :order_id, type: :integer - optional :priority, type: :string, default: 'standard' +class ProcessPayment < CMDx::Task + settings( + # Global configuration overrides + task_breakpoints: ["failed"], # Breakpoint override + workflow_breakpoints: [], # Breakpoint override + logger: CustomLogger.new($stdout), # Custom logger + + # Task configuration settings + breakpoints: ["failed"], # Contextual pointer for :task_breakpoints and :workflow_breakpoints + log_level: :info, # Log level override + log_formatter: CMDx::LogFormatters::Json.new # Log formatter override + tags: ["payments", "critical"], # Logging tags + deprecated: true # Task deprecations + ) + + def work + # Your logic here... + end +end +``` - before_execution :log_workflow_start - on_failed :notify_support - on_skipped :log_skip_reason +> [!TIP] +> Use task-level settings for tasks that require special handling, such as payment processing, +> external API calls, or critical system operations. - process ValidateOrderTask - process ChargePaymentTask - process UpdateInventoryTask - process SendConfirmationTask, if: proc { context.payment_successful? } - process ExpressShippingTask, if: proc { priority == 'express' } +### Registrations - private +Register middlewares, callbacks, coercions, and validators on a specific task. +Deregister options that should not be available. - def log_workflow_start - Rails.logger.info "Starting order processing for order #{order_id}" +```ruby +class ProcessPayment < CMDx::Task + # Middlewares + register :middleware, CMDx::Middlewares::Timeout + deregister :middleware, MetricsMiddleware + + # Callbacks + register :callback, :on_complete, proc { |task| + duration = task.metadata[:runtime] + StatsD.histogram("task.duration", duration, tags: ["class:#{task.class.name}"]) + } + deregister :callback, :before_execution, :setup_request_context + + # Coercions + register :coercion, :money, MoneyCoercion + deregister :coercion, :point + + # Validators + register :validator, :email, :email_validator + deregister :validator, :phone + + def work + # Your logic here... end +end +``` - def notify_support - SupportNotifier.alert("Order workflow failed", - order_id: order_id, - error: result.metadata[:reason] - ) - end +## Configuration Management + +### Access - def log_skip_reason - Rails.logger.warn "Workflow skipped: #{result.metadata[:reason]}" +```ruby +# Global configuration access +CMDx.configuration.logger #=> +CMDx.configuration.task_breakpoints #=> ["failed"] +CMDx.configuration.middlewares.registry #=> [, ...] + +# Task configuration access +class AnalyzeData < CMDx::Task + settings(tags: ["data", "analytics"]) + + def work + self.class.settings[:logger] #=> Global configuration value + self.class.settings[:tags] #=> Task configuration value => ["data", "analytics"] end end - -# Execute workflow -result = OrderProcessingWorkflow.call(order_id: 123, priority: 'express') ``` -### Workflow Features - -- **Automatic context sharing** - Tasks access shared `context` object -- **Conditional execution** - Use `:if` conditions for optional tasks -- **Lifecycle callbacks** - Hook into workflow execution phases -- **Error propagation** - Failed tasks halt workflow execution -- **Skip handling** - Graceful handling of skipped tasks +### Resetting -## Code Generation - -Generate properly structured tasks and workflows: +> [!WARNING] +> Resetting configuration affects the entire application. Use primarily in +> test environments or during application initialization. ```ruby -# Generate individual task -rails generate cmdx:task ProcessOrder -# Creates: app/cmds/process_order_task.rb +# Reset to framework defaults +CMDx.reset_configuration! -# Generate workflow -rails generate cmdx:workflow OrderProcessing -# Creates: app/cmds/order_processing_workflow.rb +# Verify reset +CMDx.configuration.task_breakpoints #=> ["failed"] (default) +CMDx.configuration.middlewares.registry #=> Empty registry -# Generate with parameters -rails generate cmdx:task CreateUser email:string age:integer active:boolean +# Commonly used in test setup (RSpec example) +RSpec.configure do |config| + config.before(:each) do + CMDx.reset_configuration! + end +end ``` -> [!NOTE] -> Generators automatically handle naming conventions and inherit from `ApplicationTask`/`ApplicationWorkflow` when available. Generated files include parameter definitions and basic structure. +## Task Generator -### Generated Task Structure +Generate new CMDx tasks quickly using the built-in generator: -```ruby -# app/cmds/process_order_task.rb -class ProcessOrderTask < ApplicationTask - required :order_id, type: :integer +```bash +rails generate cmdx:task ProcessOrder +``` - def call - # Task implementation +This creates a new task file with the basic structure: + +```ruby +# app/tasks/process_order.rb +class ProcessOrder < CMDx::Task + def work + # Your logic here... end end ``` +> [!TIP] +> Use **present tense verbs + noun** for task names, eg: +> `ProcessOrder`, `SendWelcomeEmail`, `ValidatePaymentDetails` + --- -- **Next:** [Configuration](configuration.md) -- **See also:** [Parameters - Coercions](parameters/coercions.md) | [Workflows](workflows.md) +- **Prev:** [Tips and Tricks](tips_and_tricks.md) +- **Next:** [Basics - Setup](basics/setup.md) diff --git a/docs/internationalization.md b/docs/internationalization.md index a53c3a826..67d828dbc 100644 --- a/docs/internationalization.md +++ b/docs/internationalization.md @@ -1,668 +1,33 @@ # Internationalization (i18n) -CMDx provides comprehensive internationalization support for all error messages, parameter validation failures, coercion errors, and fault messages. All user-facing text is automatically localized based on the current `I18n.locale`, ensuring your applications can serve global audiences with native-language error reporting. +CMDx provides comprehensive internationalization support for all error messages, attribute validation failures, coercion errors, and fault messages. All user-facing text is automatically localized based on the current `I18n.locale`, ensuring your applications can serve global audiences with native-language error reporting. ## Table of Contents -- [TLDR](#tldr) -- [Available Locales](#available-locales) -- [Configuration](#configuration) -- [Fault Messages](#fault-messages) -- [Parameter Messages](#parameter-messages) -- [Coercion Messages](#coercion-messages) -- [Validation Messages](#validation-messages) -- [Custom Message Overrides](#custom-message-overrides) -- [Error Handling and Debugging](#error-handling-and-debugging) +- [Localization](#localization) +- [I18n](#i18n) -## TLDR - -```ruby -# Automatic localization based on I18n.locale -I18n.locale = :es -result = CreateUserTask.call(email: "invalid", age: "too-young") -result.metadata[:messages][:email] #=> ["formato inválido"] - -# 24 built-in languages with complete coverage -# Parameter-specific overrides available -# Covers coercion, validation, and fault messages -``` +## Localization > [!NOTE] -> CMDx automatically localizes all error messages based on your application's `I18n.locale` setting. No additional configuration is required for basic usage. - -## Available Locales - -CMDx includes built-in translations for 24 major world languages, covering both Western and Eastern language families: - -| Language | Locale | Language | Locale | Language | Locale | -|----------|--------|----------|--------|----------|--------| -| English | `:en` | Russian | `:ru` | Arabic | `:ar` | -| Spanish | `:es` | Korean | `:ko` | Dutch | `:nl` | -| French | `:fr` | Hindi | `:hi` | Swedish | `:sv` | -| German | `:de` | Polish | `:pl` | Norwegian | `:no` | -| Portuguese | `:pt` | Turkish | `:tr` | Finnish | `:fi` | -| Italian | `:it` | Danish | `:da` | Greek | `:el` | -| Japanese | `:ja` | Czech | `:cs` | Hebrew | `:he` | -| Chinese | `:zh` | Thai | `:th` | Vietnamese | `:vi` | - -> [!TIP] -> All locales provide complete coverage for every error message type, including complex nested parameter validation errors and multi-type coercion failures. - -## Configuration - -### Basic Setup +> CMDx automatically localizes all error messages based on the `I18n.locale` setting. ```ruby -# In Rails applications (config/application.rb) -config.i18n.default_locale = :en -config.i18n.available_locales = [:en, :es, :fr, :de] +class ProcessOrder < CMDx::Task + attribute :amount, type: :float -# Runtime locale switching -class ApiController < ApplicationController - before_action :set_locale - - private - - def set_locale - I18n.locale = params[:locale] || request.headers['Accept-Language']&.scan(/^[a-z]{2}/)&.first || :en + def work + # Your logic here... end end -``` -### Per-Request Localization - -```ruby -class ProcessOrderTask < CMDx::Task - required :amount, type: :float - required :customer_email, format: { with: /@/ } - - def call - # Task logic runs with current I18n.locale - ChargeCustomer.call(amount: amount, email: customer_email) - end -end - -# Different locales produce localized errors I18n.with_locale(:fr) do - result = ProcessOrderTask.call(amount: "invalid", customer_email: "bad-email") + result = ProcessOrder.execute(amount: "invalid") result.metadata[:messages][:amount] #=> ["impossible de contraindre en float"] end ``` -## Fault Messages - -> [!IMPORTANT] -> Fault messages from `fail!` and `skip!` methods are automatically localized when no explicit reason is provided. - -### Default Fault Localization - -```ruby -class ProcessPaymentTask < CMDx::Task - required :payment_method, inclusion: { in: %w[card paypal bank] } - required :amount, type: :float - - def call - if payment_declined? - fail! # Uses localized default message - end - - if amount < minimum_charge - skip! # Uses localized default message - end - - charge_payment - end - - private - - def payment_declined? - # Payment gateway logic - rand > 0.8 - end - - def minimum_charge - 5.00 - end -end - -# English -I18n.locale = :en -result = ProcessPaymentTask.call(payment_method: "card", amount: 99.99) -result.metadata[:reason] #=> "no reason given" - -# Spanish -I18n.locale = :es -result = ProcessPaymentTask.call(payment_method: "card", amount: 99.99) -result.metadata[:reason] #=> "no se proporcionó razón" - -# Japanese -I18n.locale = :ja -result = ProcessPaymentTask.call(payment_method: "card", amount: 99.99) -result.metadata[:reason] #=> "理由が提供されませんでした" -``` - -### Custom Fault Messages - -```ruby -class ProcessRefundTask < CMDx::Task - required :order_id, type: :integer - required :reason, presence: true - - def call - order = find_order(order_id) - - # Custom messages override locale defaults - fail!("Payment gateway unavailable") if gateway_down? - skip!("Refund already processed") if order.refunded? - - process_refund(order) - end -end -``` - -## Parameter Messages - -> [!WARNING] -> Parameter errors include both missing required parameters and undefined source method delegates, with full internationalization support. - -### Required Parameter Errors - -```ruby -class CreateUserAccountTask < CMDx::Task - required :email, format: { with: /@/ } - required :password, length: { min: 8 } - required :age, type: :integer, numeric: { min: 18 } - - optional :profile_image, source: :nonexistent_upload_method - optional :referral_code, source: :missing_referral_source - - def call - User.create!(email: email, password: password, age: age) - end -end - -# Missing required parameters -I18n.locale = :en -result = CreateUserAccountTask.call({}) -result.metadata[:messages] -# { -# email: ["is a required parameter"], -# password: ["is a required parameter"], -# age: ["is a required parameter"] -# } - -# German localization -I18n.locale = :de -result = CreateUserAccountTask.call({}) -result.metadata[:messages] -# { -# email: ["ist ein erforderlicher Parameter"], -# password: ["ist ein erforderlicher Parameter"], -# age: ["ist ein erforderlicher Parameter"] -# } -``` - -### Source Method Errors - -```ruby -# Undefined source method delegation -I18n.locale = :en -result = CreateUserAccountTask.call( - email: "user@example.com", - password: "securepass", - age: 25 -) -result.metadata[:messages] -# { -# profile_image: ["delegates to undefined method nonexistent_upload_method"], -# referral_code: ["delegates to undefined method missing_referral_source"] -# } - -# French localization -I18n.locale = :fr -result = CreateUserAccountTask.call( - email: "user@example.com", - password: "securepass", - age: 25 -) -result.metadata[:messages] -# { -# profile_image: ["délègue à la méthode non définie nonexistent_upload_method"], -# referral_code: ["délègue à la méthode non définie missing_referral_source"] -# } -``` - -## Coercion Messages - -> [!NOTE] -> Type conversion failures provide detailed, localized error messages that specify the attempted type(s) and input value context. - -### Single Type Coercion Errors - -```ruby -class ProcessInventoryTask < CMDx::Task - required :product_id, type: :integer - required :price, type: :float - required :in_stock, type: :boolean - required :categories, type: :array - required :metadata, type: :hash - - def call - # Task implementation - end -end - -# English coercion errors -I18n.locale = :en -result = ProcessInventoryTask.call( - product_id: "not-a-number", - price: "invalid-price", - in_stock: "maybe", - categories: "[invalid json", - metadata: "not-a-hash" -) - -result.metadata[:messages] -# { -# product_id: ["could not coerce into an integer"], -# price: ["could not coerce into a float"], -# in_stock: ["could not coerce into a boolean"], -# categories: ["could not coerce into an array"], -# metadata: ["could not coerce into a hash"] -# } - -# Spanish coercion errors -I18n.locale = :es -result = ProcessInventoryTask.call( - product_id: "not-a-number", - price: "invalid-price" -) - -result.metadata[:messages] -# { -# product_id: ["no se pudo coaccionar a un integer"], -# price: ["no se pudo coaccionar a un float"] -# } -``` - -### Multiple Type Coercion Errors - -```ruby -class ProcessFlexibleDataTask < CMDx::Task - required :amount, type: [:float, :big_decimal, :integer] - required :identifier, type: [:integer, :string] - required :timestamp, type: [:datetime, :date, :time] - - def call - # Task implementation - end -end - -# Multiple type failure messages -I18n.locale = :en -result = ProcessFlexibleDataTask.call( - amount: "definitely-not-numeric", - identifier: nil, - timestamp: "not-a-date" -) - -result.metadata[:messages] -# { -# amount: ["could not coerce into one of: float, big_decimal, integer"], -# identifier: ["could not coerce into one of: integer, string"], -# timestamp: ["could not coerce into one of: datetime, date, time"] -# } - -# Chinese localization -I18n.locale = :zh -result = ProcessFlexibleDataTask.call(amount: "invalid") -result.metadata[:messages][:amount] #=> ["无法强制转换为以下类型之一:float、big_decimal、integer"] -``` - -### Nested Parameter Coercion - -```ruby -class ProcessOrderTask < CMDx::Task - required :order, type: :hash do - required :id, type: :integer - required :total, type: :float - - required :customer, type: :hash do - required :id, type: :integer - required :active, type: :boolean - end - end - - def call - # Task implementation - end -end - -# Nested coercion errors with full path context -result = ProcessOrderTask.call( - order: { - id: "not-a-number", - total: "invalid-amount", - customer: { - id: "bad-id", - active: "maybe" - } - } -) - -result.metadata[:messages] -# { -# "order.id": ["could not coerce into an integer"], -# "order.total": ["could not coerce into a float"], -# "order.customer.id": ["could not coerce into an integer"], -# "order.customer.active": ["could not coerce into a boolean"] -# } -``` - -## Validation Messages - -> [!TIP] -> All built-in validators provide comprehensive internationalization support, including contextual information for complex validation rules. - -### Format Validation - -```ruby -class CreateUserTask < CMDx::Task - required :email, format: { with: /@/, message: nil } # Use default i18n - required :phone, format: { with: /\A\+?[\d\s-()]+\z/ } - required :username, format: { with: /\A[a-zA-Z0-9_]+\z/ } - - def call - User.create!(email: email, phone: phone, username: username) - end -end - -# English format errors -I18n.locale = :en -result = CreateUserTask.call( - email: "not-an-email", - phone: "invalid!phone", - username: "bad@username" -) - -result.metadata[:messages] -# { -# email: ["is an invalid format"], -# phone: ["is an invalid format"], -# username: ["is an invalid format"] -# } - -# Japanese format errors -I18n.locale = :ja -result = CreateUserTask.call(email: "invalid", phone: "bad") -result.metadata[:messages] -# { -# email: ["無効な形式です"], -# phone: ["無効な形式です"] -# } -``` - -### Numeric Validation - -```ruby -class ConfigureServiceTask < CMDx::Task - required :port, numeric: { min: 1024, max: 65535 } - required :timeout, numeric: { greater_than: 0, less_than: 300 } - required :retry_count, numeric: { min: 1, max: 10 } - - def call - # Service configuration - end -end - -# English numeric errors -I18n.locale = :en -result = ConfigureServiceTask.call( - port: 80, # Below minimum - timeout: 500, # Above maximum - retry_count: 0 # Below minimum -) - -result.metadata[:messages] -# { -# port: ["must be greater than or equal to 1024"], -# timeout: ["must be less than 300"], -# retry_count: ["must be greater than or equal to 1"] -# } - -# German numeric errors -I18n.locale = :de -result = ConfigureServiceTask.call(port: 80, timeout: 500) -result.metadata[:messages] -# { -# port: ["muss größer oder gleich 1024 sein"], -# timeout: ["muss kleiner als 300 sein"] -# } -``` - -### Inclusion and Exclusion - -```ruby -class ProcessSubscriptionTask < CMDx::Task - required :plan, inclusion: { in: %w[basic premium enterprise] } - required :billing_cycle, inclusion: { in: %w[monthly yearly] } - required :username, exclusion: { from: %w[admin root system] } - - def call - # Subscription processing - end -end - -# English inclusion/exclusion errors -I18n.locale = :en -result = ProcessSubscriptionTask.call( - plan: "invalid-plan", - billing_cycle: "weekly", - username: "admin" -) - -result.metadata[:messages] -# { -# plan: ["is not included in the list"], -# billing_cycle: ["is not included in the list"], -# username: ["is reserved"] -# } - -# French inclusion/exclusion errors -I18n.locale = :fr -result = ProcessSubscriptionTask.call(plan: "invalid", username: "root") -result.metadata[:messages] -# { -# plan: ["n'est pas inclus dans la liste"], -# username: ["est réservé"] -# } -``` - -### Length Validation - -```ruby -class CreatePostTask < CMDx::Task - required :title, length: { min: 5, max: 100 } - required :content, length: { min: 50 } - required :tags, length: { max: 10 } - - def call - Post.create!(title: title, content: content, tags: tags) - end -end - -# English length errors -I18n.locale = :en -result = CreatePostTask.call( - title: "Hi", # Too short - content: "Brief content", # Too short - tags: (1..15).to_a # Too many -) - -result.metadata[:messages] -# { -# title: ["is too short (minimum is 5 characters)"], -# content: ["is too short (minimum is 50 characters)"], -# tags: ["is too long (maximum is 10 characters)"] -# } - -# Russian length errors -I18n.locale = :ru -result = CreatePostTask.call(title: "Hi", content: "Short") -result.metadata[:messages] -# { -# title: ["слишком короткий (минимум 5 символов)"], -# content: ["слишком короткий (минимум 50 символов)"] -# } -``` - -## Custom Message Overrides - -> [!IMPORTANT] -> Parameter-specific custom messages always take precedence over locale defaults, allowing fine-grained control while maintaining i18n support. - -### Override Examples - -```ruby -class RegisterAccountTask < CMDx::Task - required :email, - format: { with: /@/, message: "Please provide a valid email address" } - - required :password, - length: { min: 8, message: "Password must be at least 8 characters" } - - required :age, - numeric: { min: 18, message: "You must be 18 or older to register" } - - def call - # Custom messages override i18n, regardless of locale - end -end - -# Custom messages ignore locale settings -I18n.locale = :es -result = RegisterAccountTask.call( - email: "invalid", - password: "short", - age: 16 -) - -result.metadata[:messages] -# { -# email: ["Please provide a valid email address"], # Custom override -# password: ["Password must be at least 8 characters"], # Custom override -# age: ["You must be 18 or older to register"] # Custom override -# } -``` - -### Conditional Overrides - -```ruby -class ProcessPaymentTask < CMDx::Task - required :amount, type: :float, numeric: { min: 0.01 } - - # Conditional message based on context - def validate_amount - if context[:currency] == "USD" && amount < 0.50 - add_error(:amount, "USD payments must be at least $0.50") - elsif context[:currency] == "EUR" && amount < 0.01 - add_error(:amount, "EUR payments must be at least €0.01") - end - end - - def call - validate_amount - # Payment processing - end -end -``` - -## Error Handling and Debugging - -> [!WARNING] -> When debugging i18n issues, check locale availability, fallback behavior, and message key resolution to identify configuration problems. - -### Debugging Missing Translations - -```ruby -# Enable translation debugging -I18n.exception_handler = lambda do |exception, locale, key, options| - Rails.logger.warn "Missing translation: #{locale}.#{key}" - "translation missing: #{locale}.#{key}" -end - -class DebuggingTask < CMDx::Task - required :test_param, type: :integer - - def call - # Intentionally trigger coercion error for debugging - end -end - -# Test with unsupported locale -I18n.locale = :unsupported_locale -result = DebuggingTask.call(test_param: "invalid") -# Logs: "Missing translation: unsupported_locale.cmdx.errors.coercion.integer" -``` - -### Fallback Configuration - -```ruby -# Configure fallback behavior in Rails -I18n.fallbacks = { es: [:es, :en], fr: [:fr, :en] } - -class TestLocalizationTask < CMDx::Task - required :value, type: :integer - - def call - # Task logic - end -end - -# Test fallback behavior -I18n.locale = :es # Falls back to :en if Spanish translation missing -result = TestLocalizationTask.call(value: "invalid") -# Uses English if Spanish translation unavailable -``` - -### Error Message Analysis - -```ruby -class AnalyzeErrorsTask < CMDx::Task - required :data, type: :hash do - required :id, type: :integer - required :nested, type: :hash do - required :value, type: :float - end - end - - def call - # Complex nested structure for testing - end -end - -# Comprehensive error analysis -result = AnalyzeErrorsTask.call( - data: { - id: "not-integer", - nested: { - value: "not-float" - } - } -) - -# Analyze error structure -puts "Failed: #{result.failed?}" -puts "Error count: #{result.metadata[:messages].count}" -puts "Nested errors present: #{result.metadata[:messages].keys.any? { |k| k.include?('.') }}" - -result.metadata[:messages].each do |param, errors| - puts "#{param}: #{errors.join(', ')}" -end -# Output shows full parameter path context for nested errors -``` - --- - **Prev:** [Logging](logging.md) -- **Next:** [Testing](testing.md) +- **Next:** [Deprecation](deprecation.md) diff --git a/docs/interruptions/exceptions.md b/docs/interruptions/exceptions.md index e9ae27c4e..971d794fe 100644 --- a/docs/interruptions/exceptions.md +++ b/docs/interruptions/exceptions.md @@ -1,232 +1,52 @@ # Interruptions - Exceptions -CMDx provides robust exception handling that differs between the `call` and `call!` methods. Understanding how unhandled exceptions are processed is crucial for building reliable task execution flows and implementing proper error handling strategies. +CMDx provides robust exception handling that differs between the `execute` and `execute!` methods. Understanding how unhandled exceptions are processed is crucial for building reliable task execution flows and implementing proper error handling strategies. ## Table of Contents -- [TLDR](#tldr) -- [Exception Handling Methods](#exception-handling-methods) -- [Exception Metadata](#exception-metadata) -- [Bang Call Behavior](#bang-call-behavior) -- [Exception Classification](#exception-classification) -- [Error Handling Patterns](#error-handling-patterns) +- [Exception Handling](#exception-handling) + - [Non-bang execution](#non-bang-execution) + - [Bang execution](#bang-execution) -## TLDR +## Exception Handling -```ruby -# Non-bang call - captures ALL exceptions -result = ProcessOrderTask.call # Never raises, always returns result -result.failed? # true if exception occurred -result.metadata[:original_exception] # Access original exception - -# Bang call - lets exceptions propagate -ProcessOrderTask.call! # Raises exceptions (except configured faults) - -# Exception info always available in metadata -result.metadata[:reason] # Human-readable error message -result.metadata[:original_exception] # Original exception object -``` - -## Exception Handling Methods +### Non-bang execution -> [!IMPORTANT] -> The key difference: `call` guarantees a result object, while `call!` allows exceptions to propagate for standard error handling patterns. - -### Non-bang Call (`call`) - -The `call` method captures **all** unhandled exceptions and converts them to failed results, ensuring predictable behavior and consistent result processing. - -| Behavior | Description | -|----------|-------------| -| **Exception Capture** | All exceptions caught and converted | -| **Return Value** | Always returns a result object | -| **State** | `"interrupted"` for exception failures | -| **Status** | `"failed"` for all captured exceptions | -| **Metadata** | Exception details preserved | +The `execute` method captures **all** unhandled exceptions and converts them to failed results, ensuring predictable behavior and consistent result processing. ```ruby -class ProcessPaymentTask < CMDx::Task - def call - raise ActiveRecord::RecordNotFound, "Payment method not found" +class ProcessPayment < CMDx::Task + def work + raise UnknownPaymentMethod, "unsupported payment method" end end -result = ProcessPaymentTask.call +result = ProcessPayment.execute result.state #=> "interrupted" result.status #=> "failed" result.failed? #=> true +result.reason #=> "[UnknownPaymentMethod] unsupported payment method" +result.cause #=> ``` -### Bang Call (`call!`) +### Bang execution -The `call!` method allows unhandled exceptions to propagate, enabling standard Ruby exception handling while respecting CMDx fault configuration. +The `execute!` method allows unhandled exceptions to propagate, enabling standard Ruby exception handling while respecting CMDx fault configuration. ```ruby -class ProcessPaymentTask < CMDx::Task - def call - raise StandardError, "Payment gateway unavailable" +class ProcessPayment < CMDx::Task + def work + raise UnknownPaymentMethod, "unsupported payment method" end end begin - ProcessPaymentTask.call! -rescue StandardError => e + ProcessPayment.execute! +rescue UnknownPaymentMethod => e puts "Handle exception: #{e.message}" end ``` -## Exception Metadata - -> [!NOTE] -> Exception information is preserved in result metadata, providing full debugging context while maintaining clean result interfaces. - -### Metadata Structure - -```ruby -result = ProcessPaymentTask.call - -# Exception metadata always includes: -result.metadata[:reason] #=> "[StandardError] Payment gateway unavailable" -result.metadata[:original_exception] #=> - -# Access original exception properties -exception = result.metadata[:original_exception] -exception.class #=> StandardError -exception.message #=> "Payment gateway unavailable" -exception.backtrace #=> ["lib/tasks/payment.rb:15:in `call'", ...] -``` - -### Exception Type Checking - -```ruby -class DatabaseTask < CMDx::Task - def call - raise ActiveRecord::ConnectionNotEstablished, "Database unavailable" - end -end - -result = DatabaseTask.call - -if result.failed? && result.metadata[:original_exception] - case result.metadata[:original_exception] - when ActiveRecord::ConnectionNotEstablished - retry_with_fallback_database - when Net::TimeoutError - retry_with_increased_timeout - when StandardError - log_and_alert_administrators - end -end -``` - -## Bang Call Behavior - -> [!WARNING] -> `call!` propagates exceptions immediately, bypassing result object creation. Only use when you need direct exception handling or integration with exception-based error handling systems. - -### Fault vs Exception Handling - -CMDx faults receive special treatment based on `task_halt` configuration: - -```ruby -class ProcessOrderTask < CMDx::Task - cmd_settings!(task_halt: [CMDx::Result::FAILED]) - - def call - if context.payment_invalid - fail!(reason: "Invalid payment method") # CMDx fault - else - raise StandardError, "System error" # Regular exception - end - end -end - -# Fault behavior (converted to exception due to task_halt) -begin - ProcessOrderTask.call!(payment_invalid: true) -rescue CMDx::Failed => e - puts "Controlled fault: #{e.message}" -end - -# Exception behavior (propagates normally) -begin - ProcessOrderTask.call!(payment_invalid: false) -rescue StandardError => e - puts "System exception: #{e.message}" -end -``` - -## Exception Classification - -### Protected Exceptions - -> [!IMPORTANT] -> CMDx framework exceptions always propagate regardless of call method, ensuring framework integrity and proper error reporting. - -Certain exceptions are never converted to failed results: - -```ruby -class InvalidTask < CMDx::Task - # Intentionally not implementing call method -end - -# Framework exceptions always propagate -begin - InvalidTask.call # Even non-bang call propagates framework exceptions -rescue CMDx::UndefinedCallError => e - puts "Framework exception: #{e.message}" -end -``` - -### Exception Hierarchy - -| Exception Type | `call` Behavior | `call!` Behavior | -|----------------|-----------------|------------------| -| **CMDx Framework** | Propagates | Propagates | -| **CMDx Faults** | Converts to result | Respects `task_halt` config | -| **Standard Exceptions** | Converts to result | Propagates | -| **Custom Exceptions** | Converts to result | Propagates | - -## Error Handling Patterns - -### Graceful Degradation - -```ruby -class ProcessUserDataTask < CMDx::Task - def call - user_data = fetch_user_data - process_data(user_data) - end - - private - - def fetch_user_data - # May raise various exceptions - external_api.get_user_data(context.user_id) - end -end - -# Handle with graceful degradation -result = ProcessUserDataTask.call(user_id: 12345) - -if result.failed? - case result.metadata[:original_exception] - when Net::TimeoutError - # Retry with cached data - fallback_processor.process_cached_data(user_id) - when JSON::ParserError - # Handle malformed response - error_reporter.log_api_format_error - else - # Generic error handling - notify_administrators(result.metadata[:reason]) - end -end -``` - -> [!TIP] -> Use `call` for workflow processing where you need guaranteed result objects, and `call!` for direct integration with existing exception-based error handling patterns. - --- - **Prev:** [Interruptions - Faults](faults.md) diff --git a/docs/interruptions/faults.md b/docs/interruptions/faults.md index 72e271862..f03fa0b1b 100644 --- a/docs/interruptions/faults.md +++ b/docs/interruptions/faults.md @@ -1,113 +1,72 @@ # Interruptions - Faults -Faults are exception mechanisms that halt task execution via `skip!` and `fail!` methods. When tasks execute with the `call!` method, fault exceptions matching the task's interruption status are raised, enabling sophisticated exception handling and control flow patterns. +Faults are exception mechanisms that halt task execution via `skip!` and `fail!` methods. When tasks execute with the `execute!` method, fault exceptions matching the task's interruption status are raised, enabling sophisticated exception handling and control flow patterns. ## Table of Contents -- [TLDR](#tldr) - [Fault Types](#fault-types) -- [Exception Handling](#exception-handling) -- [Fault Context Access](#fault-context-access) +- [Fault Handling](#fault-handling) +- [Data Access](#data-access) - [Advanced Matching](#advanced-matching) + - [Task-Specific Matching](#task-specific-matching) + - [Custom Logic Matching](#custom-logic-matching) - [Fault Propagation](#fault-propagation) + - [Basic Propagation](#basic-propagation) + - [Additional Metadata](#additional-metadata) - [Chain Analysis](#chain-analysis) -- [Configuration](#configuration) - -## TLDR - -```ruby -# Basic exception handling -begin - PaymentProcessor.call!(amount: 100) -rescue CMDx::Skipped => e - handle_skipped_payment(e.result.metadata[:reason]) -rescue CMDx::Failed => e - handle_failed_payment(e.result.metadata[:error]) -rescue CMDx::Fault => e - handle_any_interruption(e) -end - -# Advanced matching -rescue CMDx::Failed.for?(PaymentProcessor, CardValidator) => e -rescue CMDx::Fault.matches? { |f| f.context.amount > 1000 } => e - -# Fault propagation -throw!(validation_result) if validation_result.failed? -``` ## Fault Types | Type | Triggered By | Use Case | |------|--------------|----------| -| `CMDx::Skipped` | `skip!` method | Optional processing, early returns | -| `CMDx::Failed` | `fail!` method | Validation errors, processing failures | | `CMDx::Fault` | Base class | Catch-all for any interruption | +| `CMDx::SkipFault` | `skip!` method | Optional processing, early returns | +| `CMDx::FailFault` | `fail!` method | Validation errors, processing failures | > [!NOTE] > All fault exceptions inherit from `CMDx::Fault` and provide access to the complete task execution context including result, task, context, and chain information. -## Exception Handling - -### Basic Rescue Patterns +## Fault Handling ```ruby begin - ProcessOrderTask.call!(order_id: 123) -rescue CMDx::Skipped => e + ProcessOrder.execute!(order_id: 123) +rescue CMDx::SkipFault => e logger.info "Order processing skipped: #{e.message}" schedule_retry(e.context.order_id) -rescue CMDx::Failed => e +rescue CMDx::FailFault => e logger.error "Order processing failed: #{e.message}" - notify_customer(e.context.customer_email, e.result.metadata[:error]) + notify_customer(e.context.customer_email, e.result.metadata[:code]) rescue CMDx::Fault => e logger.warn "Order processing interrupted: #{e.message}" rollback_transaction end ``` -### Error-Specific Handling +## Data Access -```ruby -begin - PaymentProcessor.call!(card_token: token, amount: amount) -rescue CMDx::Failed => e - case e.result.metadata[:error_code] - when "INSUFFICIENT_FUNDS" - suggest_different_payment_method - when "CARD_DECLINED" - request_card_verification - when "NETWORK_ERROR" - retry_payment_later - else - escalate_to_support(e) - end -end -``` - -## Fault Context Access - -Faults provide comprehensive access to execution context: +Faults provide comprehensive access to execution context, eg: ```ruby begin - UserRegistration.call!(email: email, password: password) + UserRegistration.execute!(email: email, password: password) rescue CMDx::Fault => e # Result information - e.result.status #=> "failed" or "skipped" - e.result.metadata[:reason] #=> "Email already exists" - e.result.runtime #=> 0.05 + e.result.state #=> "interrupted" + e.result.status #=> "failed" or "skipped" + e.result.reason #=> "Email already exists" # Task information - e.task.class.name #=> "UserRegistration" - e.task.id #=> "abc123..." + e.task.class #=> + e.task.id #=> "abc123..." # Context data - e.context.email #=> "user@example.com" - e.context.password #=> "[FILTERED]" + e.context.email #=> "user@example.com" + e.context.password #=> "[FILTERED]" - # Chain information (for workflows) - e.chain&.id #=> "def456..." - e.chain&.results&.size #=> 3 + # Chain information + e.chain.id #=> "def456..." + e.chain.size #=> 3 end ``` @@ -115,16 +74,15 @@ end ### Task-Specific Matching -> [!TIP] -> Use `for?` to handle faults only from specific task classes, enabling targeted exception handling in complex workflows. +Use `for?` to handle faults only from specific task classes, enabling targeted exception handling in complex workflows. ```ruby begin - PaymentWorkflow.call!(payment_data: data) -rescue CMDx::Failed.for?(CardValidator, PaymentProcessor) => e + PaymentWorkflow.execute!(payment_data: data) +rescue CMDx::FailFault.for?(CardValidator, PaymentProcessor) => e # Handle only payment-related failures retry_with_backup_method(e.context) -rescue CMDx::Skipped.for?(FraudCheck, RiskAssessment) => e +rescue CMDx::SkipFault.for?(FraudCheck, RiskAssessment) => e # Handle security-related skips flag_for_manual_review(e.context.transaction_id) end @@ -134,10 +92,10 @@ end ```ruby begin - OrderProcessor.call!(order: order_data) + OrderProcessor.execute!(order: order_data) rescue CMDx::Fault.matches? { |f| f.context.order_value > 1000 } => e escalate_high_value_failure(e) -rescue CMDx::Failed.matches? { |f| f.result.metadata[:retry_count] > 3 } => e +rescue CMDx::FailFault.matches? { |f| f.result.metadata[:retry_count] > 3 } => e abandon_processing(e) rescue CMDx::Fault.matches? { |f| f.result.metadata[:error_type] == "timeout" } => e increase_timeout_and_retry(e) @@ -146,20 +104,23 @@ end ## Fault Propagation -> [!IMPORTANT] -> Use `throw!` to propagate failures while preserving fault context and maintaining the error chain for debugging. +Use `throw!` to propagate failures while preserving fault context and maintaining the error chain for debugging. ### Basic Propagation ```ruby class OrderProcessor < CMDx::Task - def call - # Validate order data - validation_result = OrderValidator.call(context) - throw!(validation_result) if validation_result.failed? + def work + # Validate order + validation_result = OrderValidator.execute(context) + throw!(validation_result) # Skipped or Failed + + # Check inventory + check_inventory = CheckInventory.execute(context) + throw!(check_inventory) if check_inventory.skipped? # Process payment - payment_result = PaymentProcessor.call(context) + payment_result = PaymentProcessor.execute(context) throw!(payment_result) if payment_result.failed? # Continue processing @@ -168,12 +129,12 @@ class OrderProcessor < CMDx::Task end ``` -### Propagation with Context +### Additional Metadata ```ruby class WorkflowProcessor < CMDx::Task - def call - step_result = DataValidation.call(context) + def work + step_result = DataValidation.execute(context) if step_result.failed? throw!(step_result, { @@ -190,18 +151,17 @@ end ## Chain Analysis -> [!NOTE] -> Results provide methods to analyze fault propagation and identify original failure sources in complex execution chains. +Results provide methods to analyze fault propagation and identify original failure sources in complex execution chains. ```ruby -result = PaymentWorkflow.call(invalid_data) +result = PaymentWorkflow.execute(invalid_data) if result.failed? # Trace the original failure original = result.caused_failure if original puts "Original failure: #{original.task.class.name}" - puts "Reason: #{original.metadata[:reason]}" + puts "Reason: #{original.reason}" end # Find what propagated the failure @@ -220,46 +180,6 @@ if result.failed? end ``` -## Configuration - -### Task Halt Settings - -Control which statuses raise exceptions using `task_halt`: - -```ruby -class DataProcessor < CMDx::Task - # Only failures raise exceptions - cmd_settings!(task_halt: [CMDx::Result::FAILED]) - - def call - skip!(reason: "No data to process") if data.empty? - # Skip will NOT raise exception on call! - end -end - -class CriticalValidator < CMDx::Task - # Both failures and skips raise exceptions - cmd_settings!(task_halt: [CMDx::Result::FAILED, CMDx::Result::SKIPPED]) - - def call - skip!(reason: "Validation bypassed") if bypass_mode? - # Skip WILL raise exception on call! - end -end -``` - -> [!WARNING] -> Task halt configuration only affects the `call!` method. The `call` method always captures exceptions and converts them to result objects regardless of halt settings. - -### Global Configuration - -```ruby -# Configure default halt behavior -CMDx.configure do |config| - config.task_halt = [CMDx::Result::FAILED] # Default: only failures halt -end -``` - --- - **Prev:** [Interruptions - Halt](halt.md) diff --git a/docs/interruptions/halt.md b/docs/interruptions/halt.md index 3c10cbaed..835bd6f71 100644 --- a/docs/interruptions/halt.md +++ b/docs/interruptions/halt.md @@ -1,148 +1,110 @@ # Interruptions - Halt -Halting stops execution of a task with explicit intent signaling. Tasks provide two primary halt methods that control execution flow and result in different outcomes, each serving specific use cases in business logic. +Halting stops task execution with explicit intent signaling. Tasks provide two primary halt methods that control execution flow and result in different outcomes. ## Table of Contents -- [TLDR](#tldr) -- [Skip (`skip!`)](#skip-skip) -- [Fail (`fail!`)](#fail-fail) +- [Skipping](#skipping) +- [Failing](#failing) - [Metadata Enrichment](#metadata-enrichment) - [State Transitions](#state-transitions) -- [Exception Behavior](#exception-behavior) -- [Error Handling](#error-handling) -- [The Reason Key](#the-reason-key) +- [Execution Behavior](#execution-behavior) + - [Non-bang execution](#non-bang-execution) + - [Bang execution](#bang-execution) +- [Best Practices](#best-practices) -## TLDR +## Skipping -```ruby -# Skip when task shouldn't execute (not an error) -skip!(reason: "Order already processed") - -# Fail when task encounters error condition -fail!(reason: "Insufficient funds", error_code: "PAYMENT_DECLINED") - -# With structured metadata -skip!( - reason: "User inactive", - user_id: 123, - last_active: "2023-01-01" -) - -# Exception behavior with call vs call! -result = Task.call(params) # Returns result object -Task.call!(params) # Raises CMDx::Skipped/Failed on halt -``` - -## Skip (`skip!`) - -> [!NOTE] -> Use `skip!` when a task cannot or should not execute under current conditions, but this is not an error. Skipped tasks are considered successful outcomes. - -The `skip!` method indicates that a task did not meet the criteria to continue execution. This represents a controlled, intentional interruption where the task determines that execution is not necessary or appropriate. - -### Basic Usage +The `skip!` method indicates a task did not meet criteria to continue execution. This represents a controlled, intentional interruption where the task determines that execution is not necessary or appropriate. ```ruby -class ProcessOrderTask < CMDx::Task - required :order_id, type: :integer - - def call - context.order = Order.find(order_id) +class ProcessOrder < CMDx::Task + def work + # Without a reason + skip! if Array(ENV["PHASED_OUT_TASKS"]).include?(self.class.name) - # Skip if order already processed - skip!(reason: "Order already processed") if context.order.processed? + # With a reason + skip!("Outside business hours") unless Time.now.hour.between?(9, 17) - # Skip if prerequisites not met - skip!(reason: "Payment method required") unless context.order.payment_method + order = Order.find(context.order_id) - # Continue with business logic - context.order.process! + if order.processed? + skip!("Order already processed") + else + order.process! + end end end -``` - -### Common Skip Scenarios -| Scenario | Example | -|----------|---------| -| **Already processed** | `skip!(reason: "User already verified")` | -| **Prerequisites missing** | `skip!(reason: "Required documents not uploaded")` | -| **Business rules** | `skip!(reason: "Outside business hours")` | -| **State conditions** | `skip!(reason: "Account suspended")` | +result = ProcessSubscription.execute(user_id: 123) -## Fail (`fail!`) +# Executed +result.status #=> "skipped" -> [!IMPORTANT] -> Use `fail!` when a task encounters an error that prevents successful completion. Failed tasks represent error conditions that need to be handled or corrected. +# Without a reason +result.reason #=> "no reason given" -The `fail!` method indicates that a task encountered an error condition that prevents successful completion. This represents controlled failure where the task explicitly determines that execution cannot continue. - -### Basic Usage - -```ruby -class ProcessPaymentTask < CMDx::Task - required :payment_id, type: :integer +# With a reason +result.reason #=> "Outside business hours" +``` - def call - context.payment = Payment.find(payment_id) +> [!NOTE] +> Skipping is not a failure or error. Skipped tasks are considered successful outcomes. - # Fail on validation errors - fail!(reason: "Payment amount must be positive") unless context.payment.amount > 0 +## Failing - # Fail on business rule violations - fail!(reason: "Insufficient funds", code: "INSUFFICIENT_FUNDS") unless sufficient_funds? +The `fail!` method indicates a task encountered an error condition that prevents successful completion. This represents controlled failure where the task explicitly determines that execution cannot continue. - # Continue with processing - charge_payment +```ruby +class ProcessPayment < CMDx::Task + def work + # Without a reason + skip! if Array(ENV["PHASED_OUT_TASKS"]).include?(self.class.name) + + payment = Payment.find(context.payment_id) + + # With a reason + if payment.unsupported_type? + fail!("Unsupported payment type") + elsif !payment.amount.positive? + fail!("Payment amount must be positive") + else + payment.charge! + end end +end - private +result = ProcessSubscription.execute(user_id: 123) - def sufficient_funds? - context.payment.account.balance >= context.payment.amount - end -end -``` +# Executed +result.status #=> "failed" -### Common Fail Scenarios +# Without a reason +result.reason #=> "no reason given" -| Scenario | Example | -|----------|---------| -| **Validation errors** | `fail!(reason: "Invalid email format")` | -| **Business rule violations** | `fail!(reason: "Credit limit exceeded")` | -| **External service errors** | `fail!(reason: "Payment gateway unavailable")` | -| **Data integrity issues** | `fail!(reason: "Duplicate transaction detected")` | +# With a reason +result.reason #=> "Unsupported payment type" +``` ## Metadata Enrichment -Both halt methods accept metadata to provide context about the interruption. Metadata is stored as a hash and becomes available through the result object. - -### Structured Metadata +Both halt methods accept metadata to provide additional context about the interruption. Metadata is stored as a hash and becomes available through the result object. ```ruby -class ProcessSubscriptionTask < CMDx::Task - required :user_id, type: :integer - - def call - context.user = User.find(user_id) - - if context.user.subscription_expired? - skip!( - reason: "Subscription expired", - user_id: context.user.id, - expired_at: context.user.subscription_expires_at, - plan_type: context.user.subscription_plan, - grace_period_ends: context.user.subscription_expires_at + 7.days - ) +class ProcessSubscription < CMDx::Task + def work + user = User.find(context.user_id) + + if user.subscription_expired? + # Without metadata + skip!("Subscription expired") end - unless context.user.payment_method_valid? + unless user.payment_method_valid? + # With metadata fail!( - reason: "Invalid payment method", - user_id: context.user.id, - payment_method_id: context.user.payment_method&.id, - error_code: "PAYMENT_METHOD_INVALID", + "Invalid payment method", + error_code: "PAYMENT_METHOD.INVALID", retry_after: Time.current + 1.hour ) end @@ -150,35 +112,30 @@ class ProcessSubscriptionTask < CMDx::Task process_subscription end end -``` - -### Accessing Metadata -```ruby -result = ProcessSubscriptionTask.call(user_id: 123) +result = ProcessSubscription.execute(user_id: 123) -# Check result status -result.skipped? #=> true -result.failed? #=> false +# Without metadata +result.metadata #=> {} -# Access metadata -result.metadata[:reason] #=> "Subscription expired" -result.metadata[:user_id] #=> 123 -result.metadata[:expired_at] #=> 2023-01-01 10:00:00 UTC -result.metadata[:grace_period_ends] #=> 2023-01-08 10:00:00 UTC +# With metadata +result.metadata #=> { + # error_code: "PAYMENT_METHOD.INVALID", + # retry_after: