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 +4 -4
- data/CHANGELOG.md +26 -0
- data/README.md +24 -4
- data/lib/file_hutch/backfill.rb +73 -0
- data/lib/file_hutch/client.rb +9 -0
- data/lib/file_hutch/rails/attachable.rb +4 -3
- data/lib/file_hutch/rails/engine.rb +4 -0
- data/lib/file_hutch/rails.rb +6 -0
- data/lib/file_hutch/version.rb +1 -1
- data/lib/generators/file_hutch/attachment/attachment_generator.rb +2 -2
- data/lib/generators/file_hutch/install/install_generator.rb +1 -1
- data/lib/tasks/file_hutch.rake +15 -0
- metadata +5 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4b59ec3bdd7aeb52b1e3b135698091716fcb3a38ac5ce686ec648631fe7b7087
|
|
4
|
+
data.tar.gz: 17485d436dbdcf4ddd85cd8c8c4706343281e13c433b0ba0544df81293ba0858
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
165
|
-
|
|
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, `
|
|
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` | `
|
|
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
|
data/lib/file_hutch/client.rb
CHANGED
|
@@ -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
|
-
# `
|
|
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
|
-
#
|
|
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
|
|
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
|
data/lib/file_hutch/rails.rb
CHANGED
data/lib/file_hutch/version.rb
CHANGED
|
@@ -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 `
|
|
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}:
|
|
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
|
|
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.
|
|
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
|
|
14
|
-
|
|
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
|