active_storage_quota 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.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +52 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +404 -0
  5. data/app/controllers/active_storage_quota/direct_uploads_controller.rb +96 -0
  6. data/db/migrate/20260917000001_create_active_storage_quota_tables.rb +72 -0
  7. data/db/migrate/20260918000001_create_active_storage_quota_charges.rb +45 -0
  8. data/lib/active_storage_quota/account.rb +200 -0
  9. data/lib/active_storage_quota/attachable.rb +44 -0
  10. data/lib/active_storage_quota/attachment_accounting.rb +144 -0
  11. data/lib/active_storage_quota/audit/counter_audit.rb +62 -0
  12. data/lib/active_storage_quota/audit/finding.rb +17 -0
  13. data/lib/active_storage_quota/audit/ledger_audit.rb +164 -0
  14. data/lib/active_storage_quota/audit/result.rb +106 -0
  15. data/lib/active_storage_quota/audit.rb +75 -0
  16. data/lib/active_storage_quota/backfill/cursor.rb +64 -0
  17. data/lib/active_storage_quota/backfill/result.rb +146 -0
  18. data/lib/active_storage_quota/backfill.rb +310 -0
  19. data/lib/active_storage_quota/blob_accounting.rb +44 -0
  20. data/lib/active_storage_quota/charge.rb +379 -0
  21. data/lib/active_storage_quota/configuration.rb +37 -0
  22. data/lib/active_storage_quota/definition.rb +100 -0
  23. data/lib/active_storage_quota/engine.rb +28 -0
  24. data/lib/active_storage_quota/errors.rb +115 -0
  25. data/lib/active_storage_quota/owner.rb +154 -0
  26. data/lib/active_storage_quota/owner_resolver.rb +402 -0
  27. data/lib/active_storage_quota/owner_source.rb +88 -0
  28. data/lib/active_storage_quota/reconciliation/budget.rb +70 -0
  29. data/lib/active_storage_quota/reconciliation/counter_reconciler.rb +78 -0
  30. data/lib/active_storage_quota/reconciliation/ledger_reconciler.rb +250 -0
  31. data/lib/active_storage_quota/reconciliation/result.rb +107 -0
  32. data/lib/active_storage_quota/reconciliation.rb +72 -0
  33. data/lib/active_storage_quota/record.rb +20 -0
  34. data/lib/active_storage_quota/reservation.rb +297 -0
  35. data/lib/active_storage_quota/version.rb +5 -0
  36. data/lib/active_storage_quota.rb +207 -0
  37. data/lib/tasks/active_storage_quota.rake +11 -0
  38. data/lib/tasks/active_storage_quota_audit.rake +15 -0
  39. data/lib/tasks/active_storage_quota_backfill.rake +104 -0
  40. data/lib/tasks/active_storage_quota_reconcile.rake +100 -0
  41. metadata +150 -0
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ module ActiveStorageQuota
6
+ # Mixed into ActiveStorage::Blob through the :active_storage_blob load hook.
7
+ #
8
+ # This is how purging is observed. ActiveStorage::Attachment#purge deletes the
9
+ # attachment row with `delete`, firing no callbacks, and then destroys the
10
+ # blob -- and that destruction does run callbacks. So the blob is where a
11
+ # purge becomes visible to accounting.
12
+ #
13
+ # Idempotent against AttachmentAccounting: whichever path deletes the charge
14
+ # row is the one that lowers used_bytes, and the other finds nothing left.
15
+ #
16
+ # A blob shared by several attachments is not destroyed at all (Blob's own
17
+ # before_destroy raises InvalidForeignKey, which purge swallows), so purging
18
+ # one attachment of a shared blob is invisible here too. That remains a
19
+ # reconciliation case.
20
+ module BlobAccounting
21
+ extend ActiveSupport::Concern
22
+
23
+ included do
24
+ after_destroy_commit :discard_storage_quota_charges
25
+ end
26
+
27
+ private
28
+ # Two independent cleanups. They are isolated from each other on purpose:
29
+ # a failure discarding charges must not stop reservations being released,
30
+ # and neither may ever roll back the deletion of a user's file.
31
+ def discard_storage_quota_charges
32
+ cleanup("blob_destroy") { ActiveStorageQuota::Charge.discard_blob!(blob_id: id) }
33
+ cleanup("blob_destroy_reservations") do
34
+ ActiveStorageQuota::Reservation.release_active_for_blob!(blob_id: id)
35
+ end
36
+ end
37
+
38
+ def cleanup(operation)
39
+ yield
40
+ rescue StandardError => e
41
+ ActiveStorageQuota.accounting_failed(operation: operation, exception: e, blob_id: id.to_s)
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,379 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveStorageQuota
4
+ # The accounting ledger: one row per (account, blob) meaning "this account is
5
+ # charged this blob's size, once".
6
+ #
7
+ # Three layers hold storage state, and they are not interchangeable:
8
+ #
9
+ # ActiveStorage::Attachment ground truth for ownership. Only a full rebuild
10
+ # reading live attachments can restore it.
11
+ # Charge canonical ledger during normal operation.
12
+ # Account#used_bytes denormalized aggregate of charges.
13
+ #
14
+ # The write-path invariant is:
15
+ #
16
+ # account.used_bytes == account.charges.sum(:byte_size)
17
+ #
18
+ # It is structural, not hoped for: +record!+ is the only operation that adds
19
+ # to used_bytes and +discard!+ the only one that subtracts, and each does so
20
+ # in the same transaction that inserts or deletes the charge row. The charge
21
+ # row is the claim -- double counting would need a duplicate row, which the
22
+ # unique index forbids.
23
+ #
24
+ # Charges *can* drift from attachments, because Active Storage's detach and
25
+ # shared-blob purge paths fire no callbacks and deletion accounting fails
26
+ # open. That is what reconciliation is for, and why it has two levels:
27
+ # charges repair used_bytes, attachments rebuild charges.
28
+ class Charge < Record
29
+ self.table_name = "active_storage_quota_charges"
30
+
31
+ # A charge can be created and destroyed underneath us by concurrent attach
32
+ # and detach activity. Each retry is correct; this bounds how long we keep
33
+ # losing before giving up.
34
+ MAX_ATTEMPTS = 3
35
+
36
+ belongs_to :quota_account,
37
+ class_name: "ActiveStorageQuota::Account",
38
+ inverse_of: :charges
39
+
40
+ validates :blob_id, presence: true
41
+ validates :byte_size, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
42
+ validates :attachment_count, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
43
+
44
+ class << self
45
+ # Records one attachment of +blob_id+ against +account+.
46
+ #
47
+ # The first attachment inserts a charge and raises used_bytes by the
48
+ # blob's size, consuming a matching reservation if one is held. Later
49
+ # attachments of the same blob to the same account only raise the
50
+ # reference count.
51
+ #
52
+ # Raises QuotaExceeded when the bytes do not fit, and
53
+ # ConcurrentAccountingConflict when contention outlasts MAX_ATTEMPTS.
54
+ def record!(account:, blob_id:, byte_size:, limit_bytes:)
55
+ blob_id = blob_id.to_s
56
+
57
+ MAX_ATTEMPTS.times do
58
+ outcome = attempt_record(account, blob_id, byte_size, limit_bytes)
59
+
60
+ return outcome unless outcome == :contended
61
+ end
62
+
63
+ raise ConcurrentAccountingConflict,
64
+ "could not record a charge for account #{account.id} and blob " \
65
+ "#{blob_id} after #{MAX_ATTEMPTS} attempts; the charge is being " \
66
+ "created and destroyed concurrently"
67
+ end
68
+
69
+ # Records the removal of one attachment.
70
+ #
71
+ # Returns true only when this call removed the last attachment and so
72
+ # lowered used_bytes.
73
+ def discard!(quota_account_id:, blob_id:)
74
+ blob_id = blob_id.to_s
75
+
76
+ transaction(requires_new: true) do
77
+ charge = find_by(quota_account_id: quota_account_id, blob_id: blob_id)
78
+
79
+ if charge.nil?
80
+ false
81
+ else
82
+ discard_one(quota_account_id, blob_id, charge.byte_size)
83
+ end
84
+ end
85
+ end
86
+
87
+ # Re-points one of +account+'s attachments from one blob to another, as
88
+ # ActiveStorage::Attachment#update!(blob: other) does. Internal: called
89
+ # from the attachment's after_update, inside its transaction.
90
+ #
91
+ # One transaction, so a refusal leaves both blobs exactly as they were.
92
+ # The old reference is dropped before the new one is claimed, so the bytes
93
+ # an in-place replacement frees can make room for it.
94
+ #
95
+ # Every other accounting path locks the charge row and then the account,
96
+ # so this takes both charge rows first -- in blob_id order, so two moves in
97
+ # opposite directions queue rather than deadlock -- and only then touches
98
+ # the account. A charge that does not exist yet cannot be locked, so the
99
+ # new blob's row is inserted provisionally, carrying no usage, to hold it.
100
+ def move!(account:, from_blob_id:, to_blob_id:, byte_size:, limit_bytes:) # :nodoc:
101
+ from = from_blob_id.to_s
102
+ to = to_blob_id.to_s
103
+
104
+ transaction(requires_new: true) do
105
+ old_charge = nil
106
+ new_charge = nil
107
+ provisional = false
108
+
109
+ [ from, to ].sort.each do |blob_id|
110
+ if blob_id == from
111
+ old_charge = lock_pair(account.id, from)
112
+ else
113
+ new_charge, provisional = claim_pair(account.id, to, byte_size)
114
+ end
115
+ end
116
+
117
+ discard_one(account.id, from, old_charge.byte_size) if old_charge
118
+
119
+ if provisional
120
+ where(id: new_charge.id).update_all([ "attachment_count = 1, updated_at = ?", Time.current ])
121
+ claim_usage!(account, new_charge, limit_bytes)
122
+ else
123
+ where(id: new_charge.id)
124
+ .update_all([ "attachment_count = attachment_count + 1, updated_at = ?", Time.current ])
125
+ # Already charged, so a hold taken for this blob bought nothing.
126
+ Reservation.release_matching!(quota_account_id: account.id, blob_id: to)
127
+ end
128
+ end
129
+ end
130
+
131
+ # Removes every charge for a blob that no longer exists, across all
132
+ # accounts. Each account is settled in its own transaction so one failure
133
+ # cannot strand the others.
134
+ #
135
+ # Idempotent against +discard!+: whichever path deletes the row is the one
136
+ # that lowers used_bytes.
137
+ def discard_blob!(blob_id:)
138
+ rows = where(blob_id: blob_id.to_s).pluck(:id, :quota_account_id, :byte_size)
139
+
140
+ rows.count do |id, account_id, bytes|
141
+ transaction(requires_new: true) do
142
+ if where(id: id).delete_all == 1
143
+ Account.release_used_bytes!(account_id, bytes)
144
+ true
145
+ else
146
+ false
147
+ end
148
+ end
149
+ end
150
+ end
151
+
152
+ # Repairs one (account, blob) pair to match live Active Storage ownership.
153
+ #
154
+ # Deliberately NOT built on record!. Normal accounting is delta-based --
155
+ # one attach means attachment_count += 1 -- while repair is absolute:
156
+ # attachment_count becomes the live count, whatever it was before. Calling
157
+ # record! here would add one on every run and make reconciliation
158
+ # non-idempotent while amplifying the drift it is meant to remove.
159
+ #
160
+ # The block recounts live attachments and is called only *after* this
161
+ # transaction owns the charge row, which is what closes the race against a
162
+ # concurrent detach (see the provisional insert below).
163
+ #
164
+ # Returns :created, :removed, :count_corrected, :deletion_skipped or :noop.
165
+ def reconcile_pair!(quota_account_id:, blob_id:, blob_byte_size:, allow_deletion:, &recount)
166
+ blob_id = blob_id.to_s
167
+
168
+ transaction(requires_new: true) do
169
+ charge = lock_pair(quota_account_id, blob_id)
170
+
171
+ if charge
172
+ repair_existing(charge, allow_deletion, &recount)
173
+ elsif blob_byte_size.nil?
174
+ # No charge and no blob to size one from: nothing to create.
175
+ :noop
176
+ else
177
+ create_if_still_needed(quota_account_id, blob_id, blob_byte_size, allow_deletion, &recount)
178
+ end
179
+ end
180
+ end
181
+
182
+ # What reconcile_pair! would do, without locks or writes. Shares the
183
+ # decision table with the real thing so a plan cannot drift from the
184
+ # repair it predicts.
185
+ def plan_pair(quota_account_id:, blob_id:, allow_deletion:, expected_count:)
186
+ charge = find_by(quota_account_id: quota_account_id, blob_id: blob_id.to_s)
187
+
188
+ decide(
189
+ charge_present: !charge.nil?,
190
+ charge_count: charge&.attachment_count,
191
+ expected_count: expected_count,
192
+ allow_deletion: allow_deletion
193
+ )
194
+ end
195
+
196
+ # The single decision table, used by both apply and dry run.
197
+ def decide(charge_present:, charge_count:, expected_count:, allow_deletion:)
198
+ if !charge_present
199
+ expected_count.positive? ? :created : :noop
200
+ elsif expected_count.zero?
201
+ allow_deletion ? :removed : :deletion_skipped
202
+ elsif charge_count != expected_count
203
+ :count_corrected
204
+ else
205
+ :noop
206
+ end
207
+ end
208
+
209
+ private
210
+ def lock_pair(quota_account_id, blob_id)
211
+ lock.find_by(quota_account_id: quota_account_id, blob_id: blob_id)
212
+ end
213
+
214
+ def repair_existing(charge, allow_deletion, &recount)
215
+ expected = recount.call
216
+
217
+ case decide(charge_present: true, charge_count: charge.attachment_count,
218
+ expected_count: expected, allow_deletion: allow_deletion)
219
+ when :removed
220
+ # We hold the row lock, so nothing else can have taken it.
221
+ unless where(id: charge.id).delete_all == 1
222
+ raise CounterInvariantViolation,
223
+ "charge #{charge.id} vanished while this transaction held its lock"
224
+ end
225
+
226
+ Account.release_used_bytes!(charge.quota_account_id, charge.byte_size)
227
+ :removed
228
+ when :count_corrected
229
+ where(id: charge.id)
230
+ .update_all([ "attachment_count = ?, updated_at = ?", expected, Time.current ])
231
+ :count_corrected
232
+ when :deletion_skipped
233
+ :deletion_skipped
234
+ else
235
+ :noop
236
+ end
237
+ end
238
+
239
+ # A charge that does not exist cannot be locked, so we create a
240
+ # provisional row carrying no usage, and only then read the live
241
+ # attachment state. Because the row is ours until commit, a concurrent
242
+ # detach either lands before the recount (and we drop the provisional)
243
+ # or blocks behind us (and adjusts the finished charge afterwards).
244
+ # Reading attachments *before* owning a row would let a deletion slip
245
+ # between the read and the insert, leaving a charge nothing backs.
246
+ def create_if_still_needed(quota_account_id, blob_id, blob_byte_size, allow_deletion, &recount)
247
+ charge = insert_provisional(quota_account_id, blob_id, blob_byte_size)
248
+
249
+ if charge.nil?
250
+ # Live accounting won the insert. Take the winner under lock and
251
+ # treat it as an existing pair; never increment for our own sake.
252
+ winner = lock_pair(quota_account_id, blob_id)
253
+ return :noop if winner.nil?
254
+
255
+ return repair_existing(winner, allow_deletion, &recount)
256
+ end
257
+
258
+ expected = recount.call
259
+
260
+ if expected.zero?
261
+ # Nothing to charge after all. The provisional never carried usage,
262
+ # so removing it is not a destructive repair.
263
+ where(id: charge.id).delete_all
264
+ :noop
265
+ else
266
+ where(id: charge.id)
267
+ .update_all([ "attachment_count = ?, updated_at = ?", expected, Time.current ])
268
+ claim_usage_for_repair!(charge)
269
+ :created
270
+ end
271
+ end
272
+
273
+ # The charge row for a move's new blob, held by this transaction: the
274
+ # existing one under lock, or a provisional one we inserted. Returns
275
+ # [charge, provisional?]. Bounded like record!, for the same reason.
276
+ def claim_pair(quota_account_id, blob_id, byte_size)
277
+ MAX_ATTEMPTS.times do
278
+ inserted = insert_provisional(quota_account_id, blob_id, byte_size)
279
+ return [ inserted, true ] if inserted
280
+
281
+ existing = lock_pair(quota_account_id, blob_id)
282
+ return [ existing, false ] if existing
283
+ end
284
+
285
+ raise ConcurrentAccountingConflict,
286
+ "could not move an attachment onto blob #{blob_id} for account " \
287
+ "#{quota_account_id} after #{MAX_ATTEMPTS} attempts; its charge is " \
288
+ "being created and destroyed concurrently"
289
+ end
290
+
291
+ def insert_provisional(quota_account_id, blob_id, blob_byte_size)
292
+ transaction(requires_new: true) do
293
+ create!(quota_account_id: quota_account_id, blob_id: blob_id,
294
+ byte_size: blob_byte_size, attachment_count: 0)
295
+ end
296
+ rescue ActiveRecord::RecordNotUnique
297
+ nil
298
+ end
299
+
300
+ # Repair is truth restoration, not admission control, so capacity is
301
+ # claimed with a nil limit: an owner whose files moved to them is
302
+ # charged even when that leaves them over quota. Future uploads are
303
+ # blocked normally by the usual guarded claim.
304
+ def claim_usage_for_repair!(charge)
305
+ return if Reservation.consume_matching!(charge: charge)
306
+
307
+ Account.find(charge.quota_account_id)
308
+ .claim_used_capacity!(charge.byte_size, nil)
309
+ end
310
+
311
+ def attempt_record(account, blob_id, byte_size, limit_bytes)
312
+ transaction(requires_new: true) do
313
+ charge = insert_charge(account, blob_id, byte_size)
314
+
315
+ if charge
316
+ claim_usage!(account, charge, limit_bytes)
317
+ charge
318
+ else
319
+ add_attachment(account, blob_id)
320
+ end
321
+ end
322
+ end
323
+
324
+ # The INSERT is the claim. Only the transaction that wins it may raise
325
+ # used_bytes. A savepoint because on PostgreSQL a constraint violation
326
+ # poisons the enclosing transaction, and this one is nested.
327
+ def insert_charge(account, blob_id, byte_size)
328
+ transaction(requires_new: true) do
329
+ create!(
330
+ quota_account_id: account.id,
331
+ blob_id: blob_id,
332
+ byte_size: byte_size,
333
+ attachment_count: 1
334
+ )
335
+ end
336
+ rescue ActiveRecord::RecordNotUnique
337
+ nil
338
+ end
339
+
340
+ def add_attachment(account, blob_id)
341
+ affected = where(quota_account_id: account.id, blob_id: blob_id)
342
+ .update_all([ "attachment_count = attachment_count + 1, updated_at = ?", Time.current ])
343
+
344
+ # A concurrent final detach deleted the row between our INSERT losing
345
+ # and this UPDATE running. Start over.
346
+ return :contended if affected.zero?
347
+
348
+ # These bytes were already charged, so any hold we took bought nothing.
349
+ Reservation.release_matching!(quota_account_id: account.id, blob_id: blob_id)
350
+
351
+ find_by(quota_account_id: account.id, blob_id: blob_id)
352
+ end
353
+
354
+ # used_bytes must rise by exactly the blob's size, once. A held
355
+ # reservation supplies that capacity; otherwise it is claimed fresh.
356
+ def claim_usage!(account, charge, limit_bytes)
357
+ return if Reservation.consume_matching!(charge: charge)
358
+
359
+ account.claim_used_capacity!(charge.byte_size, limit_bytes)
360
+ end
361
+
362
+ # Both statements share a transaction deliberately: the UPDATE holds the
363
+ # row's write lock through the DELETE, so a concurrent attach cannot
364
+ # change the count in between. Splitting them loses attachments.
365
+ def discard_one(quota_account_id, blob_id, byte_size)
366
+ now = Time.current
367
+ scope = where(quota_account_id: quota_account_id, blob_id: blob_id)
368
+
369
+ scope.where("attachment_count > 0")
370
+ .update_all([ "attachment_count = attachment_count - 1, updated_at = ?", now ])
371
+
372
+ return false unless scope.where(attachment_count: 0).delete_all == 1
373
+
374
+ Account.release_used_bytes!(quota_account_id, byte_size, now)
375
+ true
376
+ end
377
+ end
378
+ end
379
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveStorageQuota
4
+ # Gem-wide settings. Access through ActiveStorageQuota.config or set them in
5
+ # an initializer:
6
+ #
7
+ # ActiveStorageQuota.configure do |config|
8
+ # config.reservation_ttl = 30.minutes
9
+ # end
10
+ class Configuration
11
+ # One hour, in seconds. Long enough for a slow client to finish a large
12
+ # direct upload, short enough that a crashed process does not hold quota
13
+ # for the rest of the day.
14
+ DEFAULT_RESERVATION_TTL = 3_600
15
+
16
+ # How long a reservation stays committable, as an Integer number of seconds.
17
+ #
18
+ # Assigning an ActiveSupport::Duration is supported and normalised, so
19
+ # +config.reservation_ttl = 1.hour+ reads back as +3600+. Every byte and
20
+ # duration this gem exposes is an Integer.
21
+ attr_reader :reservation_ttl
22
+
23
+ def initialize
24
+ @reservation_ttl = DEFAULT_RESERVATION_TTL
25
+ end
26
+
27
+ def reservation_ttl=(value)
28
+ unless value.is_a?(Numeric) && value.to_i.positive?
29
+ raise ConfigurationError,
30
+ "reservation_ttl must be a positive number of seconds " \
31
+ "(got #{value.inspect})"
32
+ end
33
+
34
+ @reservation_ttl = value.to_i
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveStorageQuota
4
+ # Resolves the storage limit declared by +has_storage_quota+.
5
+ #
6
+ # A declaration is one of:
7
+ #
8
+ # limit: 5.gigabytes # Integer number of bytes
9
+ # limit: :storage_limit_for_plan # Symbol, a method on the owner
10
+ # limit: ->(owner) { ... } # callable taking the owner
11
+ # limit: -> { ... } # callable evaluated against the owner
12
+ # limit: nil # unlimited
13
+ #
14
+ # Whatever the declaration, the resolved value must be an Integer of zero or
15
+ # more bytes, or nil for unlimited. Nothing is coerced: a limit of "5000" or
16
+ # 5.0 is a bug in the host application, not a number to round off, so it
17
+ # raises ConfigurationError rather than silently calling +to_i+.
18
+ #
19
+ # Static declarations are validated when the class body is evaluated. Symbol
20
+ # and callable declarations can only be validated once resolved, so they are
21
+ # checked on every call.
22
+ class Definition
23
+ # Arities a callable limit may declare: zero (evaluated against the owner)
24
+ # or one (passed the owner).
25
+ SUPPORTED_ARITIES = [ 0, 1 ].freeze
26
+
27
+ attr_reader :limit
28
+
29
+ def initialize(limit:)
30
+ @limit = limit
31
+ validate_declaration!
32
+ end
33
+
34
+ # The limit for +owner+, in bytes. Returns nil when unlimited.
35
+ #
36
+ # Raises ConfigurationError if a Symbol or callable declaration resolves to
37
+ # something that is not an Integer >= 0 or nil.
38
+ def resolve(owner)
39
+ validate_resolved(call_limit(owner), owner)
40
+ end
41
+
42
+ private
43
+ def call_limit(owner)
44
+ case limit
45
+ when nil, Integer then limit
46
+ when Symbol then owner.public_send(limit)
47
+ else
48
+ limit.arity.zero? ? owner.instance_exec(&limit) : limit.call(owner)
49
+ end
50
+ end
51
+
52
+ def validate_declaration!
53
+ case limit
54
+ when nil then nil
55
+ when Integer then validate_non_negative!(limit)
56
+ when Symbol then nil
57
+ else
58
+ unless limit.respond_to?(:call)
59
+ raise ConfigurationError,
60
+ "has_storage_quota limit: must be an Integer number of bytes, " \
61
+ "nil (unlimited), a Symbol naming a method on the owner, or a " \
62
+ "callable; got #{limit.inspect} (#{limit.class})"
63
+ end
64
+
65
+ unless SUPPORTED_ARITIES.include?(limit.arity)
66
+ raise ConfigurationError,
67
+ "has_storage_quota limit: a callable must take no arguments " \
68
+ "(evaluated against the owner) or one argument (the owner); " \
69
+ "got arity #{limit.arity}"
70
+ end
71
+ end
72
+ end
73
+
74
+ def validate_non_negative!(bytes)
75
+ return bytes unless bytes.negative?
76
+
77
+ raise ConfigurationError,
78
+ "has_storage_quota limit: must be zero or more bytes; got #{bytes}"
79
+ end
80
+
81
+ def validate_resolved(bytes, owner)
82
+ return nil if bytes.nil?
83
+
84
+ unless bytes.is_a?(Integer)
85
+ raise ConfigurationError,
86
+ "#{owner.class.name} resolved its storage limit to " \
87
+ "#{bytes.inspect} (#{bytes.class}); expected an Integer number " \
88
+ "of bytes, or nil for unlimited"
89
+ end
90
+
91
+ if bytes.negative?
92
+ raise ConfigurationError,
93
+ "#{owner.class.name} resolved its storage limit to #{bytes}; " \
94
+ "expected zero or more bytes"
95
+ end
96
+
97
+ bytes
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/engine"
4
+
5
+ module ActiveStorageQuota
6
+ # Makes the gem's migrations available to the host application via
7
+ #
8
+ # bin/rails active_storage_quota:install:migrations
9
+ #
10
+ # and its app/controllers autoloadable, so applications can subclass
11
+ # ActiveStorageQuota::DirectUploadsController without requiring anything.
12
+ #
13
+ # There is deliberately no rake_tasks block: Rails::Engine#run_tasks_blocks
14
+ # already loads every lib/tasks/*.rake in the engine. Loading them again here
15
+ # would define each task twice, and Rake *appends* actions rather than
16
+ # replacing them -- so every task body would run twice.
17
+ #
18
+ # The engine is otherwise thin: no routes, no controllers to mount, nothing to
19
+ # isolate. Applications opt into the direct-upload endpoint by subclassing and
20
+ # routing it themselves.
21
+ class Engine < ::Rails::Engine
22
+ # Without this, Rails derives the namespace from the class name and the
23
+ # migration task becomes active_storage_quota_engine:install:migrations,
24
+ # which is both undocumented and inconsistent with every other task this
25
+ # gem ships under active_storage_quota:.
26
+ railtie_name "active_storage_quota"
27
+ end
28
+ end