active_shrine 0.6.1 → 0.7.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1c8e608b43c2c62c71a1a476bedc6511be6aef1be62c2a264e881bcc3944e2f1
4
- data.tar.gz: d7833ce493df81523712856818248859ece92c6562c1736698165cf417c21345
3
+ metadata.gz: b0330c7363b9c8b14e0f718886933c33dff3998618da11b47e5d14e813def269
4
+ data.tar.gz: eda5c702640dd7c6b884ffd6aea3bf9246dffb729100d8d762bb109a6e35e79e
5
5
  SHA512:
6
- metadata.gz: 2bcb62909d42c8db6f1c9f7c5fa1533a77032ccb7960f9a63c23cd1acc3f4f680eff8c2633970487d10c4beefb00252233efa1329a1a0403e865c1db1ad15d5e
7
- data.tar.gz: eae0c637b8671eafcd12bbf6026b39149ee06d359c1060f5ac661052cf5b8a8dfdf1c2a0ed29ad4892a3503097ff0e279c858d4be13049ebea7867ff6350480e
6
+ metadata.gz: 31e7f98809b76810e2056389ef8512c1f990864d6ec05324bee267b00f9381aace0182fb1a4fb1043d54617d42ab45ccae12199af112df55beeff016115ce307
7
+ data.tar.gz: 9010a2da61a53f475fc24b258892a179848dcd93e36fe38f7e3a257d451b0ad43977497d5a63e4f68c95b30f47f63a9f6764324a5780de56c8e40315b416677d
data/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.7.0] - 2026-04-15
4
+
5
+ - Support `variant:` and `strict:` options in `Attachment#url`
6
+
3
7
  ## [0.1.0] - 2023-12-01
4
8
 
5
9
  - Initial release
@@ -0,0 +1,235 @@
1
+ # Attachment URL Primitive Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development (recommended) or superpowers-extended-cc:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Enhance `ActiveShrine::Attachment#url` to accept a variant and a `strict:` kwarg, replacing the caller-side boilerplate for Shrine's two-phase upload lifecycle.
6
+
7
+ **Architecture:** A single small change to `Attachment#url` (lib/active_shrine/attachment.rb:40). Non-strict default returns cache URL during pending; strict mode returns nil until promotion. Variant-missing falls back to original. The delegation in `Attached::One` / `Attached::Many` (via `delegate_missing_to :attachment, allow_nil: true`) propagates the new signature automatically — no changes needed there.
8
+
9
+ **Tech Stack:** Ruby, Shrine, Minitest, Combustion.
10
+
11
+ **User Verification:** NO — no user verification required. Automated tests cover the full matrix.
12
+
13
+ ---
14
+
15
+ ## File Structure
16
+
17
+ - **Modify:** `lib/active_shrine/attachment.rb` — enhance the `url` method in `AttachmentMethods`.
18
+ - **Modify:** `test/internal/config/initializers/shrine.rb` — add the `derivatives` plugin so tests can exercise variant URLs.
19
+ - **Create:** `test/attachment_test.rb` — covers the full strict/non-strict × cached/stored × variant matrix.
20
+
21
+ ## Task 1: Enable derivatives in test shrine config
22
+
23
+ **Goal:** Add the Shrine `derivatives` plugin to the test environment so tests can attach, promote, and read variant URLs.
24
+
25
+ **Files:**
26
+ - Modify: `test/internal/config/initializers/shrine.rb`
27
+
28
+ **Acceptance Criteria:**
29
+ - [ ] `Shrine.plugin :derivatives` is loaded in the test init file.
30
+ - [ ] `bundle exec rake test` still runs (no regressions in existing tests).
31
+
32
+ **Verify:** `bundle exec rake test` → existing tests pass.
33
+
34
+ **Steps:**
35
+
36
+ - [ ] **Step 1: Add the derivatives plugin**
37
+
38
+ Edit `test/internal/config/initializers/shrine.rb`. Add this line after the existing plugin calls (e.g. after `Shrine.plugin :refresh_metadata`):
39
+
40
+ ```ruby
41
+ Shrine.plugin :derivatives
42
+ ```
43
+
44
+ - [ ] **Step 2: Run existing tests**
45
+
46
+ Run: `bundle exec rake test`
47
+ Expected: All existing tests continue to pass.
48
+
49
+ - [ ] **Step 3: Commit**
50
+
51
+ ```bash
52
+ git add test/internal/config/initializers/shrine.rb
53
+ git commit -m "test: enable Shrine derivatives plugin in test config"
54
+ ```
55
+
56
+ ## Task 2: Implement `Attachment#url` with variant + strict support
57
+
58
+ **Goal:** Replace the no-arg `url` method with a signature that accepts a variant and a `strict:` kwarg, with TDD driving the full behavior matrix.
59
+
60
+ **Files:**
61
+ - Create: `test/attachment_test.rb`
62
+ - Modify: `lib/active_shrine/attachment.rb` (method at line 40-42)
63
+
64
+ **Acceptance Criteria:**
65
+ - [ ] `Attachment#url` accepts `(variant = nil, strict: false)`.
66
+ - [ ] Non-strict, no variant, stored → returns original storage URL.
67
+ - [ ] Non-strict, no variant, cached → returns cache URL.
68
+ - [ ] Non-strict, variant, stored, derivative present → returns variant URL.
69
+ - [ ] Non-strict, variant, stored, derivative absent → falls back to original URL.
70
+ - [ ] Non-strict, variant, cached → falls back to cache URL.
71
+ - [ ] Strict, no variant, stored → returns original URL.
72
+ - [ ] Strict, no variant, cached → returns nil.
73
+ - [ ] Strict, variant, stored, derivative present → returns variant URL.
74
+ - [ ] Strict, variant, stored, derivative absent → falls back to original URL.
75
+ - [ ] Strict, variant, cached → returns nil.
76
+ - [ ] No existing caller that passes zero args is broken.
77
+
78
+ **Verify:** `bundle exec rake test TEST=test/attachment_test.rb` → all 11 cases pass.
79
+
80
+ **Steps:**
81
+
82
+ - [ ] **Step 1: Write the failing test file**
83
+
84
+ Create `test/attachment_test.rb` with the full matrix. This uses `TestModel` (has_one_attached :file), attaches a real uploaded file, and toggles between cached and stored states by saving the record (which triggers promotion via the inline background blocks in the test setup — the test forces synchronous promotion by calling `file_attacher.promote` directly so we don't depend on ActiveJob).
85
+
86
+ ```ruby
87
+ require "test_helper"
88
+ require "stringio"
89
+
90
+ class AttachmentUrlTest < Minitest::Test
91
+ def setup
92
+ @model = TestModel.create!
93
+ io = StringIO.new("hello world")
94
+ io.define_singleton_method(:original_filename) { "hello.txt" }
95
+ io.define_singleton_method(:content_type) { "text/plain" }
96
+ @model.file = io
97
+ @model.save!
98
+ @attachment = @model.file_attachment
99
+ end
100
+
101
+ def teardown
102
+ @model&.file_attacher&.destroy
103
+ @model&.destroy
104
+ end
105
+
106
+ # --- helpers -------------------------------------------------------------
107
+
108
+ def promote!
109
+ @attachment.file_attacher.promote
110
+ @attachment.save!
111
+ end
112
+
113
+ def add_thumb_derivative!
114
+ promote!
115
+ thumb_io = StringIO.new("thumb bytes")
116
+ @attachment.file_attacher.add_derivatives(thumb: thumb_io)
117
+ @attachment.save!
118
+ end
119
+
120
+ # --- non-strict (default) ------------------------------------------------
121
+
122
+ def test_non_strict_no_variant_stored_returns_original_url
123
+ promote!
124
+ assert_match %r{/uploads/}, @attachment.url
125
+ refute_match %r{/uploads/cache/}, @attachment.url
126
+ end
127
+
128
+ def test_non_strict_no_variant_cached_returns_cache_url
129
+ assert_match %r{/uploads/cache/}, @attachment.url
130
+ end
131
+
132
+ def test_non_strict_variant_stored_with_derivative_returns_variant_url
133
+ add_thumb_derivative!
134
+ assert_match %r{/uploads/.+/thumb}, @attachment.url(:thumb)
135
+ end
136
+
137
+ def test_non_strict_variant_stored_without_derivative_falls_back_to_original
138
+ promote!
139
+ url = @attachment.url(:thumb)
140
+ assert_match %r{/uploads/}, url
141
+ refute_match %r{thumb}, url
142
+ end
143
+
144
+ def test_non_strict_variant_cached_falls_back_to_cache_url
145
+ assert_match %r{/uploads/cache/}, @attachment.url(:thumb)
146
+ end
147
+
148
+ # --- strict --------------------------------------------------------------
149
+
150
+ def test_strict_no_variant_stored_returns_original_url
151
+ promote!
152
+ assert_match %r{/uploads/}, @attachment.url(strict: true)
153
+ end
154
+
155
+ def test_strict_no_variant_cached_returns_nil
156
+ assert_nil @attachment.url(strict: true)
157
+ end
158
+
159
+ def test_strict_variant_stored_with_derivative_returns_variant_url
160
+ add_thumb_derivative!
161
+ assert_match %r{/uploads/.+/thumb}, @attachment.url(:thumb, strict: true)
162
+ end
163
+
164
+ def test_strict_variant_stored_without_derivative_falls_back_to_original
165
+ promote!
166
+ url = @attachment.url(:thumb, strict: true)
167
+ assert_match %r{/uploads/}, url
168
+ refute_match %r{thumb}, url
169
+ end
170
+
171
+ def test_strict_variant_cached_returns_nil
172
+ assert_nil @attachment.url(:thumb, strict: true)
173
+ end
174
+ end
175
+ ```
176
+
177
+ - [ ] **Step 2: Run tests to verify they fail**
178
+
179
+ Run: `bundle exec rake test TEST=test/attachment_test.rb`
180
+ Expected: failures — some pass (the no-arg stored case already works), but tests passing kwargs or variants will fail with `ArgumentError: wrong number of arguments` because the current `url` method takes zero arguments.
181
+
182
+ - [ ] **Step 3: Update `Attachment#url`**
183
+
184
+ In `lib/active_shrine/attachment.rb`, replace the existing method (lines 40-42):
185
+
186
+ ```ruby
187
+ def url
188
+ file_url
189
+ end
190
+ ```
191
+
192
+ with:
193
+
194
+ ```ruby
195
+ # Returns a URL for this attachment.
196
+ #
197
+ # @param variant [Symbol, nil] derivative name (e.g. :thumb). If the
198
+ # derivative does not exist, falls back to the original.
199
+ # @param strict [Boolean] when true, returns nil unless the file has
200
+ # been promoted to permanent storage. Defaults to false, which returns
201
+ # the cache URL during the pending window so uploads are visible
202
+ # immediately.
203
+ def url(variant = nil, strict: false)
204
+ attacher = file_attacher
205
+ return nil if strict && !attacher.stored?
206
+
207
+ (variant && attacher.url(variant)) || attacher.url
208
+ end
209
+ ```
210
+
211
+ - [ ] **Step 4: Run tests to verify they pass**
212
+
213
+ Run: `bundle exec rake test TEST=test/attachment_test.rb`
214
+ Expected: all 11 test cases pass.
215
+
216
+ - [ ] **Step 5: Run the full test suite for regressions**
217
+
218
+ Run: `bundle exec rake test`
219
+ Expected: no regressions in existing tests (the new signature is backward-compatible — existing `url` callers pass zero args and still work).
220
+
221
+ - [ ] **Step 6: Commit**
222
+
223
+ ```bash
224
+ git add lib/active_shrine/attachment.rb test/attachment_test.rb
225
+ git commit -m "feat: support variant and strict mode in Attachment#url"
226
+ ```
227
+
228
+ ---
229
+
230
+ ## Self-Review
231
+
232
+ - **Spec coverage:** both design decisions from the spec (enhance rather than add, lenient default, variant fallback to original) are covered in Task 2. Test matrix matches the spec's "Tests" section 1:1.
233
+ - **Placeholder scan:** no TBD/TODO; all code blocks are concrete.
234
+ - **Type consistency:** `variant`, `strict:`, `file_attacher`, `stored?` used consistently across plan, code, and tests.
235
+ - **Verification requirement scan:** NO — spec contains no human-in-the-loop verification requirement.
@@ -0,0 +1,19 @@
1
+ {
2
+ "planPath": "docs/superpowers/plans/2026-04-14-attachment-url-primitive.md",
3
+ "tasks": [
4
+ {
5
+ "id": 1,
6
+ "subject": "Task 1: Enable derivatives in test shrine config",
7
+ "status": "pending",
8
+ "description": "**Goal:** Add the Shrine `derivatives` plugin to the test environment so tests can exercise variant URLs.\n\n**Files:**\n- Modify: `test/internal/config/initializers/shrine.rb`\n\n**Acceptance Criteria:**\n- [ ] `Shrine.plugin :derivatives` is loaded in the test init file.\n- [ ] Existing test suite still passes.\n\n**Verify:** `bundle exec rake test` → existing tests pass.\n\n```json:metadata\n{\"files\": [\"test/internal/config/initializers/shrine.rb\"], \"verifyCommand\": \"bundle exec rake test\", \"acceptanceCriteria\": [\"derivatives plugin loaded\", \"existing tests pass\"], \"requiresUserVerification\": false}\n```"
9
+ },
10
+ {
11
+ "id": 2,
12
+ "subject": "Task 2: Implement Attachment#url with variant + strict support",
13
+ "status": "pending",
14
+ "blockedBy": [1],
15
+ "description": "**Goal:** Replace the no-arg `url` method with a signature that accepts a variant and a `strict:` kwarg, driven by the full behavior matrix in tests.\n\n**Files:**\n- Create: `test/attachment_test.rb`\n- Modify: `lib/active_shrine/attachment.rb` (method at line 40-42)\n\n**Verify:** `bundle exec rake test TEST=test/attachment_test.rb` → all 11 cases pass.\n\n```json:metadata\n{\"files\": [\"test/attachment_test.rb\", \"lib/active_shrine/attachment.rb\"], \"verifyCommand\": \"bundle exec rake test TEST=test/attachment_test.rb\", \"acceptanceCriteria\": [\"new signature accepts variant and strict\", \"non-strict fallback chain works\", \"strict returns nil until stored\", \"no regressions in existing callers\"], \"requiresUserVerification\": false}\n```"
16
+ }
17
+ ],
18
+ "lastUpdated": "2026-04-14"
19
+ }
@@ -0,0 +1,118 @@
1
+ # Attachment URL primitive
2
+
3
+ ## Problem
4
+
5
+ `ActiveShrine::Attachment#url` (lib/active_shrine/attachment.rb:40) delegates
6
+ to Shrine's `file_url` with no variant handling and no awareness of Shrine's
7
+ two-phase upload lifecycle (cache → promotion → stored). Callers end up
8
+ reimplementing the same safety logic repeatedly:
9
+
10
+ ```ruby
11
+ def image_url(variant = nil)
12
+ attacher = image_attachment&.file_attacher
13
+ return nil unless attacher&.stored?
14
+ variant ? (attacher.url(variant) || attacher.url) : attacher.url
15
+ end
16
+ ```
17
+
18
+ This duplicates three concerns that belong in the gem:
19
+
20
+ 1. Skipping the cache phase.
21
+ 2. Falling back to the original when a derivative is missing.
22
+ 3. Passing a variant through at all.
23
+
24
+ ## Design
25
+
26
+ Enhance `Attachment#url` to accept a variant and a `strict:` kwarg. No new
27
+ method — there should be one way to get a URL for an attachment.
28
+
29
+ ```ruby
30
+ # Returns a URL for this attachment.
31
+ #
32
+ # @param variant [Symbol, nil] derivative name (e.g. :thumb). If the
33
+ # derivative does not exist, falls back to the original.
34
+ # @param strict [Boolean] when true, returns nil unless the file has been
35
+ # promoted to permanent storage. Defaults to false, which returns the
36
+ # cache URL during the pending window so uploads are visible immediately.
37
+ def url(variant = nil, strict: false)
38
+ attacher = file_attacher
39
+ return nil if strict && !attacher.stored?
40
+
41
+ (variant && attacher.url(variant)) || attacher.url
42
+ end
43
+ ```
44
+
45
+ ### Fallback chain
46
+
47
+ Non-strict (default):
48
+
49
+ ```
50
+ variant URL (if stored & derivative exists)
51
+ → original storage URL (if stored)
52
+ → cache URL (if still pending)
53
+ → nil (nothing attached)
54
+ ```
55
+
56
+ Strict:
57
+
58
+ ```
59
+ variant URL (if derivative exists)
60
+ → original storage URL
61
+ → nil (anything not yet promoted)
62
+ ```
63
+
64
+ ### Call sites
65
+
66
+ `Attached::One` and `Attached::Many` use `delegate_missing_to :attachment,
67
+ allow_nil: true` (lib/active_shrine/attached/one.rb:28), so the new signature
68
+ flows through automatically:
69
+
70
+ ```ruby
71
+ user.avatar.url # original; cache URL during pending
72
+ user.avatar.url(:thumb) # thumb → original → cache → nil
73
+ user.avatar.url(:thumb, strict: true) # nil until promoted
74
+ ```
75
+
76
+ ### Design decisions
77
+
78
+ - **Lenient default, strict opt-in.** Active Storage and CarrierWave return
79
+ a usable URL immediately after attach. Defaulting to nil-until-promoted
80
+ would surprise users by making uploads appear to vanish during background
81
+ processing. The strict mode exists for callers who explicitly want to hide
82
+ half-processed attachments.
83
+ - **Enhance `url`, don't add a new method.** Avoids two APIs doing almost
84
+ the same thing. The existing no-arg `url` behavior is preserved.
85
+ - **Variant-missing falls back to original.** Derivatives are often
86
+ aspirational — added after the fact, or missing because processing failed.
87
+ A broken `<img>` tag is worse than a wrong-size image.
88
+ - **No app-wide default for `strict:`.** Per-call only. Global config invites
89
+ action-at-a-distance bugs, and the kwarg is cheap at the call site.
90
+
91
+ ## Tests
92
+
93
+ Exercise the full matrix in `spec/active_shrine/attachment_spec.rb` (or
94
+ equivalent). Required cases:
95
+
96
+ **Non-strict (default):**
97
+ - No variant, stored → original URL.
98
+ - No variant, cached (not stored) → cache URL.
99
+ - Variant, stored, derivative exists → variant URL.
100
+ - Variant, stored, derivative missing → original URL (fallback).
101
+ - Variant, cached → cache URL (fallback through variant/original).
102
+
103
+ **Strict:**
104
+ - No variant, stored → original URL.
105
+ - No variant, cached → nil.
106
+ - Variant, stored, derivative exists → variant URL.
107
+ - Variant, stored, derivative missing → original URL.
108
+ - Variant, cached → nil.
109
+
110
+ Tests should use Shrine's memory storage and derivatives plugin to exercise
111
+ real stored/cached/derivative transitions rather than mocking the attacher.
112
+
113
+ ## Out of scope
114
+
115
+ - Changing `Attached::One`/`Attached::Many` — delegation handles it.
116
+ - Any new helper methods (`derivative_url`, `safe_url`, etc.).
117
+ - Configuration hooks or initializers.
118
+ - Changes to `representable?`, `content_type`, or other attachment metadata.
@@ -37,8 +37,19 @@ module ActiveShrine
37
37
  before_save :maybe_store_record
38
38
 
39
39
  module AttachmentMethods
40
- def url
41
- file_url
40
+ # Returns a URL for this attachment.
41
+ #
42
+ # @param variant [Symbol, nil] derivative name (e.g. :thumb). If the
43
+ # derivative does not exist, falls back to the original.
44
+ # @param strict [Boolean] when true, returns nil unless the file has
45
+ # been promoted to permanent storage. Defaults to false, which
46
+ # returns the cache URL during the pending window so uploads are
47
+ # visible immediately.
48
+ def url(variant = nil, strict: false)
49
+ attacher = file_attacher
50
+ return nil if strict && !attacher.stored?
51
+
52
+ (variant && attacher.url(variant)) || attacher.url
42
53
  end
43
54
 
44
55
  def content_type
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveShrine
4
- VERSION = "0.6.1"
4
+ VERSION = "0.7.0"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: active_shrine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.1
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Radioactive Labs
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2025-08-05 00:00:00.000000000 Z
11
+ date: 2026-04-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: railties
@@ -150,6 +150,34 @@ dependencies:
150
150
  - - ">="
151
151
  - !ruby/object:Gem::Version
152
152
  version: '0'
153
+ - !ruby/object:Gem::Dependency
154
+ name: marcel
155
+ requirement: !ruby/object:Gem::Requirement
156
+ requirements:
157
+ - - ">="
158
+ - !ruby/object:Gem::Version
159
+ version: '0'
160
+ type: :development
161
+ prerelease: false
162
+ version_requirements: !ruby/object:Gem::Requirement
163
+ requirements:
164
+ - - ">="
165
+ - !ruby/object:Gem::Version
166
+ version: '0'
167
+ - !ruby/object:Gem::Dependency
168
+ name: mini_mime
169
+ requirement: !ruby/object:Gem::Requirement
170
+ requirements:
171
+ - - ">="
172
+ - !ruby/object:Gem::Version
173
+ version: '0'
174
+ type: :development
175
+ prerelease: false
176
+ version_requirements: !ruby/object:Gem::Requirement
177
+ requirements:
178
+ - - ">="
179
+ - !ruby/object:Gem::Version
180
+ version: '0'
153
181
  description: Write a longer description or delete this line.
154
182
  email:
155
183
  executables: []
@@ -165,6 +193,9 @@ files:
165
193
  - README.md
166
194
  - Rakefile
167
195
  - config.ru
196
+ - docs/superpowers/plans/2026-04-14-attachment-url-primitive.md
197
+ - docs/superpowers/plans/2026-04-14-attachment-url-primitive.md.tasks.json
198
+ - docs/superpowers/specs/2026-04-14-attachment-url-primitive-design.md
168
199
  - gemfiles/rails_7.1.gemfile
169
200
  - gemfiles/rails_8.gemfile
170
201
  - lib/active_shrine.rb