file_hutch 0.1.0 → 0.3.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: e2b2054f9f101d16f7ea61d375dfcbb9715838b5988c517bd547da595759f454
4
- data.tar.gz: b81dc343e6ee21e17d396502b0700ff7e9ed253e5729f928eca28673248c2801
3
+ metadata.gz: 4b59ec3bdd7aeb52b1e3b135698091716fcb3a38ac5ce686ec648631fe7b7087
4
+ data.tar.gz: 17485d436dbdcf4ddd85cd8c8c4706343281e13c433b0ba0544df81293ba0858
5
5
  SHA512:
6
- metadata.gz: 48a44a32ed534d38c731ab9cb9be31c856b635a642035ae5942cdcfffca73743c973f8fe11cb7a2dcccf7206e503b650d1997dd98c17f85dbcee705aebb20adc
7
- data.tar.gz: 13cfab0ce82a0afa0594818e1508b994ee066790319fb621a18c7bc3033bc787a2a1afa22699c29a92bc1e783b101a1bb9e7bb9ae0e6e08465c813286d18a98a
6
+ metadata.gz: c07c3d0cc5169acbb0ee860d8374be31128d62ba6fd9e03fbca8defed997b90313e706eb13e0f84f7dff5b2d12d0e6f036fd34c289eaa1f12f6f4523d7a8e088
7
+ data.tar.gz: e3b329321814427ffee8e657ae512e01201b9b5db1a505c64513f47f29e8f23e7db6d19671643ad7cdfb5632b5f1738f354274cdd351dc97530fdd6960d25f57
data/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0 — 2026-09-24
4
+
5
+ ### Removed
6
+
7
+ - `has_file_hutch_file`, deprecated in 0.2.0 as promised. Rename it to `has_hutch`; the options
8
+ are the same.
9
+
10
+ ### Rails
11
+
12
+ - `bin/rails file_hutch:backfill MODEL=… FROM=…` moves an Active Storage attachment onto its
13
+ `has_hutch` column: streams each blob through FileHutch, or with `ADOPT_FROM=conn_…` registers
14
+ blobs in place when the Active Storage bucket is connected. Restartable; fills only blank rows.
15
+ Backed by `FileHutch::Backfill`.
16
+
17
+ ### Client
18
+
19
+ - `adopt(key:, storage_connection:)` registers an object already in a connected bucket as a file,
20
+ without copying it. Idempotent: adopting the same key again returns the same file.
21
+
22
+ ## 0.2.0 — 2026-09-16
23
+
24
+ ### Rails
25
+
26
+ - `has_file_hutch_file` is now `has_hutch`, with the same options. The old name still works and
27
+ logs a deprecation warning; it will be removed in 0.3.
28
+
3
29
  ## 0.1.0 — 2026-09-15
4
30
 
5
31
  First release.
data/README.md CHANGED
@@ -161,8 +161,8 @@ bin/rails generate file_hutch:attachment User avatar # adds users.avatar_file
161
161
 
162
162
  ```ruby
163
163
  class User < ApplicationRecord
164
- has_file_hutch_file :avatar, policy: "avatars"
165
- has_file_hutch_file :contract, policy: "documents", dependent: false
164
+ has_hutch :avatar, policy: "avatars"
165
+ has_hutch :contract, policy: "documents", dependent: false
166
166
  end
167
167
 
168
168
  user.avatar = params[:avatar] # uploaded IO → uploaded to storage on save
@@ -227,14 +227,14 @@ Submit buttons are disabled while uploading. The element dispatches `filehutch:s
227
227
  `filehutch:progress`, `filehutch:complete`, and `filehutch:error`. `directUpload(file, { url,
228
228
  policy, onProgress })` is exported for use without Stimulus.
229
229
 
230
- On save, `has_file_hutch_file` verifies the submitted id is a ready file under the declared policy,
230
+ On save, `has_hutch` verifies the submitted id is a ready file under the declared policy,
231
231
  so a client cannot attach someone else's upload to the wrong field.
232
232
 
233
233
  ### Coming from Active Storage
234
234
 
235
235
  | Active Storage | file_hutch |
236
236
  | --- | --- |
237
- | `has_one_attached :avatar` | `has_file_hutch_file :avatar, policy: "avatars"` |
237
+ | `has_one_attached :avatar` | `has_hutch :avatar, policy: "avatars"` |
238
238
  | `active_storage_blobs` + `attachments` tables | `users.avatar_file_id` |
239
239
  | `url_for(user.avatar)` | `user.avatar_url` / `user.avatar_signed_url` |
240
240
  | `user.avatar.variant(resize_to_fill: [200, 200])` | `user.avatar_transform_url("avatar")`, defined once in the dashboard |
@@ -242,6 +242,26 @@ so a client cannot attach someone else's upload to the wrong field.
242
242
  | `DirectUpload` JS | `file_hutch/direct_upload_controller` |
243
243
  | service.yml, CORS, signed URL code | policies in the FileHutch dashboard |
244
244
 
245
+ Moving existing attachments across is one task. Add the column and `has_hutch`
246
+ beside the Active Storage attachment, then:
247
+
248
+ ```sh
249
+ bin/rails file_hutch:backfill MODEL=User FROM=avatar
250
+ ```
251
+
252
+ Each blob is read through Active Storage and uploaded under the attachment's
253
+ policy, and its file ID goes into the column. Only rows whose column is still
254
+ blank are touched, so a run that stops half way (or leaves failures) carries on
255
+ where it was when you run it again. `TO=` names the `has_hutch` attachment when
256
+ it differs from the Active Storage one.
257
+
258
+ If the bucket Active Storage writes to is connected to your FileHutch project,
259
+ add `ADOPT_FROM=conn_…` and nothing is copied: each blob becomes a file where it
260
+ already is, by its key.
261
+
262
+ The Active Storage attachment is left alone. Read from the new column with a
263
+ fallback to the old one until the backfill has filled every row, then remove it.
264
+
245
265
  ## Development
246
266
 
247
267
  ```sh
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FileHutch
4
+ # Moves one Active Storage attachment on a model onto its has_hutch column:
5
+ #
6
+ # FileHutch::Backfill.new(model: User, from: :photo, to: :avatar).call
7
+ #
8
+ # Two ways, per run:
9
+ #
10
+ # * Stream (the default). Each blob's bytes are read through Active Storage
11
+ # and uploaded to FileHutch under the attachment's policy. Works whatever
12
+ # bucket Active Storage uses, and costs a download and an upload per file.
13
+ #
14
+ # * Adopt (adopt_from: "conn_…"). When the bucket Active Storage writes to is
15
+ # connected to FileHutch, each blob is registered where it already is, by
16
+ # its key. Nothing is copied or downloaded.
17
+ #
18
+ # Restartable. Only records whose column is still blank are touched, and the
19
+ # column is written as soon as each file exists, so a run that stops half way
20
+ # carries on from where it was. Adopting is idempotent on the server too.
21
+ #
22
+ # The Active Storage attachment is left alone: keep reading from it until the
23
+ # new column is filled everywhere, then remove it.
24
+ class Backfill
25
+ Result = Struct.new(:done, :skipped, :failed, keyword_init: true)
26
+
27
+ def initialize(model:, from:, to: from, adopt_from: nil, client: FileHutch.client, logger: FileHutch.config.logger)
28
+ @model, @from, @to, @adopt_from, @client, @logger = model, from.to_sym, to.to_sym, adopt_from.presence, client, logger
29
+ @options = model.file_hutch_files.fetch(@to) do
30
+ raise ArgumentError, "#{model.name} has no has_hutch :#{@to}"
31
+ end
32
+ end
33
+
34
+ def call
35
+ result = Result.new(done: 0, skipped: 0, failed: 0)
36
+
37
+ @model.where(column => [ nil, "" ]).find_each do |record|
38
+ attachment = record.public_send(@from)
39
+ unless attachment.respond_to?(:attached?) && attachment.attached?
40
+ result.skipped += 1
41
+ next
42
+ end
43
+
44
+ file = @adopt_from ? adopt(attachment.blob) : stream(attachment.blob)
45
+ # update_column: the file exists already, so has_hutch's check on save
46
+ # would only ask the server again, and callbacks have no business here.
47
+ record.update_column(column, file.id)
48
+ result.done += 1
49
+ rescue FileHutch::Error => e
50
+ result.failed += 1
51
+ @logger&.warn("[file_hutch] backfill #{@model.name}##{record.id} #{@from}: #{e.message}")
52
+ end
53
+
54
+ result
55
+ end
56
+
57
+ private
58
+
59
+ def column = @options.fetch(:column)
60
+ def policy = @options.fetch(:policy)
61
+
62
+ def adopt(blob)
63
+ @client.adopt(storage_connection: @adopt_from, key: blob.key, filename: blob.filename.to_s,
64
+ content_type: blob.content_type, policy: policy)
65
+ end
66
+
67
+ def stream(blob)
68
+ blob.open do |io|
69
+ @client.upload(io, policy: policy, filename: blob.filename.to_s, content_type: blob.content_type)
70
+ end
71
+ end
72
+ end
73
+ end
@@ -35,6 +35,15 @@ module FileHutch
35
35
  request(:get, "/api/v1/transforms").fetch("transforms").map { |t| Transform.new(t, client: self) }
36
36
  end
37
37
 
38
+ # Registers an object that is already in a connected bucket as a file, in
39
+ # place: nothing is copied and the object keeps its key. Adopting the same
40
+ # key again returns the same file, so a backfill can be re-run.
41
+ def adopt(key:, storage_connection:, filename: nil, content_type: nil, visibility: nil, policy: nil)
42
+ body = { storage_connection: storage_connection, key: key, filename: filename,
43
+ content_type: content_type, visibility: visibility, policy: policy }.compact
44
+ File.new(request(:post, "/api/v1/files/adopt", body).fetch("file"), client: self)
45
+ end
46
+
38
47
  # -- Declarative configuration (see `file_hutch plan|apply`) -----------
39
48
 
40
49
  # The project as a config hash: {"uploads" => {...}, "transforms" => {...}, "environments" => [...]}.
@@ -3,11 +3,11 @@
3
3
  require "active_support/concern"
4
4
 
5
5
  module FileHutch
6
- # `has_file_hutch_file` for Active Record. The model stores one string column,
6
+ # `has_hutch` for Active Record. The model stores one string column,
7
7
  # `<name>_file_id`, holding an FileHutch file id. Nothing about storage leaks in.
8
8
  #
9
9
  # class User < ApplicationRecord
10
- # has_file_hutch_file :avatar, policy: "avatars"
10
+ # has_hutch :avatar, policy: "avatars"
11
11
  # end
12
12
  #
13
13
  # user.avatar = params[:avatar] # uploaded IO: uploaded to storage on save
@@ -25,7 +25,7 @@ module FileHutch
25
25
  extend ActiveSupport::Concern
26
26
 
27
27
  class_methods do
28
- def has_file_hutch_file(name, policy:, column: "#{name}_file_id", dependent: :delete, verify: true)
28
+ def has_hutch(name, policy:, column: "#{name}_file_id", dependent: :delete, verify: true)
29
29
  include Attachable unless include?(Attachable)
30
30
  name = name.to_sym
31
31
  column = column.to_s
@@ -45,6 +45,7 @@ module FileHutch
45
45
  define_method(:"purge_#{name}") { file_hutch_purge(name) }
46
46
  end
47
47
 
48
+
48
49
  def file_hutch_files
49
50
  @file_hutch_files ||= superclass.respond_to?(:file_hutch_files) ? superclass.file_hutch_files.dup : {}
50
51
  end
@@ -13,6 +13,10 @@ module FileHutch
13
13
  class Engine < ::Rails::Engine
14
14
  isolate_namespace FileHutch
15
15
 
16
+ initializer "file_hutch.deprecator" do |app|
17
+ app.deprecators[:file_hutch] = FileHutch.deprecator if app.respond_to?(:deprecators)
18
+ end
19
+
16
20
  initializer "file_hutch.active_record" do
17
21
  ActiveSupport.on_load(:active_record) { extend FileHutch::Attachable::ClassMethods }
18
22
  end
@@ -2,4 +2,10 @@
2
2
 
3
3
  require "rails"
4
4
  require_relative "rails/attachable"
5
+ require_relative "backfill"
6
+
7
+ module FileHutch
8
+ def self.deprecator = @deprecator ||= ActiveSupport::Deprecation.new("0.4", "file_hutch")
9
+ end
10
+
5
11
  require_relative "rails/engine"
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FileHutch
4
- VERSION = "0.1.0"
4
+ VERSION = "0.3.0"
5
5
  end
@@ -6,7 +6,7 @@ require "rails/generators/active_record"
6
6
  module FileHutch
7
7
  module Generators
8
8
  # bin/rails generate file_hutch:attachment User avatar
9
- # Adds users.avatar_file_id (string). Pair with `has_file_hutch_file :avatar, policy: "…"`.
9
+ # Adds users.avatar_file_id (string). Pair with `has_hutch :avatar, policy: "…"`.
10
10
  class AttachmentGenerator < ::Rails::Generators::NamedBase
11
11
  include ::ActiveRecord::Generators::Migration
12
12
 
@@ -18,7 +18,7 @@ module FileHutch
18
18
  end
19
19
 
20
20
  def show_macro
21
- say %(Add to #{class_name}: has_file_hutch_file :#{attachment}, policy: "#{attachment.pluralize}"), :green
21
+ say %(Add to #{class_name}: has_hutch :#{attachment}, policy: "#{attachment.pluralize}"), :green
22
22
  end
23
23
 
24
24
  private
@@ -25,7 +25,7 @@ module FileHutch
25
25
  say ""
26
26
  say "FileHutch installed.", :green
27
27
  say " 1. Set FILE_HUTCH_API_KEY (Dashboard → API keys) and, if not production, FILE_HUTCH_URL."
28
- say " 2. Add a column and macro: bin/rails g file_hutch:attachment User avatar then has_file_hutch_file :avatar, policy: \"avatars\""
28
+ say " 2. Add a column and macro: bin/rails g file_hutch:attachment User avatar then has_hutch :avatar, policy: \"avatars\""
29
29
  say " 3. For browser uploads, set FileHutch.config.authorize_direct_upload in the initializer and register the"
30
30
  say " Stimulus controller: application.register(\"filehutch-direct-upload\", DirectUploadController)"
31
31
  end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :file_hutch do
4
+ desc "Copy an Active Storage attachment onto a has_hutch column. " \
5
+ "MODEL=User FROM=photo [TO=avatar] [ADOPT_FROM=conn_… to register blobs in place instead of copying]"
6
+ task backfill: :environment do
7
+ model = ENV.fetch("MODEL") { abort "MODEL= is required, e.g. MODEL=User" }.constantize
8
+ from = ENV.fetch("FROM") { abort "FROM= is required: the Active Storage attachment name" }
9
+ to = ENV.fetch("TO", from)
10
+
11
+ result = FileHutch::Backfill.new(model: model, from: from, to: to, adopt_from: ENV["ADOPT_FROM"]).call
12
+ puts "#{model.name}##{to}: #{result.done} filled, #{result.skipped} with nothing attached, #{result.failed} failed"
13
+ puts "Run it again to retry the failures; filled rows are skipped." if result.failed.positive?
14
+ end
15
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: file_hutch
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andy Leverenz
@@ -10,8 +10,8 @@ cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies: []
12
12
  description: Talk to the FileHutch file control plane from Ruby. Server-side and browser-direct
13
- uploads, signed URLs, and a has_file_hutch_file macro for Active Record that stores
14
- only opaque file ids.
13
+ uploads, signed URLs, and a has_hutch macro for Active Record that stores only opaque
14
+ file ids.
15
15
  email:
16
16
  - andy@justalever.com
17
17
  executables:
@@ -27,6 +27,7 @@ files:
27
27
  - config/routes.rb
28
28
  - exe/file_hutch
29
29
  - lib/file_hutch.rb
30
+ - lib/file_hutch/backfill.rb
30
31
  - lib/file_hutch/cli.rb
31
32
  - lib/file_hutch/client.rb
32
33
  - lib/file_hutch/configuration.rb
@@ -44,6 +45,7 @@ files:
44
45
  - lib/generators/file_hutch/attachment/templates/migration.rb.tt
45
46
  - lib/generators/file_hutch/install/install_generator.rb
46
47
  - lib/generators/file_hutch/install/templates/initializer.rb
48
+ - lib/tasks/file_hutch.rake
47
49
  homepage: https://github.com/filehutch/filehutch-ruby
48
50
  licenses:
49
51
  - MIT