active_storage-async_variants 0.1.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 4395d49e7c04b9206145e2c8dbe0bf3f4466ed736054ec5e9fc3b8fc7dea3a1c
4
+ data.tar.gz: 1cf4f1090be29b6ff6a7a50be05ecaa2bcc79f50de9a203ec26d99c9d8e58fc8
5
+ SHA512:
6
+ metadata.gz: 1d696905633e0039f8489377d41ea120fd89d5fccfcadbd8872981ea916d9032395f5f66091eba170de40f03547b7b93c90457e6796f15868ae3e7d3ecf937c4
7
+ data.tar.gz: 21c13be811dc6c6b9b19123f6ef8c9c23c1e2cbc0360ee9f79a88b4bf83d023f514d61964e75b749c2b073ad19e7db52f878a2b55de8c90b4018ff831a4d3f2d
@@ -0,0 +1,27 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [master]
6
+ pull_request:
7
+ branches: [master]
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ strategy:
13
+ fail-fast: false
14
+ matrix:
15
+ ruby: ["3.3", "3.4", "4.0"]
16
+ rails: ["rails-7.2", "rails-8.0", "rails-8.1"]
17
+ exclude:
18
+ - ruby: "4.0"
19
+ rails: "rails-7.2"
20
+ steps:
21
+ - uses: actions/checkout@v4
22
+ - uses: ruby/setup-ruby@v1
23
+ with:
24
+ ruby-version: ${{ matrix.ruby }}
25
+ bundler-cache: true
26
+ - run: bundle exec appraisal ${{ matrix.rails }} bundle install
27
+ - run: bundle exec appraisal ${{ matrix.rails }} rake
data/.ruby-gemset ADDED
@@ -0,0 +1 @@
1
+ active_storage-async_variants
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ ruby-3.4.2
data/Appraisals ADDED
@@ -0,0 +1,11 @@
1
+ appraise "rails-7.2" do
2
+ gem "rails", "~> 7.2.0"
3
+ end
4
+
5
+ appraise "rails-8.0" do
6
+ gem "rails", "~> 8.0.0"
7
+ end
8
+
9
+ appraise "rails-8.1" do
10
+ gem "rails", "~> 8.1.0"
11
+ end
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2026-03-03
4
+
5
+ - Initial release
data/CLAUDE.md ADDED
@@ -0,0 +1,48 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## What This Is
6
+
7
+ A Rails engine gem that extends Active Storage with async-safe variant processing. Solves the problem where slow transformations (e.g., video transcoding) block requests or fail silently. The `fallback:` option on a variant definition opts it into async processing.
8
+
9
+ ## Commands
10
+
11
+ ```bash
12
+ bundle exec rake # Run full test suite (default task is :spec)
13
+ bundle exec rspec # Run all specs
14
+ bundle exec rspec spec/active_storage/async_variants_spec.rb # Run the main spec file
15
+ bundle exec rspec spec/active_storage/async_variants_spec.rb -e "description" # Run specific example
16
+ ```
17
+
18
+ ## Architecture
19
+
20
+ The gem works by prepending extension modules onto Active Storage classes:
21
+
22
+ - **`VariationExtension`** → `ActiveStorage::Variation` — extracts async options (`fallback:`, `transformer:`, `max_retries:`) from variant config before passing the rest to standard Active Storage
23
+ - **`AttachmentExtension`** → `ActiveStorage::Attachment` — hooks into `transform_variants_later` to enqueue `ProcessJob` for variants with `fallback:`
24
+ - **`VariantWithRecordExtension`** → `ActiveStorage::VariantWithRecord` — overrides URL generation to serve fallback while processing; adds state query methods (`ready?`, `processing?`, `pending?`, `failed?`)
25
+ - **`ProcessJob`** — background job that determines transformer type (inline vs external) and processes accordingly
26
+
27
+ ### Transformer Types
28
+
29
+ - **Inline**: implements `process(file, **options)` → blocks worker, returns `{ io:, content_type:, filename: }`
30
+ - **External**: implements `initiate(source_url:, destination_url:, callback_url:, **options)` → frees worker immediately, external service POSTs to callback URL when done
31
+
32
+ The gem detects which type by checking if `process` is overridden on the transformer class.
33
+
34
+ ### Callback Endpoint
35
+
36
+ `CallbacksController` mounted at `/active_storage/async_variants/callbacks/:token` receives webhook POSTs from external services. Tokens are signed with `ActiveStorage.verifier`.
37
+
38
+ ### State Machine
39
+
40
+ `VariantRecord.state`: `pending` → `processing` → `processed` (success) or `failed` (with error message and attempt count)
41
+
42
+ ## Testing
43
+
44
+ - Uses RSpec with a dummy Rails app in `spec/dummy/`
45
+ - SQLite in-memory database defined in `spec/support/active_record.rb`
46
+ - Jobs use `:test` queue adapter (not executed automatically — use `perform_enqueued_jobs` to run them)
47
+ - Test helpers `create_variant_record` and `simulate_processed_variant` are defined in `spec/support/active_record.rb`
48
+ - There is one main spec file: `spec/active_storage/async_variants_spec.rb`
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Micah Geisel
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,228 @@
1
+ # ActiveStorage::AsyncVariants
2
+
3
+ Extends Active Storage with pluggable per-variant transformers, async-safe variant processing, and failure handling.
4
+
5
+ ## The Problem
6
+
7
+ Active Storage's variant system assumes transformations are fast and reliable -- like generating an image thumbnail. But some transformations are slow (transcoding a 1GB video to 720p VP9) and fallible (the transcode may permanently fail). When you use `process: :later`, Active Storage enqueues a background job, but if the variant is requested before the job finishes, it falls through to synchronous processing -- blocking the request for minutes or timing out entirely. And if the transformation fails, the error bubbles up with no tracking or retry limits.
8
+
9
+ ## Installation
10
+
11
+ ```ruby
12
+ gem "active_storage-async_variants"
13
+ ```
14
+
15
+ ```bash
16
+ bin/rails active_storage_async_variants:install:migrations
17
+ bin/rails db:migrate
18
+ ```
19
+
20
+ ## Usage
21
+
22
+ Add `fallback:` to any named variant to opt into the async pipeline:
23
+
24
+ ```ruby
25
+ class User < ApplicationRecord
26
+ has_one_attached :video do |attachable|
27
+ attachable.variant :web,
28
+ transformer: VideoTranscoder,
29
+ codec: "vp9",
30
+ resolution: "720p",
31
+ fallback: :original
32
+ end
33
+ end
34
+ ```
35
+
36
+ The presence of `fallback:` is what opts a variant into async processing. Without it, variants behave exactly as they do in standard Active Storage. The `transformer:` option is independent -- you can use a custom transformer synchronously, or use the default transformer asynchronously:
37
+
38
+ ```ruby
39
+ has_one_attached :video do |attachable|
40
+ # Async with custom transformer (video transcode)
41
+ attachable.variant :web,
42
+ transformer: VideoTranscoder,
43
+ codec: "vp9",
44
+ fallback: :original
45
+
46
+ # Async with default transformer (large image resize that's too slow for inline)
47
+ attachable.variant :thumbnail,
48
+ resize_to_limit: [200, 200],
49
+ fallback: :original
50
+
51
+ # Sync with custom transformer (fast custom processing, no fallback needed)
52
+ attachable.variant :watermarked,
53
+ transformer: WatermarkStamper
54
+ end
55
+ ```
56
+
57
+ In views, use the same Active Storage helpers:
58
+
59
+ ```erb
60
+ <%= video_tag user.video.variant(:web).url %>
61
+ ```
62
+
63
+ If the variant is still processing, this serves the original video. Once processing completes, it serves the transcoded variant.
64
+
65
+ ## Writing a Transformer
66
+
67
+ Transformers come in two flavors: **inline** (the job blocks until processing completes) and **external** (the job kicks off remote work and a webhook signals completion).
68
+
69
+ ### External Transformers (recommended for slow work)
70
+
71
+ An external transformer delegates to a remote service and returns immediately, freeing up the job worker. The remote service uploads the result directly to storage and hits a callback URL when done.
72
+
73
+ ```ruby
74
+ class LambdaTranscoder < ActiveStorage::AsyncVariants::Transformer
75
+ def initiate(source_url:, destination_url:, callback_url:, **options)
76
+ Http.post("https://transcode.example.com/jobs",
77
+ source_url: source_url,
78
+ destination_url: destination_url,
79
+ callback_url: callback_url,
80
+ codec: options[:codec],
81
+ resolution: options[:resolution],
82
+ )
83
+ end
84
+ end
85
+ ```
86
+
87
+ The gem calls `initiate` with:
88
+ - `source_url` -- a presigned GET URL for the original file
89
+ - `destination_url` -- a presigned PUT URL where the result should be uploaded
90
+ - `callback_url` -- a signed webhook URL to POST to when done
91
+
92
+ The remote service does its work (which could take minutes or hours), uploads the result to `destination_url`, then POSTs to `callback_url`:
93
+
94
+ ```
95
+ POST <callback_url>
96
+ Content-Type: application/json
97
+
98
+ { "status": "success", "content_type": "video/webm", "byte_size": 52428800 }
99
+ ```
100
+
101
+ Or on failure:
102
+
103
+ ```
104
+ POST <callback_url>
105
+ Content-Type: application/json
106
+
107
+ { "status": "failed", "error": "ffmpeg exited with status 1" }
108
+ ```
109
+
110
+ The callback URL is signed -- no authentication is needed on the caller's side.
111
+
112
+ ### Callback Endpoint
113
+
114
+ The gem mounts a callback endpoint at:
115
+
116
+ ```
117
+ POST /active_storage/async_variants/callbacks/:token
118
+ ```
119
+
120
+ The `:token` is a signed, single-use token that identifies the variant record. The gem generates this URL and passes it to `initiate` as `callback_url`. Your external service just POSTs to it -- no API keys or authentication headers required.
121
+
122
+ Expected request body:
123
+
124
+ ```json
125
+ { "status": "success", "content_type": "video/webm", "byte_size": 52428800 }
126
+ ```
127
+
128
+ ```json
129
+ { "status": "failed", "error": "ffmpeg exited with status 1" }
130
+ ```
131
+
132
+ ### Inline Transformers (simpler, blocks the worker)
133
+
134
+ For cases where you're running the transformation locally (e.g., ffmpeg on the same machine), an inline transformer blocks until done:
135
+
136
+ ```ruby
137
+ class LocalTranscoder < ActiveStorage::AsyncVariants::Transformer
138
+ def process(file, **options)
139
+ output = Tempfile.new(["output", ".webm"])
140
+ system("ffmpeg", "-i", file.path,
141
+ "-c:v", "libvpx-vp9",
142
+ "-vf", "scale=-2:#{options[:resolution]&.delete("p") || 720}",
143
+ "-c:a", "libopus",
144
+ output.path,
145
+ exception: true,
146
+ )
147
+ { io: output, content_type: "video/webm", filename: "video.webm" }
148
+ end
149
+ end
150
+ ```
151
+
152
+ The `process` method receives the source file and all non-reserved options from the variant definition. It returns a hash with `io:`, `content_type:`, and `filename:`.
153
+
154
+ The gem determines the mode by which method the transformer implements: `initiate` for external, `process` for inline.
155
+
156
+ ## Checking Variant State
157
+
158
+ ```ruby
159
+ variant = user.video.variant(:web)
160
+
161
+ variant.ready? # => true if processed successfully
162
+ variant.processing? # => true if job is running or external service is working
163
+ variant.pending? # => true if job is enqueued
164
+ variant.failed? # => true if permanently failed
165
+ variant.error # => error message string, or nil
166
+ ```
167
+
168
+ ## Fallback Options
169
+
170
+ The `fallback:` option controls what gets served while a variant is processing (or after it fails):
171
+
172
+ ```ruby
173
+ # Serve the original unprocessed file
174
+ attachable.variant :web, fallback: :original
175
+
176
+ # Return nil -- let the view handle it
177
+ attachable.variant :web, fallback: :blank
178
+
179
+ # Custom fallback
180
+ attachable.variant :web,
181
+ fallback: -> (blob) { "/placeholders/processing.svg" }
182
+ ```
183
+
184
+ ## Failure Handling
185
+
186
+ By default, a variant is retried 3 times before being marked as permanently failed. Configure per-variant:
187
+
188
+ ```ruby
189
+ attachable.variant :web,
190
+ transformer: VideoTranscoder,
191
+ codec: "vp9",
192
+ resolution: "720p",
193
+ fallback: :original,
194
+ max_retries: 5
195
+ ```
196
+
197
+ Inspect failures:
198
+
199
+ ```ruby
200
+ variant = user.video.variant(:web)
201
+ variant.failed? # => true
202
+ variant.error # => "ffmpeg exited with status 1: ..."
203
+ ```
204
+
205
+ ## How It Works
206
+
207
+ ### External transformer flow
208
+
209
+ 1. User uploads a file to an attachment that has async variants defined
210
+ 2. After attachment, a background job is enqueued for each async variant
211
+ 3. `VariantRecord` is created with state `pending`
212
+ 4. The job calls the transformer's `initiate` method with presigned source/destination URLs and a signed callback URL, then exits -- the worker is free
213
+ 5. The external service processes the file, uploads the result to the destination URL
214
+ 6. The external service POSTs to the callback URL with success/failure status
215
+ 7. The gem's callback endpoint transitions the `VariantRecord` to `processed` or `failed`
216
+ 8. When a view requests the variant URL, the gem checks state and serves the variant or the fallback
217
+
218
+ ### Inline transformer flow
219
+
220
+ 1-3. Same as above
221
+ 4. The job calls the transformer's `process` method, blocking until complete
222
+ 5. On success, the output is uploaded, the `VariantRecord` transitions to `processed`
223
+ 6. On failure, the error is recorded and the job is re-enqueued (up to `max_retries`)
224
+ 7. When a view requests the variant URL, the gem checks state and serves the variant or the fallback
225
+
226
+ ## License
227
+
228
+ MIT
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveStorage
4
+ module AsyncVariants
5
+ class CallbacksController < ActionController::API
6
+ def create
7
+ variant_record_id = ActiveStorage.verifier.verify(params[:token], purpose: :async_variant_callback)
8
+ variant_record = ActiveStorage::VariantRecord.find(variant_record_id)
9
+
10
+ case params[:status]
11
+ when "success"
12
+ variant_record.update!(state: "processed")
13
+ when "failed"
14
+ variant_record.update!(state: "failed", error: params[:error])
15
+ else
16
+ head :unprocessable_entity and return
17
+ end
18
+
19
+ head :ok
20
+ rescue ActiveSupport::MessageVerifier::InvalidSignature
21
+ head :unauthorized
22
+ end
23
+ end
24
+ end
25
+ end
data/config/routes.rb ADDED
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ Rails.application.routes.draw do
4
+ post "/active_storage/async_variants/callbacks/:token",
5
+ to: "active_storage/async_variants/callbacks#create",
6
+ as: :active_storage_async_variant_callback
7
+ end
@@ -0,0 +1,13 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "appraisal"
6
+ gem "irb"
7
+ gem "rake", "~> 13.0"
8
+ gem "rspec", "~> 3.0"
9
+ gem "rspec-rails"
10
+ gem "rails", "~> 7.2.0"
11
+ gem "sqlite3"
12
+
13
+ gemspec path: "../"
@@ -0,0 +1,269 @@
1
+ PATH
2
+ remote: ..
3
+ specs:
4
+ active_storage-async_variants (0.1.0)
5
+ activestorage (>= 7.1)
6
+
7
+ GEM
8
+ remote: https://rubygems.org/
9
+ specs:
10
+ actioncable (7.2.3)
11
+ actionpack (= 7.2.3)
12
+ activesupport (= 7.2.3)
13
+ nio4r (~> 2.0)
14
+ websocket-driver (>= 0.6.1)
15
+ zeitwerk (~> 2.6)
16
+ actionmailbox (7.2.3)
17
+ actionpack (= 7.2.3)
18
+ activejob (= 7.2.3)
19
+ activerecord (= 7.2.3)
20
+ activestorage (= 7.2.3)
21
+ activesupport (= 7.2.3)
22
+ mail (>= 2.8.0)
23
+ actionmailer (7.2.3)
24
+ actionpack (= 7.2.3)
25
+ actionview (= 7.2.3)
26
+ activejob (= 7.2.3)
27
+ activesupport (= 7.2.3)
28
+ mail (>= 2.8.0)
29
+ rails-dom-testing (~> 2.2)
30
+ actionpack (7.2.3)
31
+ actionview (= 7.2.3)
32
+ activesupport (= 7.2.3)
33
+ cgi
34
+ nokogiri (>= 1.8.5)
35
+ racc
36
+ rack (>= 2.2.4, < 3.3)
37
+ rack-session (>= 1.0.1)
38
+ rack-test (>= 0.6.3)
39
+ rails-dom-testing (~> 2.2)
40
+ rails-html-sanitizer (~> 1.6)
41
+ useragent (~> 0.16)
42
+ actiontext (7.2.3)
43
+ actionpack (= 7.2.3)
44
+ activerecord (= 7.2.3)
45
+ activestorage (= 7.2.3)
46
+ activesupport (= 7.2.3)
47
+ globalid (>= 0.6.0)
48
+ nokogiri (>= 1.8.5)
49
+ actionview (7.2.3)
50
+ activesupport (= 7.2.3)
51
+ builder (~> 3.1)
52
+ cgi
53
+ erubi (~> 1.11)
54
+ rails-dom-testing (~> 2.2)
55
+ rails-html-sanitizer (~> 1.6)
56
+ activejob (7.2.3)
57
+ activesupport (= 7.2.3)
58
+ globalid (>= 0.3.6)
59
+ activemodel (7.2.3)
60
+ activesupport (= 7.2.3)
61
+ activerecord (7.2.3)
62
+ activemodel (= 7.2.3)
63
+ activesupport (= 7.2.3)
64
+ timeout (>= 0.4.0)
65
+ activestorage (7.2.3)
66
+ actionpack (= 7.2.3)
67
+ activejob (= 7.2.3)
68
+ activerecord (= 7.2.3)
69
+ activesupport (= 7.2.3)
70
+ marcel (~> 1.0)
71
+ activesupport (7.2.3)
72
+ base64
73
+ benchmark (>= 0.3)
74
+ bigdecimal
75
+ concurrent-ruby (~> 1.0, >= 1.3.1)
76
+ connection_pool (>= 2.2.5)
77
+ drb
78
+ i18n (>= 1.6, < 2)
79
+ logger (>= 1.4.2)
80
+ minitest (>= 5.1)
81
+ securerandom (>= 0.3)
82
+ tzinfo (~> 2.0, >= 2.0.5)
83
+ appraisal (2.5.0)
84
+ bundler
85
+ rake
86
+ thor (>= 0.14.0)
87
+ base64 (0.3.0)
88
+ benchmark (0.5.0)
89
+ bigdecimal (4.0.1)
90
+ builder (3.3.0)
91
+ cgi (0.5.1)
92
+ concurrent-ruby (1.3.6)
93
+ connection_pool (3.0.2)
94
+ crass (1.0.6)
95
+ date (3.5.1)
96
+ diff-lcs (1.6.2)
97
+ drb (2.2.3)
98
+ erb (6.0.2)
99
+ erubi (1.13.1)
100
+ globalid (1.3.0)
101
+ activesupport (>= 6.1)
102
+ i18n (1.14.8)
103
+ concurrent-ruby (~> 1.0)
104
+ io-console (0.8.2)
105
+ irb (1.17.0)
106
+ pp (>= 0.6.0)
107
+ prism (>= 1.3.0)
108
+ rdoc (>= 4.0.0)
109
+ reline (>= 0.4.2)
110
+ logger (1.7.0)
111
+ loofah (2.25.0)
112
+ crass (~> 1.0.2)
113
+ nokogiri (>= 1.12.0)
114
+ mail (2.9.0)
115
+ logger
116
+ mini_mime (>= 0.1.1)
117
+ net-imap
118
+ net-pop
119
+ net-smtp
120
+ marcel (1.1.0)
121
+ mini_mime (1.1.5)
122
+ minitest (6.0.2)
123
+ drb (~> 2.0)
124
+ prism (~> 1.5)
125
+ net-imap (0.6.3)
126
+ date
127
+ net-protocol
128
+ net-pop (0.1.2)
129
+ net-protocol
130
+ net-protocol (0.2.2)
131
+ timeout
132
+ net-smtp (0.5.1)
133
+ net-protocol
134
+ nio4r (2.7.5)
135
+ nokogiri (1.19.1-aarch64-linux-gnu)
136
+ racc (~> 1.4)
137
+ nokogiri (1.19.1-aarch64-linux-musl)
138
+ racc (~> 1.4)
139
+ nokogiri (1.19.1-arm-linux-gnu)
140
+ racc (~> 1.4)
141
+ nokogiri (1.19.1-arm-linux-musl)
142
+ racc (~> 1.4)
143
+ nokogiri (1.19.1-arm64-darwin)
144
+ racc (~> 1.4)
145
+ nokogiri (1.19.1-x86_64-darwin)
146
+ racc (~> 1.4)
147
+ nokogiri (1.19.1-x86_64-linux-gnu)
148
+ racc (~> 1.4)
149
+ nokogiri (1.19.1-x86_64-linux-musl)
150
+ racc (~> 1.4)
151
+ pp (0.6.3)
152
+ prettyprint
153
+ prettyprint (0.2.0)
154
+ prism (1.9.0)
155
+ psych (5.3.1)
156
+ date
157
+ stringio
158
+ racc (1.8.1)
159
+ rack (3.2.5)
160
+ rack-session (2.1.1)
161
+ base64 (>= 0.1.0)
162
+ rack (>= 3.0.0)
163
+ rack-test (2.2.0)
164
+ rack (>= 1.3)
165
+ rackup (2.3.1)
166
+ rack (>= 3)
167
+ rails (7.2.3)
168
+ actioncable (= 7.2.3)
169
+ actionmailbox (= 7.2.3)
170
+ actionmailer (= 7.2.3)
171
+ actionpack (= 7.2.3)
172
+ actiontext (= 7.2.3)
173
+ actionview (= 7.2.3)
174
+ activejob (= 7.2.3)
175
+ activemodel (= 7.2.3)
176
+ activerecord (= 7.2.3)
177
+ activestorage (= 7.2.3)
178
+ activesupport (= 7.2.3)
179
+ bundler (>= 1.15.0)
180
+ railties (= 7.2.3)
181
+ rails-dom-testing (2.3.0)
182
+ activesupport (>= 5.0.0)
183
+ minitest
184
+ nokogiri (>= 1.6)
185
+ rails-html-sanitizer (1.7.0)
186
+ loofah (~> 2.25)
187
+ nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
188
+ railties (7.2.3)
189
+ actionpack (= 7.2.3)
190
+ activesupport (= 7.2.3)
191
+ cgi
192
+ irb (~> 1.13)
193
+ rackup (>= 1.0.0)
194
+ rake (>= 12.2)
195
+ thor (~> 1.0, >= 1.2.2)
196
+ tsort (>= 0.2)
197
+ zeitwerk (~> 2.6)
198
+ rake (13.3.1)
199
+ rdoc (7.2.0)
200
+ erb
201
+ psych (>= 4.0.0)
202
+ tsort
203
+ reline (0.6.3)
204
+ io-console (~> 0.5)
205
+ rspec (3.13.2)
206
+ rspec-core (~> 3.13.0)
207
+ rspec-expectations (~> 3.13.0)
208
+ rspec-mocks (~> 3.13.0)
209
+ rspec-core (3.13.6)
210
+ rspec-support (~> 3.13.0)
211
+ rspec-expectations (3.13.5)
212
+ diff-lcs (>= 1.2.0, < 2.0)
213
+ rspec-support (~> 3.13.0)
214
+ rspec-mocks (3.13.8)
215
+ diff-lcs (>= 1.2.0, < 2.0)
216
+ rspec-support (~> 3.13.0)
217
+ rspec-rails (8.0.3)
218
+ actionpack (>= 7.2)
219
+ activesupport (>= 7.2)
220
+ railties (>= 7.2)
221
+ rspec-core (~> 3.13)
222
+ rspec-expectations (~> 3.13)
223
+ rspec-mocks (~> 3.13)
224
+ rspec-support (~> 3.13)
225
+ rspec-support (3.13.7)
226
+ securerandom (0.4.1)
227
+ sqlite3 (2.9.1-aarch64-linux-gnu)
228
+ sqlite3 (2.9.1-aarch64-linux-musl)
229
+ sqlite3 (2.9.1-arm-linux-gnu)
230
+ sqlite3 (2.9.1-arm-linux-musl)
231
+ sqlite3 (2.9.1-arm64-darwin)
232
+ sqlite3 (2.9.1-x86_64-darwin)
233
+ sqlite3 (2.9.1-x86_64-linux-gnu)
234
+ sqlite3 (2.9.1-x86_64-linux-musl)
235
+ stringio (3.2.0)
236
+ thor (1.5.0)
237
+ timeout (0.6.0)
238
+ tsort (0.2.0)
239
+ tzinfo (2.0.6)
240
+ concurrent-ruby (~> 1.0)
241
+ useragent (0.16.11)
242
+ websocket-driver (0.8.0)
243
+ base64
244
+ websocket-extensions (>= 0.1.0)
245
+ websocket-extensions (0.1.5)
246
+ zeitwerk (2.7.5)
247
+
248
+ PLATFORMS
249
+ aarch64-linux-gnu
250
+ aarch64-linux-musl
251
+ arm-linux-gnu
252
+ arm-linux-musl
253
+ arm64-darwin
254
+ x86_64-darwin
255
+ x86_64-linux-gnu
256
+ x86_64-linux-musl
257
+
258
+ DEPENDENCIES
259
+ active_storage-async_variants!
260
+ appraisal
261
+ irb
262
+ rails (~> 7.2.0)
263
+ rake (~> 13.0)
264
+ rspec (~> 3.0)
265
+ rspec-rails
266
+ sqlite3
267
+
268
+ BUNDLED WITH
269
+ 2.6.2