cmdx 1.12.0 → 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +88 -71
  3. data/LICENSE.txt +3 -20
  4. data/README.md +8 -7
  5. data/lib/cmdx/attribute.rb +21 -5
  6. data/lib/cmdx/chain.rb +18 -4
  7. data/lib/cmdx/context.rb +18 -0
  8. data/lib/cmdx/executor.rb +35 -30
  9. data/lib/cmdx/result.rb +45 -2
  10. data/lib/cmdx/task.rb +22 -1
  11. data/lib/cmdx/version.rb +1 -1
  12. data/mkdocs.yml +67 -37
  13. metadata +3 -57
  14. data/.cursor/prompts/docs.md +0 -12
  15. data/.cursor/prompts/llms.md +0 -8
  16. data/.cursor/prompts/rspec.md +0 -24
  17. data/.cursor/prompts/yardoc.md +0 -15
  18. data/.cursor/rules/cursor-instructions.mdc +0 -68
  19. data/.irbrc +0 -18
  20. data/.rspec +0 -4
  21. data/.rubocop.yml +0 -95
  22. data/.ruby-version +0 -1
  23. data/.yard-lint.yml +0 -174
  24. data/.yardopts +0 -7
  25. data/docs/.DS_Store +0 -0
  26. data/docs/assets/favicon.ico +0 -0
  27. data/docs/assets/favicon.svg +0 -1
  28. data/docs/attributes/coercions.md +0 -155
  29. data/docs/attributes/defaults.md +0 -77
  30. data/docs/attributes/definitions.md +0 -283
  31. data/docs/attributes/naming.md +0 -68
  32. data/docs/attributes/transformations.md +0 -63
  33. data/docs/attributes/validations.md +0 -336
  34. data/docs/basics/chain.md +0 -108
  35. data/docs/basics/context.md +0 -121
  36. data/docs/basics/execution.md +0 -96
  37. data/docs/basics/setup.md +0 -84
  38. data/docs/callbacks.md +0 -157
  39. data/docs/configuration.md +0 -314
  40. data/docs/deprecation.md +0 -145
  41. data/docs/getting_started.md +0 -126
  42. data/docs/index.md +0 -134
  43. data/docs/internationalization.md +0 -126
  44. data/docs/interruptions/exceptions.md +0 -52
  45. data/docs/interruptions/faults.md +0 -169
  46. data/docs/interruptions/halt.md +0 -216
  47. data/docs/logging.md +0 -94
  48. data/docs/middlewares.md +0 -191
  49. data/docs/outcomes/result.md +0 -194
  50. data/docs/outcomes/states.md +0 -66
  51. data/docs/outcomes/statuses.md +0 -65
  52. data/docs/retries.md +0 -121
  53. data/docs/stylesheets/extra.css +0 -42
  54. data/docs/tips_and_tricks.md +0 -157
  55. data/docs/workflows.md +0 -226
  56. data/examples/active_record_database_transaction.md +0 -27
  57. data/examples/active_record_query_tagging.md +0 -46
  58. data/examples/flipper_feature_flags.md +0 -50
  59. data/examples/paper_trail_whatdunnit.md +0 -39
  60. data/examples/redis_idempotency.md +0 -71
  61. data/examples/sentry_error_tracking.md +0 -46
  62. data/examples/sidekiq_async_execution.md +0 -29
  63. data/examples/stoplight_circuit_breaker.md +0 -36
  64. data/src/cmdx-dark-logo.png +0 -0
  65. data/src/cmdx-favicon.svg +0 -1
  66. data/src/cmdx-light-logo.png +0 -0
  67. data/src/cmdx-logo.svg +0 -1
data/docs/workflows.md DELETED
@@ -1,226 +0,0 @@
1
- # Workflows
2
-
3
- Compose multiple tasks into powerful, sequential pipelines. Workflows provide a declarative way to build complex business processes with conditional execution, shared context, and flexible error handling.
4
-
5
- ## Declarations
6
-
7
- Tasks run in declaration order (FIFO), sharing a common context across the pipeline.
8
-
9
- !!! warning
10
-
11
- Don't define a `work` method in workflows—the module handles execution automatically.
12
-
13
- ### Task
14
-
15
- ```ruby
16
- class OnboardingWorkflow < CMDx::Task
17
- include CMDx::Workflow
18
-
19
- task CreateUserProfile
20
- task SetupAccountPreferences
21
-
22
- tasks SendWelcomeEmail, SendWelcomeSms, CreateDashboard
23
- end
24
- ```
25
-
26
- !!! tip
27
-
28
- Execute tasks in parallel via the [cmdx-parallel](https://github.com/drexed/cmdx-parallel) gem.
29
-
30
- ### Group
31
-
32
- Group related tasks to share configuration:
33
-
34
- !!! warning "Important"
35
-
36
- Settings and conditionals apply to all tasks in the group.
37
-
38
- ```ruby
39
- class ContentModerationWorkflow < CMDx::Task
40
- include CMDx::Workflow
41
-
42
- # Screening phase
43
- tasks ScanForProfanity, CheckForSpam, ValidateImages, breakpoints: ["skipped"]
44
-
45
- # Review phase
46
- tasks ApplyFilters, ScoreContent, FlagSuspicious
47
-
48
- # Decision phase
49
- tasks PublishContent, QueueForReview, NotifyModerators
50
- end
51
- ```
52
-
53
- ### Conditionals
54
-
55
- Conditionals support multiple syntaxes for flexible execution control:
56
-
57
- ```ruby
58
- class ContentAccessCheck
59
- def call(task)
60
- task.context.user.can?(:publish_content)
61
- end
62
- end
63
-
64
- class OnboardingWorkflow < CMDx::Task
65
- include CMDx::Workflow
66
-
67
- # If and/or Unless
68
- task SendWelcomeEmail, if: :email_configured?, unless: :email_disabled?
69
-
70
- # Proc
71
- task SendWelcomeEmail, if: -> { Rails.env.production? && self.class.name.include?("Premium") }
72
-
73
- # Lambda
74
- task SendWelcomeEmail, if: proc { context.features_enabled? }
75
-
76
- # Class or Module
77
- task SendWelcomeEmail, unless: ContentAccessCheck
78
-
79
- # Instance
80
- task SendWelcomeEmail, if: ContentAccessCheck.new
81
-
82
- # Conditional applies to all tasks of this declaration group
83
- tasks SendWelcomeEmail, CreateDashboard, SetupTutorial, if: :email_configured?
84
-
85
- private
86
-
87
- def email_configured?
88
- context.user.email_address == true
89
- end
90
-
91
- def email_disabled?
92
- context.user.communication_preference == :disabled
93
- end
94
- end
95
- ```
96
-
97
- ## Halt Behavior
98
-
99
- By default, skipped tasks don't stop the workflow—they're treated as no-ops. Configure breakpoints globally or per-task to customize this behavior.
100
-
101
- ```ruby
102
- class AnalyticsWorkflow < CMDx::Task
103
- include CMDx::Workflow
104
-
105
- task CollectMetrics # If fails → workflow stops
106
- task FilterOutliers # If skipped → workflow continues
107
- task GenerateDashboard # Only runs if no failures occurred
108
- end
109
- ```
110
-
111
- ### Task Configuration
112
-
113
- Configure halt behavior for the entire workflow:
114
-
115
- ```ruby
116
- class SecurityWorkflow < CMDx::Task
117
- include CMDx::Workflow
118
-
119
- # Halt on both failed and skipped results
120
- settings(workflow_breakpoints: ["skipped", "failed"])
121
-
122
- task PerformSecurityScan
123
- task ValidateSecurityRules
124
- end
125
-
126
- class OptionalTasksWorkflow < CMDx::Task
127
- include CMDx::Workflow
128
-
129
- # Never halt, always continue
130
- settings(breakpoints: [])
131
-
132
- task TryBackupData
133
- task TryCleanupLogs
134
- task TryOptimizeCache
135
- end
136
- ```
137
-
138
- ### Group Configuration
139
-
140
- Different task groups can have different halt behavior:
141
-
142
- ```ruby
143
- class SubscriptionWorkflow < CMDx::Task
144
- include CMDx::Workflow
145
-
146
- task CreateSubscription, ValidatePayment, workflow_breakpoints: ["skipped", "failed"]
147
-
148
- # Never halt, always continue
149
- task SendConfirmationEmail, UpdateBilling, breakpoints: []
150
- end
151
- ```
152
-
153
- ## Nested Workflows
154
-
155
- Build hierarchical workflows by composing workflows within workflows:
156
-
157
- ```ruby
158
- class EmailPreparationWorkflow < CMDx::Task
159
- include CMDx::Workflow
160
-
161
- task ValidateRecipients
162
- task CompileTemplate
163
- end
164
-
165
- class EmailDeliveryWorkflow < CMDx::Task
166
- include CMDx::Workflow
167
-
168
- tasks SendEmails, TrackDeliveries
169
- end
170
-
171
- class CompleteEmailWorkflow < CMDx::Task
172
- include CMDx::Workflow
173
-
174
- task EmailPreparationWorkflow
175
- task EmailDeliveryWorkflow, if: proc { context.preparation_successful? }
176
- task GenerateDeliveryReport
177
- end
178
- ```
179
-
180
- ## Parallel Execution
181
-
182
- Run tasks concurrently using the [Parallel](https://github.com/grosser/parallel) gem. It automatically uses all available processors for maximum throughput.
183
-
184
- !!! warning
185
-
186
- Context is read-only during parallel execution. Load all required data beforehand.
187
-
188
- ```ruby
189
- class SendWelcomeNotifications < CMDx::Task
190
- include CMDx::Workflow
191
-
192
- # Default options (dynamically calculated to available processors)
193
- tasks SendWelcomeEmail, SendWelcomeSms, SendWelcomePush, strategy: :parallel
194
-
195
- # Fix number of threads
196
- tasks SendWelcomeEmail, SendWelcomeSms, SendWelcomePush, strategy: :parallel, in_threads: 2
197
-
198
- # Fix number of forked processes
199
- tasks SendWelcomeEmail, SendWelcomeSms, SendWelcomePush, strategy: :parallel, in_processes: 2
200
-
201
- # NOTE: Reactors are not supported
202
- end
203
- ```
204
-
205
- ## Task Generator
206
-
207
- Generate new CMDx workflow tasks quickly using the built-in generator:
208
-
209
- ```bash
210
- rails generate cmdx:workflow SendNotifications
211
- ```
212
-
213
- This creates a new workflow task file with the basic structure:
214
-
215
- ```ruby
216
- # app/tasks/send_notifications.rb
217
- class SendNotifications < CMDx::Task
218
- include CMDx::Workflow
219
-
220
- tasks Task1, Task2
221
- end
222
- ```
223
-
224
- !!! tip
225
-
226
- Use **present tense verbs + pluralized noun** for workflow task names, eg: `SendNotifications`, `DownloadFiles`, `ValidateDocuments`
@@ -1,27 +0,0 @@
1
- # Active Record Query Tagging
2
-
3
- Wrap task or workflow execution in a database transaction. This is essential for data integrity when multiple steps modify the database.
4
-
5
- ### Setup
6
-
7
- ```ruby
8
- # lib/cmdx_database_transaction_middleware.rb
9
- class CmdxDatabaseTransactionMiddleware
10
- def self.call(task, **options, &)
11
- ActiveRecord::Base.transaction(requires_new: true, &)
12
- end
13
- end
14
- ```
15
-
16
- ### Usage
17
-
18
- ```ruby
19
- class MyTask < CMDx::Task
20
- register :middleware, CmdxDatabaseTransactionMiddleware
21
-
22
- def work
23
- # Do work...
24
- end
25
-
26
- end
27
- ```
@@ -1,46 +0,0 @@
1
- # Active Record Query Tagging
2
-
3
- Add a comment to every query indicating some context to help you track down where that query came from, eg:
4
-
5
- ```sh
6
- /*cmdx_task_class:ExportReportTask,cmdx_chain_id:018c2b95-b764-7615*/ SELECT * FROM reports WHERE id = 1
7
- ```
8
-
9
- ### Setup
10
-
11
- ```ruby
12
- # config/application.rb
13
- config.active_record.query_log_tags_enabled = true
14
- config.active_record.query_log_tags += [
15
- :cmdx_correlation_id,
16
- :cmdx_chain_id,
17
- :cmdx_task_class,
18
- :cmdx_task_id
19
- ]
20
-
21
- # lib/cmdx_query_tagging_middleware.rb
22
- class CmdxQueryTaggingMiddleware
23
- def self.call(task, **options, &)
24
- ActiveSupport::ExecutionContext.set(
25
- cmdx_correlation_id: task.result.metadata[:correlation_id],
26
- cmdx_chain_id: task.chain.id,
27
- cmdx_task_class: task.class.name,
28
- cmdx_task_id: task.id,
29
- &
30
- )
31
- end
32
- end
33
- ```
34
-
35
- ### Usage
36
-
37
- ```ruby
38
- class MyTask < CMDx::Task
39
- register :middleware, CmdxQueryTaggingMiddleware
40
-
41
- def work
42
- # Do work...
43
- end
44
-
45
- end
46
- ```
@@ -1,50 +0,0 @@
1
- # Flipper Feature Flags
2
-
3
- Control task execution based on Flipper feature flags.
4
-
5
- <https://github.com/flippercloud/flipper>
6
-
7
- ### Setup
8
-
9
- ```ruby
10
- # lib/cmdx_flipper_middleware.rb
11
- class CmdxFlipperMiddleware
12
- def self.call(task, **options, &)
13
- feature_name = options.fetch(:feature)
14
- actor = options.fetch(:actor, -> { task.context[:user] })
15
-
16
- # Resolve actor if it's a proc
17
- actor = actor.call if actor.respond_to?(:call)
18
-
19
- if Flipper.enabled?(feature_name, actor)
20
- yield
21
- else
22
- # Option 1: Skip the task
23
- task.skip!("Feature #{feature_name} is disabled")
24
-
25
- # Option 2: Fail the task
26
- # task.fail!("Feature #{feature_name} is disabled")
27
- end
28
- end
29
- end
30
- ```
31
-
32
- ### Usage
33
-
34
- ```ruby
35
- class NewFeatureTask < CMDx::Task
36
- # Execute only if :new_feature is enabled for the user in context
37
- register :middleware, CmdxFlipperMiddleware,
38
- feature: :new_feature
39
-
40
- # Customize the actor resolution
41
- register :middleware, CmdxFlipperMiddleware,
42
- feature: :beta_access,
43
- actor: -> { task.context[:company] }
44
-
45
- def work
46
- # ...
47
- end
48
- end
49
- ```
50
-
@@ -1,39 +0,0 @@
1
- # Paper Trail Whatdunnit
2
-
3
- Tag paper trail version records with which service made a change with a custom `whatdunnit` attribute.
4
-
5
- <https://github.com/paper-trail-gem/paper_trail?tab=readme-ov-file#4c-storing-metadata>
6
-
7
- ### Setup
8
-
9
- ```ruby
10
- # lib/cmdx_paper_trail_middleware.rb
11
- class CmdxPaperTrailMiddleware
12
- def self.call(task, **options, &)
13
- # This makes sure to reset the whatdunnit value to the previous
14
- # value for nested task calls
15
-
16
- begin
17
- PaperTrail.request.controller_info ||= {}
18
- old_whatdunnit = PaperTrail.request.controller_info[:whatdunnit]
19
- PaperTrail.request.controller_info[:whatdunnit] = task.class.name
20
- yield
21
- ensure
22
- PaperTrail.request.controller_info[:whatdunnit] = old_whatdunnit
23
- end
24
- end
25
- end
26
- ```
27
-
28
- ### Usage
29
-
30
- ```ruby
31
- class MyTask < CMDx::Task
32
- register :middleware, CmdxPaperTrailMiddleware
33
-
34
- def work
35
- # Do work...
36
- end
37
-
38
- end
39
- ```
@@ -1,71 +0,0 @@
1
- # Redis Idempotency
2
-
3
- Ensure tasks are executed exactly once using Redis to store execution state. This is critical for non-idempotent operations like charging a credit card or sending an email.
4
-
5
- ### Setup
6
-
7
- ```ruby
8
- # lib/cmdx_redis_idempotency_middleware.rb
9
- class CmdxRedisIdempotencyMiddleware
10
- def self.call(task, **options, &block)
11
- key = generate_key(task, options[:key])
12
- ttl = options[:ttl] || 5.minutes.to_i
13
-
14
- # Attempt to lock the key
15
- if Redis.current.set(key, "processing", nx: true, ex: ttl)
16
- begin
17
- block.call.tap |result|
18
- Redis.current.set(key, result.status, xx: true, ex: ttl)
19
- end
20
- rescue => e
21
- Redis.current.del(key)
22
- raise(e)
23
- end
24
- else
25
- # Key exists, handle duplicate
26
- status = Redis.current.get(key)
27
-
28
- if status == "processing"
29
- task.result.tap { |r| r.skip!("Duplicate request: currently processing", halt: true) }
30
- else
31
- task.result.tap { |r| r.skip!("Duplicate request: already processed (#{status})", halt: true) }
32
- end
33
- end
34
- end
35
-
36
- def self.generate_key(task, key_gen)
37
- id = if key_gen.respond_to?(:call)
38
- key_gen.call(task)
39
- elsif key_gen.is_a?(Symbol)
40
- task.send(key_gen)
41
- else
42
- task.context[:idempotency_key]
43
- end
44
-
45
- "cmdx:idempotency:#{task.class.name}:#{id}"
46
- end
47
- end
48
- ```
49
-
50
- ### Usage
51
-
52
- ```ruby
53
- class ChargeCustomer < CMDx::Task
54
- # Use context[:payment_id] as the unique key
55
- register :middleware, CmdxIdempotencyMiddleware,
56
- key: ->(t) { t.context[:payment_id] }
57
-
58
- def work
59
- # Charge logic...
60
- end
61
- end
62
-
63
- # First run: Executes
64
- ChargeCustomer.call(payment_id: "123")
65
- # => Success
66
-
67
- # Second run: Skips
68
- ChargeCustomer.call(payment_id: "123")
69
- # => Skipped (reason: "Duplicate request: already processed (success)")
70
- ```
71
-
@@ -1,46 +0,0 @@
1
- # Sentry Error Tracking
2
-
3
- Report unhandled exceptions and unexpected task failures to Sentry with detailed context.
4
-
5
- <https://github.com/getsentry/sentry-ruby>
6
-
7
- ### Setup
8
-
9
- ```ruby
10
- # lib/cmdx_sentry_middleware.rb
11
- class CmdxSentryMiddleware
12
- def self.call(task, **options, &)
13
- Sentry.with_scope do |scope|
14
- scope.set_tags(task: task.class.name)
15
- scope.set_context(:user, Current.user.sentry_attributes)
16
-
17
- yield.tap do |result|
18
- # Optional: Report logical failures if needed
19
- if Array(options[:report_on]).include?(result.status)
20
- Sentry.capture_message("Task #{result.status}: #{result.reason}", level: :warning)
21
- end
22
- end
23
- end
24
- rescue => e
25
- Sentry.capture_exception(e)
26
- raise(e) # Re-raise to let the task handle the error or bubble up
27
- end
28
- end
29
- ```
30
-
31
- ### Usage
32
-
33
- ```ruby
34
- class ProcessPayment < CMDx::Task
35
- # Report exceptions only
36
- register :middleware, CmdxSentryMiddleware
37
-
38
- # Report exceptions AND logical failures (result.failure?)
39
- register :middleware, CmdxSentryMiddleware, report_on: %w[failed skipped]
40
-
41
- def work
42
- # ...
43
- end
44
- end
45
- ```
46
-
@@ -1,29 +0,0 @@
1
- # Sidekiq Async Execute
2
-
3
- Execute tasks asynchronously using Sidekiq without creating separate job classes.
4
-
5
- <https://github.com/sidekiq/sidekiq>
6
-
7
- ### Setup
8
-
9
- ```ruby
10
- class MyTask < CMDx::Task
11
- include Sidekiq::Job
12
-
13
- def work
14
- # Do work...
15
- end
16
-
17
- # Use execute! to trigger Sidekiq's retry logic on failures/exceptions.
18
- def perform
19
- self.class.execute!
20
- end
21
-
22
- end
23
- ```
24
-
25
- ### Usage
26
-
27
- ```ruby
28
- MyTask.perform_async
29
- ```
@@ -1,36 +0,0 @@
1
- # Stoplight Circuit Breaker
2
-
3
- Integrate circuit breakers to protect external service calls and prevent cascading failures when dependencies are unavailable.
4
-
5
- <https://github.com/bolshakov/stoplight>
6
-
7
- ### Setup
8
-
9
- ```ruby
10
- # lib/cmdx_stoplight_middleware.rb
11
- class CmdxStoplightMiddleware
12
- def self.call(task, **options, &)
13
- light = Stoplight(options[:name] || task.class.name, **options)
14
- light.run(&)
15
- rescue Stoplight::Error::RedLight => e
16
- task.result.tap { |r| r.fail!("[#{e.class}] #{e.message}", cause: e) }
17
- end
18
- end
19
- ```
20
-
21
- ### Usage
22
-
23
- ```ruby
24
- class MyTask < CMDx::Task
25
- # With default options
26
- register :middleware, CmdxStoplightMiddleware
27
-
28
- # With stoplight options
29
- register :middleware, CmdxStoplightMiddleware, cool_off_time: 10
30
-
31
- def work
32
- # Do work...
33
- end
34
-
35
- end
36
- ```
Binary file
data/src/cmdx-favicon.svg DELETED
@@ -1 +0,0 @@
1
- <svg width="104.098" height="112.266" viewBox="0 0 60 64.708" xmlns="http://www.w3.org/2000/svg"><path d="M29.907 17.723 26.4 23.17 13.384 3.323h3.507l9.508 14.77 1.938-3.139L18.737 0H7.291L26.4 29.262 45.045 0h-3.97L30 17.54zM9.23 61.293H6.091l18.646-29.447L4.43.093H.46l20.308 31.846L0 64.708l10.985-.092 39.138-61.2h3.323L31.57 37.754l13.662 21.415h3.97L35.445 37.754 59.537.093H48.37zm29.63-23.262 15.047 23.262H43.384l-13.662-20.77-15.508 24.093h3.97l11.63-18 11.723 18H60L41.17 35.354l-.278-.461z" fill="#000"/></svg>
Binary file
data/src/cmdx-logo.svg DELETED
@@ -1 +0,0 @@
1
- <svg width="352.2" height="112.266" viewBox="0 0 203 64.708" xmlns="http://www.w3.org/2000/svg" xmlSpace="preserve"><path d="M172.908 17.723 169.4 23.17 156.385 3.323h3.507l9.508 14.77 1.938-3.139L161.738 0h-11.446L169.4 29.262 188.046 0h-3.97L173 17.54zm-20.677 43.57h-3.139l18.646-29.447L147.431.093h-3.97l20.308 31.846L143 64.708l10.985-.092 39.138-61.2h3.323L174.57 37.754l13.662 21.415h3.969l-13.754-21.415L202.538.093H191.37zm29.63-23.262 15.047 23.262h-10.523l-13.662-20.77-15.508 24.093h3.97l11.63-18 11.723 18H203l-18.83-29.262-.278-.461z" fill="#fe1817"/><path d="M41.667 14v12.8h-23.42c-3.214.272-5.665 3.05-5.665 6.318s2.45 5.937 5.664 6.318H33.17v4.248H18.246a10.65 10.65 0 0 1-9.858-10.62c0-5.447 4.194-10.077 9.64-10.512h19.39v-4.303H18.246v.054A14.823 14.823 0 0 0 4.248 33.118c0 7.898 6.21 14.38 13.998 14.815h19.172v-8.497h4.249v12.745h-23.42A19.063 19.063 0 0 1 0 33.118a19.033 19.033 0 0 1 18.246-19.063zM75 35.623 87.2 14h13.508v38.181H87.963v-14.27l-8.116 14.27h-9.749L57.734 30.504v-8.007l14.87 25.436h4.792l14.815-25.436v25.436h4.249V18.249H89.65l-14.76 25.49-14.542-25.49H53.54v29.684h4.194v-8.007l4.249 7.299v4.956H49.292v-38.18H62.8zM108.333 14h23.42C141.94 14.436 150 22.824 150 33.064c0 10.294-8.061 18.681-18.246 19.117h-23.42V22.497h23.42a10.65 10.65 0 0 1 9.858 10.621c0 5.447-4.194 10.13-9.64 10.566H116.83V30.286h4.248v9.15l10.676-.054c3.213-.273 5.664-3.05 5.664-6.264 0-2.778-1.743-5.065-4.194-5.991-.926-.382-1.47-.382-2.94-.382h-17.702v21.188h19.172a14.84 14.84 0 0 0 13.998-14.87c0-7.897-6.21-14.379-13.998-14.814h-23.42z"/></svg>