diff --git a/.cursor/prompts/llms.md b/.cursor/prompts/llms.md new file mode 100644 index 000000000..5f9ff563b --- /dev/null +++ b/.cursor/prompts/llms.md @@ -0,0 +1,20 @@ +Process the following instructions in the order given: + +1. Create an `LLM.md` file +2. Append all files within `docs/**/*.md` into @LLM.md + 2a. Use order outlined in the table of contents of @README.md + 2b. Process one file at a time faster performance and improved accuracy + 2c. Remove the table of contents from the chunk + 2c. Remove the navigations below `---` from the chunk + 2d. Wrap the chunk the files GitHub url the top and a spacer at the bottom like so: + ``` + + --- + url: https://github.com/drexed/cmdx/blob/main/docs/callbacks.md + --- + + {{ chunk }} + + --- + + ``` diff --git a/CHANGELOG.md b/CHANGELOG.md index a6b913ce8..7668b491b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,19 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [TODO] -### Required -- Create `cmdx-i18n` gem with all locales -- Create `cmdx-rspec` gem with RSpec matchers -- Create `cmdx-minitest` gem with Minitest matchers - -### Optional -- Create `cmdx-jobs` gem with background job integration -- Create `cmdx-parallel` gem with parallel workflow task execution - ## [1.5.0] - 2025-08-20 ### Changes -- BREAKING Rebuild CMDx to be simpler, less magical, and more performant +- !BREAKING! Revamp CMDx for clarity, transparency, and higher performance ## [1.1.2] - 2025-07-20 diff --git a/LLM.md b/LLM.md new file mode 100644 index 000000000..16bf3cc4f --- /dev/null +++ b/LLM.md @@ -0,0 +1,3283 @@ +# CMDx Documentation + +This file contains all documentation from the CMDx project, organized for easy consumption by LLMs and other tools. + +--- + +url: https://github.com/drexed/cmdx/blob/main/docs/getting_started.md +--- + +# Getting Started + +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. + +## Installation + +Add CMDx to your Gemfile: + +```ruby +gem 'cmdx' +``` + +For Rails applications, generate the configuration: + +```bash +rails generate cmdx:install +``` + +This creates `config/initializers/cmdx.rb` file. + +## Configuration Hierarchy + +CMDx follows a two-tier configuration hierarchy: + +1. **Global Configuration**: Framework-wide defaults +2. **Task Settings**: Class-level overrides via `settings` + +> [!IMPORTANT] +> Task-level settings take precedence over global configuration. +> Settings are inherited from superclasses and can be overridden in subclasses. + +## Global Configuration + +Global configuration settings apply to all tasks inherited from `CMDx::Task`. +Globally these settings are initialized with sensible defaults. + +### Breakpoints + +Breakpoints control when `execute!` raises faults. + +```ruby +CMDx.configure do |config| + config.task_breakpoints = "skipped" + config.workflow_breakpoints = ["skipped", "failed"] +end +``` + +### Logging + +```ruby +CMDx.configure do |config| + config.logger = CustomLogger.new($stdout) +end +``` + +### Middlewares + +```ruby +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 +``` + +> [!NOTE] +> Middlewares are executed in registration order. Each middleware wraps the next, +> creating an execution chain around task logic. + +### Callbacks + +```ruby +CMDx.configure do |config| + # Via method + config.callbacks.register :before_execution, :setup_request_context + + # Via callable (must respond to `call(task)`) + config.callbacks.register :on_success, TrackSuccessfulPurchase + + # 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}"]) + } + + # With options + config.callbacks.register :on_failure, :notify_admin, if: :production? + + # Remove callback + config.callbacks.deregister :on_success, TrackSuccessfulPurchase +end +``` + +### Coercions + +```ruby +CMDx.configure do |config| + # Via callable (must respond to `call(value, options)`) + config.coercions.register :money, MoneyCoercion + + # Via method (must match signature `def point_coercion(value, options)`) + config.coercions.register :point, :point_coercion + + # 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 +CMDx.configure do |config| + # Via callable (must respond to `call(value, options)`) + config.validators.register :email, EmailValidator + + # Via method (must match signature `def phone_validator(value, options)`) + config.validators.register :phone, :phone_validator + + # Via proc or lambda + config.validators.register :api_key, proc { |value, options| + required_prefix = options[:prefix] || "sk_" + min_length = options[:min_length] || 32 + + value.start_with?(required_prefix) && value.length >= min_length + } + + # Remove validator + config.validators.deregister :email +end +``` + +## Task Configuration + +### Settings + +Override global configuration for specific tasks using `settings`: + +```ruby +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 +``` + +> [!TIP] +> Use task-level settings for tasks that require special handling, such as payment processing, +> external API calls, or critical system operations. + +### Registrations + +Register middlewares, callbacks, coercions, and validators on a specific task. +Deregister options that should not be available. + +```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 +``` + +## Configuration Management + +### Access + +```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 +``` + +### Resetting + +> [!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_breakpoints #=> ["failed"] (default) +CMDx.configuration.middlewares.registry #=> Empty registry + +# Commonly used in test setup (RSpec example) +RSpec.configure do |config| + config.before(:each) do + CMDx.reset_configuration! + end +end +``` + +## Task Generator + +Generate new CMDx tasks quickly using the built-in generator: + +```bash +rails generate cmdx:task ProcessOrder +``` + +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` + +--- + +url: https://github.com/drexed/cmdx/blob/main/docs/basics/setup.md +--- + +# Basics - Setup + +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. + +## Structure + +Tasks inherit from `CMDx::Task` and require only a `work` method: + +```ruby +class ProcessUserOrder < CMDx::Task + def work + # Your logic here... + end +end +``` + +An exception will be raised if a work method is not defined. + +```ruby +class InvalidTask < CMDx::Task + # No `work` method defined +end + +InvalidTask.execute #=> raises CMDx::UndefinedMethodError +``` + +## Inheritance + +All configuration options are inheritable by any child classes. +Create a base class to share common configuration across tasks: + +```ruby +class ApplicationTask < CMDx::Task + register :middleware, AuthenticateUserMiddleware + + before_execution :set_correlation_id + + attribute :request_id + + private + + def set_correlation_id + context.correlation_id ||= SecureRandom.uuid + end +end + +class ProcessOrder < ApplicationTask + def work + # Your logic here... + end +end +``` + +## Lifecycle + +Tasks follow a predictable call pattern with specific states and statuses: + +| 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 executed again. + +--- + +url: https://github.com/drexed/cmdx/blob/main/docs/basics/execution.md +--- + +# 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. + +## 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 + RetryOrderJob.perform_later(e.result.context.order_id) +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 +``` + +--- + +url: https://github.com/drexed/cmdx/blob/main/docs/basics/context.md +--- + +# Basics - Context + +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. + +## Assigning Data + +Context is automatically populated with all inputs passed to a task. All keys are normalized to symbols for consistent access: + +```ruby +# Direct execution +ProcessOrder.execute(user_id: 123, currency: "USD") + +# Instance creation +ProcessOrder.new(user_id: 123, "currency" => "USD") +``` + +> [!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 ProcessOrder < CMDx::Task + def work + # Method style access (preferred) + user_id = context.user_id + amount = context.amount + + # Hash style access + order_id = context[:order_id] + metadata = context["metadata"] + + # Safe access with defaults + priority = context.fetch!(:priority, "normal") + source = context.dig(:metadata, :source) + + # Shorter alias + total = ctx.amount * ctx.tax_rate # ctx aliases context + end +end +``` + +> [!NOTE] +> 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 ProcessOrder < CMDx::Task + def work + # Direct assignment + context.user = User.find(context.user_id) + context.order = Order.find(context.order_id) + context.processed_at = Time.now + + # Hash-style assignment + context[:status] = "processing" + context["result_code"] = "SUCCESS" + + # Conditional assignment + context.notification_sent ||= false + + # Batch updates + context.merge!( + status: "completed", + total_amount: calculate_total, + completion_time: Time.now + ) + + # Remove sensitive data + context.delete!(:credit_card_number) + end + + private + + def calculate_total + context.amount + (context.amount * context.tax_rate) + end +end +``` + +> [!TIP] +> Use context for automatic input attributes and intermediate results. This creates natural data flow through your task execution pipeline. + +## Data Sharing + +Context enables seamless data flow between related tasks in complex workflows: + +```ruby +# During execution +class ProcessOrder < CMDx::Task + def work + # Validate order data + validation_result = ValidateOrder.execute(context) + + # Via context + ProcessPayment.execute(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 + +# After execution +result = ProcessOrder.execute(order_number: 123) + +ShipOrder.execute(result) +``` + +--- + +url: https://github.com/drexed/cmdx/blob/main/docs/basics/chain.md +--- + +# Basics - Chain + +Chains automatically group related task executions within a thread, providing unified tracking, correlation, and execution context management. Each thread maintains its own chain through thread-local storage, eliminating the need for manual coordination. + +## Management + +Each thread maintains its own chain context through thread-local storage, providing automatic isolation without manual coordination. + +```ruby +# Thread A +Thread.new do + result = ProcessOrder.execute(order_id: 123) + result.chain.id #=> "018c2b95-b764-7615-a924-cc5b910ed1e5" +end + +# Thread B (completely separate chain) +Thread.new do + 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 +``` + +> [!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 +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 +``` + +> [!NOTE] +> Chain creation is automatic and transparent. You don't need to manually manage chain lifecycle. + +## Inheritance + +When tasks call subtasks within the same thread, all executions automatically inherit the current chain, creating a unified execution trail. + +```ruby +class ProcessOrder < CMDx::Task + def work + context.order = Order.find(order_id) + + # Subtasks automatically inherit current chain + ValidateOrder.execute + ChargePayment.execute!(context) + SendConfirmation.execute(order_id: order_id) + end +end + +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 { |r| r.task.class } +#=> [ProcessOrder, ValidateOrder, ChargePayment, SendConfirmation] +``` + +## Structure + +Chains provide comprehensive execution information with state delegation: + +```ruby +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 + +# State delegation (from first/outer-most result) +chain.state #=> "complete" +chain.status #=> "success" +chain.outcome #=> "success" + +# Access individual results +chain.results.each_with_index do |result, index| + puts "#{index}: #{result.task.class} - #{result.status}" +end +``` + +> [!NOTE] +> Chain state always reflects the first (outer-most) task result, not individual subtask outcomes. Subtasks maintain their own success/failure states. + +--- + +url: https://github.com/drexed/cmdx/blob/main/docs/interruptions/halt.md +--- + +# Interruptions - Halt + +Halting stops task execution with explicit intent signaling. Tasks provide two primary halt methods that control execution flow and result in different outcomes. + +## Skipping + +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 ProcessOrder < CMDx::Task + def work + # Without a reason + skip! if Array(ENV["PHASED_OUT_TASKS"]).include?(self.class.name) + + # With a reason + skip!("Outside business hours") unless Time.now.hour.between?(9, 17) + + order = Order.find(context.order_id) + + if order.processed? + skip!("Order already processed") + else + order.process! + end + end +end + +result = ProcessSubscription.execute(user_id: 123) + +# Executed +result.status #=> "skipped" + +# Without a reason +result.reason #=> "no reason given" + +# With a reason +result.reason #=> "Outside business hours" +``` + +> [!NOTE] +> Skipping is not a failure or error. Skipped tasks are considered successful outcomes. + +## Failing + +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. + +```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 + +result = ProcessSubscription.execute(user_id: 123) + +# Executed +result.status #=> "failed" + +# Without a reason +result.reason #=> "no reason given" + +# With a reason +result.reason #=> "Unsupported payment type" +``` + +## Metadata Enrichment + +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 ProcessSubscription < CMDx::Task + def work + user = User.find(context.user_id) + + if user.subscription_expired? + # Without metadata + skip!("Subscription expired") + end + + unless user.payment_method_valid? + # With metadata + fail!( + "Invalid payment method", + error_code: "PAYMENT_METHOD.INVALID", + retry_after: Time.current + 1.hour + ) + end + + process_subscription + end +end + +result = ProcessSubscription.execute(user_id: 123) + +# Without metadata +result.metadata #=> {} + +# With metadata +result.metadata #=> { + # error_code: "PAYMENT_METHOD.INVALID", + # retry_after: