1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
# Switch DB access from datasets to Sequel models
Date: 2026-06-25
Task: `yn` — Switch DB access from datasets to Sequel models
## Goal
Replace Domus's raw-dataset database access (`db[:assets]`, `db[:files]`,
`db[:asset_attachments]`) with a Sequel model layer. The motivation is all of:
associations, validations and clearer error handling, a real domain-object
layer, and timestamp automation.
As part of this, rename the `files` concept to `uploads` everywhere — table,
foreign key, URL route, and on-disk directory.
## Data layer
A single migration, `db/migrate/006_rename_files_to_uploads.rb`:
```ruby
Sequel.migration do
change do
rename_table :files, :uploads
rename_column :asset_attachments, :file_id, :upload_id
end
end
```
`asset_attachments` has a composite primary key `[asset_id, file_id]` and a
foreign key on `file_id`, so SQLite rebuilds that table during the column
rename. Verify the rebuilt table keeps the composite PK and the FK pointing at
the renamed `uploads`. `change` reverses both operations, so rollback works.
The disk and URL rename lives in code, not the migration (migrations must not
touch the filesystem):
- `App#files_root` becomes `uploads_root` = `storage_path / "uploads"`.
`file_path` keeps its shape.
- The `web.rb` static-plugin route `/files/` becomes `/uploads/`, and the
`asset.rb` view image `src` becomes `/uploads/#{id}#{extension}`.
Existing dev blobs in `storage/files/` need to move to `storage/uploads/`.
There is no `db:reset` task and seeds regenerate from the XDG cache, so the
clean path is: delete `db/domus.db` and `storage/`, then
`rake db:migrate db:seed`.
The `documents` table and `Document` model stay as-is — still unused by app
code, now actually required so the model loads.
## Models
All three models live in `lib/domus/models.rb`, with `sole` loaded globally:
```ruby
Sequel::Model.plugin :sole
module Domus
class Asset < Sequel::Model
plugin :timestamps
plugin :validation_helpers
many_to_many :uploads,
join_table: :asset_attachments,
left_key: :asset_id, right_key: :upload_id,
order: Sequel[:asset_attachments][:created_at]
def validate
super
validates_presence :name
end
end
class Upload < Sequel::Model(:uploads)
IMAGE_EXTENSIONS = %w[.jpg .jpeg .png .gif .webp .heic .heif].freeze
plugin :timestamps
plugin :validation_helpers
many_to_many :assets,
join_table: :asset_attachments,
left_key: :upload_id, right_key: :asset_id
def validate
super
validates_presence :extension
validates_includes IMAGE_EXTENSIONS, :extension
end
end
class Document < Sequel::Model
plugin :timestamps
end
end
```
Notes:
- `IMAGE_EXTENSIONS` moves from `Web` to `Upload`, so the dependency points
web → models (correct direction), not the reverse.
- The `timestamps` plugin sets `created_at` on insert. It defaults to also
setting `updated_at`, which these tables lack. Confirm against Sequel 5.104
that it tolerates the missing column; if not, pass `update_on_create: false`
or fall back to a `before_create` hook.
- `Upload` reflects on the `uploads` table at class-load time, so models must
be required only after migrations have run.
## Routes, seeds, and error handling
`web.rb` references `Upload::IMAGE_EXTENSIONS` and the routes shed datasets:
```ruby
# GET /
assets = Asset.order(Sequel.desc(:created_at), Sequel.desc(:id)).limit(12).all
Views::Home.new(assets:, total: Asset.count).call
# GET /assets/:id
asset = Asset.where(id:).sole # 0 rows -> NoMatchingRow -> 404
Views::Asset.new(asset:, images: asset.uploads).call
```
`asset.uploads` returns `Upload` models ordered by the join's `created_at`,
replacing the hand-written `asset_images` join method, which is removed. Views
read `upload[:id]` / `upload[:extension]`; `[]` access works on a model, so the
views need no change beyond the `/uploads/` src.
`save_file` becomes model writes inside the existing transaction:
```ruby
db.transaction do
upload = Upload.create(extension: ext) # timestamps sets created_at
dest = app.file_path(id: upload.id, extension: ext)
FileUtils.mkdir_p(::File.dirname(dest))
FileUtils.cp(upload[:tempfile].path, dest)
asset_names.each { |name| Asset.create(name:).add_upload(upload) }
end
```
The MIME-type and size guards stay as `ClientError` raises — they act on the
upload tempfile, not model columns, and need explicit HTTP-status control. The
model presence/extension validations raise `Sequel::ValidationFailed`, mapped
to **422** in the `error_handler` alongside the existing
`Sequel::NoMatchingRow → 404`.
Seeds change the same way: `Asset.create` / `Upload.create` / `add_upload`
replace the three `db[:table].insert` calls, keeping the cache-warming and
transaction structure.
## sole as a model plugin
`lib/sequel/extensions/sole.rb` becomes `lib/sequel/plugins/sole.rb`:
```ruby
module Sequel
module Plugins
module Sole
class TooManyRows < Sequel::Error; end
module DatasetMethods
def sole
results = limit(2).all
raise Sequel::NoMatchingRow.new(self) if results.empty?
raise TooManyRows, "expected 1 row, got multiple" if results.length > 1
results.first
end
end
end
register_plugin(:sole, Sole)
end
end
```
- The exception moves from `Sequel::Sole::TooManyRows` to
`Sequel::Plugins::Sole::TooManyRows`; the error_handler and tests update.
- `app.rb` drops `@db.extension(:sole)`; `models.rb` adds the global
`Sequel::Model.plugin :sole`.
- On a model dataset, `sole` returns a model instance. The GET /assets/:id
route uses `Asset.where(id:).sole`, keeping a live caller and identical 404
semantics.
## Testing
- Require order: models load only after migrations run, in `test_helper.rb`,
`config.ru`, and the `Rakefile`.
- `test_app.rb`: setup deletes the join table raw (`db[:asset_attachments]
.delete`) for FK order, then `Asset.dataset.delete` / `Upload.dataset
.delete`; assertions use `Asset.count`, `Asset.first`, `asset.uploads`.
- `test_sole.rb`: rewritten against `Asset` and
`Sequel::Plugins::Sole::TooManyRows`.
- New coverage: validations reject blank name / blank or non-image extension
(`Sequel::ValidationFailed`); the error_handler maps `ValidationFailed` to
422; `asset.uploads` returns uploads oldest-first.
- `rbs-inline` annotations on the view initializers move from
`Array[Hash[Symbol, untyped]]` to `Array[Asset]` / `Array[Upload]`; `rake
check` (rbs-inline + steep) stays green.
## Follow-up
The `sequel` skill (`.claude/skills/sequel/SKILL.md`) references stale paths and
describes models as bare; update it after this lands (task `sqr`).