activestorage-aws-record 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.
@@ -0,0 +1,417 @@
1
+ require 'active_storage/aws_record/item'
2
+ require 'active_storage/aws_record/owner'
3
+
4
+ module ActiveStorage
5
+ module AwsRecord
6
+ # The blob metadata item. Mirrors the behavior of the default Active Record
7
+ # blob (and the in-memory reference backend) but persists through aws-record.
8
+ # Bytes still live in the configured Active Storage Service; only metadata is
9
+ # here. It is also an attachment owner (for +preview_image+) and carries a
10
+ # strongly-consistent +attachments_count+ used by #destroy to protect shared
11
+ # blobs.
12
+ #
13
+ # Single-table layout: the blob and all of its variant records share one
14
+ # partition (+ns#Blob#<id>+); the blob's own item is the collection root.
15
+ class Blob
16
+ include ActiveStorage::AwsRecord::Item
17
+ include ActiveStorage::AwsRecord::Owner
18
+ include ActiveStorage::Servable
19
+
20
+ MINIMUM_TOKEN_LENGTH = 28
21
+
22
+ # Every non-key attribute carries an explicit, namespaced DynamoDB name so
23
+ # it can never collide with the table's (app-chosen) key attribute names.
24
+ string_attr :id, database_attribute_name: 'as_id'
25
+ string_attr :key, database_attribute_name: 'as_key'
26
+ string_attr :filename, database_attribute_name: 'as_filename'
27
+ string_attr :content_type, database_attribute_name: 'as_content_type'
28
+ integer_attr :byte_size, database_attribute_name: 'as_byte_size'
29
+ string_attr :checksum, database_attribute_name: 'as_checksum'
30
+ # Fiber/thread-safe default: a lambda yields a fresh hash per instance
31
+ # instead of sharing one mutable hash across all records.
32
+ map_attr :metadata, database_attribute_name: 'as_metadata', default_value: -> { {} }
33
+ string_attr :service_name, database_attribute_name: 'as_service_name'
34
+ string_attr :created_at, database_attribute_name: 'as_created_at'
35
+ integer_attr :attachments_count, database_attribute_name: 'as_attachments_count', default_value: 0
36
+ string_attr :entity, database_attribute_name: 'as_entity', default_value: 'Blob'
37
+
38
+ attr_accessor :local_io
39
+
40
+ class << self
41
+ def services
42
+ ActiveStorage::Services.registry
43
+ end
44
+
45
+ def services=(registry)
46
+ ActiveStorage::Services.registry = registry
47
+ end
48
+
49
+ def service
50
+ ActiveStorage::Services.default
51
+ end
52
+
53
+ def service=(service)
54
+ ActiveStorage::Services.default = service
55
+ end
56
+
57
+ # Microsecond-precision, fixed-width UTC ISO8601 — lexically sortable (so
58
+ # has_many ordering by created_at is correct) and parseable back to a Time.
59
+ def current_timestamp
60
+ Time.now.utc.iso8601(6)
61
+ end
62
+
63
+ def generate_unique_secure_token(length: MINIMUM_TOKEN_LENGTH)
64
+ SecureRandom.base36(length)
65
+ end
66
+
67
+ # Contract +find(id)+: GetItem on the blob's collection-root key.
68
+ def find(id)
69
+ keys = logical_keys_for(id)
70
+ get_item(**keys) ||
71
+ raise(ActiveStorage::RecordNotFound, "Couldn't find #{name} with id=#{id.inspect}")
72
+ rescue ArgumentError, Aws::Record::Errors::KeyMissing
73
+ raise ActiveStorage::RecordNotFound, "Couldn't find #{name} with id=#{id.inspect}"
74
+ end
75
+
76
+ # Owner resolution for Active Storage (Blob is itself an attachment owner
77
+ # via +preview_image+). Delegate to the composite-key +#find+: {Attachable}'s
78
+ # default +find_with_opts(hash_key => id)+ adapter cannot address the
79
+ # +ns#Blob#<id>+ key.
80
+ def active_storage_find(id)
81
+ find(id)
82
+ end
83
+
84
+ # Logical keys for a blob id without an instance (used by the attachment
85
+ # refcount transaction). The blob and its variants share one partition.
86
+ def logical_keys_for(blob_id)
87
+ root = ns_key('Blob', blob_id)
88
+ { h: root, r: root, item_id: root }
89
+ end
90
+
91
+ def build_after_unfurling(key: nil, io:, filename:, content_type: nil, metadata: nil, service_name: nil, identify: true, record: nil)
92
+ new(key: key, filename: filename, content_type: content_type, metadata: metadata, service_name: service_name).tap do |blob|
93
+ blob.unfurl(io, identify: identify)
94
+ end
95
+ end
96
+
97
+ def create_after_unfurling!(key: nil, io:, filename:, content_type: nil, metadata: nil, service_name: nil, identify: true, record: nil)
98
+ build_after_unfurling(key: key, io: io, filename: filename, content_type: content_type, metadata: metadata, service_name: service_name, identify: identify, record: record).tap(&:save!)
99
+ end
100
+
101
+ def create_and_upload!(key: nil, io:, filename:, content_type: nil, metadata: nil, service_name: nil, identify: true, record: nil)
102
+ create_after_unfurling!(key: key, io: io, filename: filename, content_type: content_type, metadata: metadata, service_name: service_name, identify: identify, record: record).tap do |blob|
103
+ blob.upload_without_unfurling(io)
104
+ end
105
+ end
106
+
107
+ def create_before_direct_upload!(key: nil, filename:, byte_size:, checksum:, content_type: nil, metadata: nil, service_name: nil, record: nil)
108
+ metadata = ActiveStorage.filter_blob_metadata(metadata || {})
109
+ new(key: key, filename: filename, byte_size: byte_size, checksum: checksum, content_type: content_type, metadata: metadata, service_name: service_name).tap(&:save!)
110
+ end
111
+
112
+ def find_signed(id, record: nil, purpose: :blob_id)
113
+ find_signed!(id, record: record, purpose: purpose)
114
+ rescue ActiveSupport::MessageVerifier::InvalidSignature, ActiveStorage::RecordNotFound
115
+ nil
116
+ end
117
+
118
+ def find_signed!(id, record: nil, purpose: :blob_id)
119
+ find(ActiveStorage.verifier.verify(id, purpose: purpose.to_s))
120
+ end
121
+
122
+ def scope_for_strict_loading
123
+ self
124
+ end
125
+ end
126
+
127
+ def initialize(attributes = {})
128
+ super
129
+ self.id ||= generate_uuid
130
+ self.key ||= self.class.generate_unique_secure_token
131
+ self.metadata ||= {}
132
+ self.service_name ||= self.class.service&.name&.to_s
133
+ self.created_at ||= self.class.current_timestamp
134
+ self.attachments_count ||= 0
135
+ end
136
+
137
+ # Logical keys: the blob and its variants share the +ns#Blob#<id>+ partition;
138
+ # the blob's own sort key mirrors the partition (the collection root).
139
+ def logical_keys
140
+ self.class.logical_keys_for(id)
141
+ end
142
+
143
+ def signed_id(purpose: :blob_id, expires_in: nil, expires_at: nil)
144
+ ActiveStorage.verifier.generate(id, purpose: purpose.to_s, expires_in: expires_in, expires_at: expires_at)
145
+ end
146
+
147
+ # Returns a Time (not the stored String), because Active Storage's proxy
148
+ # controller passes +blob.created_at+ to +http_cache_forever+, which calls
149
+ # +.utc+ on it for the Last-Modified header.
150
+ def created_at
151
+ raw = read_attribute(:created_at)
152
+ return raw unless raw.is_a?(String)
153
+
154
+ Time.iso8601(raw)
155
+ rescue ArgumentError
156
+ nil
157
+ end
158
+
159
+ def filename
160
+ ActiveStorage::Filename.new(read_attribute(:filename).to_s)
161
+ end
162
+
163
+ def filename=(value)
164
+ write_attribute(:filename, value&.to_s)
165
+ end
166
+
167
+ def custom_metadata
168
+ indifferent_metadata[:custom] || {}
169
+ end
170
+
171
+ def identified = !!indifferent_metadata[:identified]
172
+ def identified?(*) = identified
173
+ def identified=(value)
174
+ metadata_set(:identified, value)
175
+ end
176
+
177
+ def analyzed = !!indifferent_metadata[:analyzed]
178
+ def analyzed?(*) = analyzed
179
+ def analyzed=(value)
180
+ metadata_set(:analyzed, value)
181
+ end
182
+
183
+ def composed = !!indifferent_metadata[:composed]
184
+ def composed=(value)
185
+ metadata_set(:composed, value)
186
+ end
187
+
188
+ def identify_without_saving
189
+ return if identified?
190
+
191
+ self.content_type ||= 'application/octet-stream'
192
+ self.identified = true
193
+ end
194
+
195
+ def analyze_without_saving
196
+ metadata_set(:analyzed, true)
197
+ end
198
+
199
+ def analyze
200
+ analyze_without_saving
201
+ save!
202
+ end
203
+
204
+ def analyze_later
205
+ ActiveStorage::AnalyzeJob.perform_later(self)
206
+ end
207
+
208
+ def upload(io, identify: true)
209
+ unfurl(io, identify: identify)
210
+ upload_without_unfurling(io)
211
+ end
212
+
213
+ def unfurl(io, identify: true)
214
+ self.checksum = service.compute_checksum(io)
215
+ self.content_type = Marcel::MimeType.for(io, name: filename.to_s, declared_type: content_type) if content_type.nil? || identify
216
+ self.byte_size = io.size
217
+ self.identified = true
218
+ end
219
+
220
+ def upload_without_unfurling(io)
221
+ service.upload(key, io, checksum: checksum, content_type: content_type)
222
+ end
223
+
224
+ def download(&block)
225
+ service.download(key, &block)
226
+ end
227
+
228
+ def download_chunk(range)
229
+ service.download_chunk(key, range)
230
+ end
231
+
232
+ def open(tmpdir: nil, &block)
233
+ if local_io
234
+ open_local_io(tmpdir: tmpdir, &block)
235
+ else
236
+ service.open(key, checksum: checksum, verify: !composed,
237
+ name: ["ActiveStorage-#{id}-", filename.extension_with_delimiter], tmpdir: tmpdir, &block)
238
+ end
239
+ end
240
+
241
+ def url(expires_in: ActiveStorage.service_urls_expire_in, disposition: :inline, filename: nil, **options)
242
+ service.url(key, expires_in: expires_in, filename: ActiveStorage::Filename.wrap(filename || self.filename),
243
+ content_type: content_type_for_serving, disposition: forced_disposition_for_serving || disposition, **options)
244
+ end
245
+
246
+ def service_url_for_direct_upload(expires_in: ActiveStorage.service_urls_expire_in)
247
+ service.url_for_direct_upload(key, expires_in: expires_in, content_type: content_type,
248
+ content_length: byte_size, checksum: checksum, custom_metadata: custom_metadata)
249
+ end
250
+
251
+ def service_headers_for_direct_upload
252
+ service.headers_for_direct_upload(key, filename: filename, content_type: content_type,
253
+ content_length: byte_size, checksum: checksum, custom_metadata: custom_metadata)
254
+ end
255
+
256
+ def content_type_for_serving = super
257
+ def forced_disposition_for_serving = super
258
+
259
+ def image? = content_type&.start_with?('image')
260
+ def audio? = content_type&.start_with?('audio')
261
+ def video? = content_type&.start_with?('video')
262
+ def text? = content_type&.start_with?('text')
263
+
264
+ def variable?
265
+ ActiveStorage.variable_content_types.include?(content_type)
266
+ end
267
+
268
+ def previewable?
269
+ ActiveStorage.previewers.any? { |klass| klass.accept?(self) }
270
+ end
271
+
272
+ def representable?
273
+ variable? || previewable?
274
+ end
275
+
276
+ def variant(transformations)
277
+ raise ActiveStorage::InvariableError unless variable?
278
+
279
+ variant_class.new(self, ActiveStorage::Variation.wrap(transformations).default_to(default_variant_transformations))
280
+ end
281
+
282
+ def preview(transformations)
283
+ raise ActiveStorage::UnpreviewableError unless previewable?
284
+
285
+ ActiveStorage::Preview.new(self, transformations)
286
+ end
287
+
288
+ def representation(transformations)
289
+ case
290
+ when previewable? then preview(transformations)
291
+ when variable? then variant(transformations)
292
+ else
293
+ raise ActiveStorage::UnrepresentableError
294
+ end
295
+ end
296
+
297
+ # The blob→attachment reverse lookup is intentionally unsupported (it is the
298
+ # one access pattern that would force a secondary index; the generic path
299
+ # only ever reaches it for a non-persisted blob, which has no rows). Returns
300
+ # an empty, no-op relation; materializing it on a persisted blob raises.
301
+ def attachments
302
+ ActiveStorage::AwsRecord::Attachment.none_for_blob(persisted?)
303
+ end
304
+
305
+ def service
306
+ self.class.services.fetch(service_name)
307
+ end
308
+
309
+ def mirror_later
310
+ service.mirror_later(key, checksum: checksum) if service.respond_to?(:mirror_later)
311
+ end
312
+
313
+ def delete
314
+ service.delete(key)
315
+ service.delete_prefixed("variants/#{key}/") if image?
316
+ end
317
+
318
+ # Delete the metadata item and, when variant tracking is on, its variant
319
+ # records. A still-referenced blob (attachments_count > 0) raises
320
+ # ForeignKeyViolation via a strongly-consistent conditional delete.
321
+ def destroy
322
+ @previously_persisted = persisted?
323
+ destroyed = false
324
+ run_callbacks(:destroy) do
325
+ # Guard on persisted? so a new/stamped blob object with a colliding id
326
+ # cannot delete the stored blob's metadata.
327
+ if persisted?
328
+ delete_with_foreign_key_guard!
329
+ destroyed = true
330
+ end
331
+ end
332
+ if destroyed
333
+ sweep_variant_records if ActiveStorage.track_variants
334
+ run_callbacks(:commit)
335
+ end
336
+ destroyed
337
+ end
338
+
339
+ def purge
340
+ destroy
341
+ delete if previously_persisted?
342
+ rescue ActiveStorage::ForeignKeyViolation
343
+ nil
344
+ end
345
+
346
+ def purge_later
347
+ ActiveStorage::PurgeJob.perform_later(self)
348
+ end
349
+
350
+ def previously_persisted?
351
+ @previously_persisted
352
+ end
353
+
354
+ private
355
+
356
+ # Conditional delete on the blob's own item: succeeds only when no
357
+ # attachment references it. The count lives on this item and is mutated
358
+ # atomically with each attachment write, so the guard is strong.
359
+ def delete_with_foreign_key_guard!
360
+ delete!(
361
+ condition_expression: 'attribute_not_exists(#c) OR #c = :zero',
362
+ expression_attribute_names: { '#c' => 'as_attachments_count' },
363
+ expression_attribute_values: { ':zero' => 0 }
364
+ )
365
+ rescue Aws::DynamoDB::Errors::ConditionalCheckFailedException
366
+ raise ActiveStorage::ForeignKeyViolation
367
+ end
368
+
369
+ # Destroy the blob's variant records. In Mode A this is a strong base-table
370
+ # query, so the sweep sees every variant. In Mode B (numeric range key) the
371
+ # listing comes from the eventually-consistent GSI — a variant created
372
+ # within the GSI's propagation window of this purge may be missed (a benign,
373
+ # documented limitation of the numeric-range fallback; the H2 ConditionCheck
374
+ # still prevents creating a variant *after* the blob row is gone).
375
+ def sweep_variant_records
376
+ ActiveStorage::AwsRecord::VariantRecord.where_blob(id).each(&:destroy)
377
+ end
378
+
379
+ def indifferent_metadata
380
+ (metadata || {}).each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
381
+ end
382
+
383
+ def metadata_set(key, value)
384
+ h = (metadata || {}).each_with_object({}) { |(k, v), acc| acc[k.to_s] = v }
385
+ h[key.to_s] = value
386
+ self.metadata = h
387
+ end
388
+
389
+ def open_local_io(tmpdir:)
390
+ Tempfile.open(["ActiveStorage-#{id}-", filename.extension_with_delimiter], tmpdir) do |file|
391
+ file.binmode
392
+ local_io.rewind if local_io.respond_to?(:rewind)
393
+ IO.copy_stream(local_io, file)
394
+ local_io.rewind if local_io.respond_to?(:rewind)
395
+ file.rewind
396
+ yield file
397
+ end
398
+ end
399
+
400
+ def default_variant_transformations
401
+ { format: default_variant_format }
402
+ end
403
+
404
+ def default_variant_format
405
+ if ActiveStorage.web_image_content_types.include?(content_type)
406
+ filename.extension.presence || :png
407
+ else
408
+ :png
409
+ end
410
+ end
411
+
412
+ def variant_class
413
+ ActiveStorage.track_variants ? ActiveStorage::VariantWithRecord : ActiveStorage::Variant
414
+ end
415
+ end
416
+ end
417
+ end
@@ -0,0 +1,62 @@
1
+ module ActiveStorage
2
+ module AwsRecord
3
+ # Raised when the gem cannot adapt to the configured DynamoDB table (e.g. a
4
+ # numeric partition key, a missing range key, or a key-attribute name that
5
+ # collides with one of the gem's own stored attributes).
6
+ class ConfigurationError < StandardError; end
7
+
8
+ # Holds the gem's settings. In a Rails app these are populated from
9
+ # +config.activestorage_aws_record+ by the Railtie; outside Rails (the gem's
10
+ # own test suite) they are set directly via {AwsRecord.configure}.
11
+ #
12
+ # The guiding principle is *assume as little as possible*: the only thing the
13
+ # gem truly needs is the +table_name+. The partition/sort key attribute names
14
+ # and types are discovered from the live table at boot (see {Schema}); set
15
+ # {#partition_key}/{#sort_key} only to override that discovery.
16
+ class Configuration
17
+ # Name of the single DynamoDB table that holds every Active Storage item
18
+ # (blob, attachment, and variant-record metadata). Single Table Design: the
19
+ # gem never creates entity-specific tables. The partition/sort key attribute
20
+ # names and types are auto-detected from this table at boot (see {Schema}).
21
+ attr_accessor :table_name
22
+
23
+ # First segment of every key the gem writes, isolating Active Storage items
24
+ # from the application's own items in the shared table. Make it unique if
25
+ # the default ("ActiveStorage") could collide with application keys.
26
+ attr_accessor :namespace
27
+
28
+ # Delimiter between key segments (the "#" pattern). Must not appear in a
29
+ # +record_type+, +record_id+, or attachment +name+.
30
+ attr_accessor :separator
31
+
32
+ # Hash of options forwarded to +Aws::DynamoDB::Client.new+ (e.g. +:region+,
33
+ # +:endpoint+, +:credentials+). An +:endpoint+ of "http://localhost:8000"
34
+ # targets DynamoDB Local.
35
+ attr_accessor :client_options
36
+
37
+ # An explicit +Aws::DynamoDB::Client+. When set it is used as-is and
38
+ # +client_options+ is ignored. Handy for tests and dependency injection.
39
+ attr_accessor :client
40
+
41
+ # When +true+, the gem will create the table (and, in Mode B, the index) if
42
+ # missing. Defaults to +false+: production tables are application-managed.
43
+ attr_accessor :manage_table
44
+
45
+ # Name of the string-keyed GSI used only when the table's range key is
46
+ # numeric (Mode B); identifies which GSI carries the adjacency keys when a
47
+ # table has several. Its key attribute names are auto-detected. Ignored in
48
+ # Mode A (string range key, no GSI).
49
+ attr_accessor :index_name
50
+
51
+ def initialize
52
+ @table_name = 'active_storage'
53
+ @namespace = 'ActiveStorage'
54
+ @separator = '#'
55
+ @client_options = {}
56
+ @client = nil
57
+ @manage_table = false
58
+ @index_name = 'active_storage_index'
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,86 @@
1
+ module ActiveStorage
2
+ module AwsRecord
3
+ # Single-table key machinery, mixed into the gem's three entities only
4
+ # (+Blob+, +Attachment+, +VariantRecord+) — never into application owner
5
+ # models. It turns each entity's *logical* keys into the *physical* DynamoDB
6
+ # keys for the resolved {Schema} mode, and stamps them before a write.
7
+ #
8
+ # Including classes must implement +#logical_keys+ returning
9
+ # <tt>{ h:, r:, item_id: }</tt>:
10
+ # * +h+ — the partition adjacency key (the +ns#…#…+ string),
11
+ # * +r+ — the sort adjacency key,
12
+ # * +item_id+— a globally-unique id string (the base-table partition in Mode B).
13
+ module Item
14
+ extend ActiveSupport::Concern
15
+
16
+ include Persistence
17
+
18
+ class_methods do
19
+ # @return [Schema] the resolved table layout.
20
+ def schema
21
+ ActiveStorage::AwsRecord.schema
22
+ end
23
+
24
+ # Namespaced +#+-key: prepends the configured namespace to +parts+.
25
+ def ns_key(*parts)
26
+ ActiveStorage::AwsRecord.key(ActiveStorage::AwsRecord.config.namespace, *parts)
27
+ end
28
+
29
+ # aws-record key hash (Ruby attribute symbols → values) for +find_with_opts+.
30
+ def aws_record_key(h:, r:, item_id:)
31
+ if schema.range_mode?
32
+ { dynamo_partition_key: h, dynamo_range_key: r }
33
+ else
34
+ { dynamo_partition_key: item_id, dynamo_range_key: 0 }
35
+ end
36
+ end
37
+
38
+ # Raw DynamoDB key (DB attribute names → values) for transact/raw calls.
39
+ def physical_key(h:, r:, item_id:)
40
+ if schema.range_mode?
41
+ { schema.partition_attr => h, schema.sort_attr => r }
42
+ else
43
+ { schema.partition_attr => item_id, schema.sort_attr => 0 }
44
+ end
45
+ end
46
+
47
+ # Fetch one item by logical keys (strongly consistent by default).
48
+ # @return [Aws::Record, nil]
49
+ def get_item(h:, r:, item_id:, consistent: true)
50
+ find_with_opts(key: aws_record_key(h:, r:, item_id:), consistent_read: consistent)
51
+ end
52
+ end
53
+
54
+ def schema
55
+ ActiveStorage::AwsRecord.schema
56
+ end
57
+
58
+ def ns_key(*parts)
59
+ self.class.ns_key(*parts)
60
+ end
61
+
62
+ # The raw key for this instance (DB attribute names → values).
63
+ def physical_key
64
+ self.class.physical_key(**logical_keys)
65
+ end
66
+
67
+ # Stamp the physical key attributes from this item's logical keys. Only on
68
+ # create: a key attribute cannot be updated in DynamoDB, and re-stamping
69
+ # would mark it dirty and break the next +update_item+.
70
+ def stamp_physical_keys!
71
+ return unless new_record?
72
+
73
+ keys = logical_keys
74
+ if schema.range_mode?
75
+ self.dynamo_partition_key = keys[:h]
76
+ self.dynamo_range_key = keys[:r]
77
+ else
78
+ self.dynamo_partition_key = keys[:item_id]
79
+ self.dynamo_range_key = 0
80
+ self.dynamo_index_partition = keys[:h]
81
+ self.dynamo_index_sort = keys[:r]
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,82 @@
1
+ require 'active_storage/aws_record/attachable'
2
+
3
+ module ActiveStorage
4
+ module AwsRecord
5
+ # Make a **greenfield** aws-record model an Active Storage attachment owner:
6
+ # {Attachable}'s contract glue *plus* an aws-record persistence implementation
7
+ # that runs Active Storage's callback chains. Use this when the model has no
8
+ # persistence of its own; if it already defines +save+/+destroy+ (versioning,
9
+ # events, search), include {Attachable} instead so this does not clobber them.
10
+ #
11
+ # class Message
12
+ # include Aws::Record
13
+ # include ActiveStorage::AwsRecord::Owner
14
+ # string_attr :id, hash_key: true
15
+ # has_one_attached :avatar
16
+ # has_many_attached :images
17
+ # end
18
+ #
19
+ # Include +Aws::Record+ *before* +Owner+ (as above) so +Owner+'s +save+/+destroy+
20
+ # sit above aws-record in the ancestor chain and their +super+ reaches it.
21
+ module Owner
22
+ extend ActiveSupport::Concern
23
+
24
+ included do
25
+ include ActiveStorage::AwsRecord::Persistence
26
+ include ActiveStorage::AwsRecord::Attachable
27
+
28
+ # Owner controls persistence, so it runs a real :commit chain (deferred
29
+ # purges flush on commit) and a :rollback chain so they can be cancelled.
30
+ # ({Attachable} defines the :save/:destroy/:validation chains.)
31
+ define_model_callbacks :commit unless respond_to?(:_commit_callbacks, true)
32
+ define_model_callbacks :rollback unless respond_to?(:_rollback_callbacks, true)
33
+ end
34
+
35
+ # Persist via aws-record inside the :save (then :commit) chains. +super+ is
36
+ # aws-record's terminal +save+ (not its +save!+), so this never re-enters.
37
+ def save(opts = {})
38
+ saved = false
39
+ run_callbacks(:save) do
40
+ stamp_physical_keys! if respond_to?(:stamp_physical_keys!)
41
+ saved = super(opts)
42
+ # A failed (invalid) save must not run after_save / flush attachments.
43
+ throw :abort unless saved
44
+ saved
45
+ end
46
+ run_callbacks(:commit) if saved
47
+ saved
48
+ end
49
+
50
+ # Bang variant. Delegates to #save (one callback run) rather than calling
51
+ # aws-record's #save! — which delegates back to #save and would run the
52
+ # :save/:commit chains twice. A falsy #save is either a validation failure
53
+ # (surface the messages) or a before_save +throw :abort+.
54
+ def save!(opts = {})
55
+ return self if save(opts)
56
+
57
+ message = errors.any? ? errors.full_messages.to_sentence : 'Save halted by a before_save callback'
58
+ raise ActiveStorage::RecordNotSaved.new(message, self)
59
+ rescue Aws::Record::Errors::ConditionalWriteFailed => e
60
+ raise ActiveStorage::RecordNotSaved.new(e.message, self)
61
+ end
62
+
63
+ # Remove the backend record *inside* the destroy callbacks so Active
64
+ # Storage's +after_destroy+ cleanup sees +persisted? == false+, and only run
65
+ # :commit when the destroy actually happened. The +delete!+ is guarded on
66
+ # +persisted?+ so destroying a never-saved owner whose id collides with a
67
+ # stored row cannot delete the stored row.
68
+ def destroy(opts = {})
69
+ destroyed = false
70
+ run_callbacks(:destroy) do
71
+ if persisted?
72
+ delete!(opts)
73
+ destroyed = true
74
+ end
75
+ end
76
+ run_callbacks(:commit) if destroyed
77
+ destroyed
78
+ end
79
+ alias_method :destroy!, :destroy
80
+ end
81
+ end
82
+ end