plutonium 0.55.0 → 0.56.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/.claude/skills/plutonium-resource/SKILL.md +21 -2
- data/.claude/skills/plutonium-ui/SKILL.md +15 -2
- data/CHANGELOG.md +31 -0
- data/app/assets/plutonium.css +1 -1
- data/app/assets/plutonium.js +94 -26
- data/app/assets/plutonium.js.map +2 -2
- data/app/assets/plutonium.min.js +9 -9
- data/app/assets/plutonium.min.js.map +3 -3
- data/config/initializers/rabl.rb +16 -0
- data/docs/.vitepress/config.ts +1 -0
- data/docs/public/templates/lite.rb +10 -0
- data/docs/reference/generators/lite.md +65 -0
- data/docs/reference/resource/definition.md +18 -2
- data/docs/reference/ui/assets.md +14 -0
- data/docs/reference/ui/displays.md +27 -1
- data/docs/reference/ui/forms.md +2 -1
- data/docs/reference/ui/layouts.md +33 -0
- data/docs/superpowers/plans/2026-06-04-sqlite-tune-maintenance-generators.md +857 -0
- data/docs/superpowers/plans/2026-06-04-sqlite-tune-maintenance-generators.md.tasks.json +45 -0
- data/docs/superpowers/specs/2026-06-04-sqlite-tune-maintenance-generators-design.md +238 -0
- data/gemfiles/rails_7.gemfile.lock +1 -1
- data/gemfiles/rails_8.0.gemfile.lock +1 -1
- data/gemfiles/rails_8.1.gemfile.lock +1 -1
- data/lib/generators/pu/core/update/update_generator.rb +4 -1
- data/lib/generators/pu/lib/plutonium_generators/concerns/configures_recurring.rb +89 -0
- data/lib/generators/pu/lite/maintenance/maintenance_generator.rb +45 -0
- data/lib/generators/pu/lite/maintenance/templates/app/jobs/sqlite_maintenance_job.rb.tt +60 -0
- data/lib/generators/pu/lite/rails_pulse/rails_pulse_generator.rb +4 -51
- data/lib/generators/pu/lite/rails_pulse/templates/config/initializers/rails_pulse.rb.tt +1 -1
- data/lib/generators/pu/lite/tune/tune_generator.rb +105 -0
- data/lib/plutonium/models/has_cents.rb +10 -0
- data/lib/plutonium/resource/controllers/interactive_actions.rb +19 -2
- data/lib/plutonium/routing/mapper_extensions.rb +5 -0
- data/lib/plutonium/ui/display/base.rb +9 -0
- data/lib/plutonium/ui/display/components/badge.rb +83 -0
- data/lib/plutonium/ui/display/components/boolean.rb +28 -6
- data/lib/plutonium/ui/display/components/currency.rb +50 -0
- data/lib/plutonium/ui/display/options/inferred_types.rb +13 -0
- data/lib/plutonium/ui/display/theme.rb +5 -0
- data/lib/plutonium/ui/form/base.rb +5 -0
- data/lib/plutonium/ui/form/components/toggle.rb +14 -0
- data/lib/plutonium/ui/form/concerns/renders_nested_resource_fields.rb +14 -25
- data/lib/plutonium/ui/form/concerns/renders_repeater_row_controls.rb +67 -0
- data/lib/plutonium/ui/form/concerns/renders_structured_inputs.rb +5 -38
- data/lib/plutonium/ui/form/interaction.rb +7 -2
- data/lib/plutonium/ui/form/options/inferred_types.rb +2 -0
- data/lib/plutonium/ui/form/resource.rb +1 -0
- data/lib/plutonium/ui/form/theme.rb +12 -0
- data/lib/plutonium/ui/grid/card.rb +58 -21
- data/lib/plutonium/ui/layout/icon_rail.rb +29 -9
- data/lib/plutonium/ui/sidebar_menu.rb +29 -0
- data/lib/plutonium/version.rb +1 -1
- data/package.json +1 -1
- data/plutonium.gemspec +5 -4
- data/src/css/components.css +126 -0
- data/src/js/controllers/dirty_form_guard_controller.js +55 -4
- data/src/js/controllers/nested_resource_form_fields_controller.js +35 -12
- data/src/js/controllers/resource_drop_down_controller.js +49 -14
- metadata +19 -6
|
@@ -0,0 +1,857 @@
|
|
|
1
|
+
# SQLite Tuning & Maintenance Generators Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development (recommended) or superpowers-extended-cc:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** Add two `pu:lite` generators — `pu:lite:tune` (SQLite performance pragmas) and `pu:lite:maintenance` (a scheduled `SqliteMaintenanceJob`) — and wire them into the lite app template and docs.
|
|
6
|
+
|
|
7
|
+
**Architecture:** Two new `Rails::Generators::Base` generators under `lib/generators/pu/lite/`. The `recurring.yml` injection logic currently private to the rails_pulse generator is extracted into a shared, unit-testable `ConfiguresRecurring` concern (with a pure nested `RecurringYAML` class, mirroring the existing `ConfiguresSqlite::DatabaseYAML`). `pu:lite:tune` edits `config/database.yml`'s `default: &default` block; `pu:lite:maintenance` templates a job and schedules it via the new concern.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Ruby, Rails generators (Thor), Zeitwerk autoloading, Minitest, VitePress docs.
|
|
10
|
+
|
|
11
|
+
**User Verification:** NO — no user verification required. (The original request is "add support for SQLite config + maintenance to the template"; success is verifiable by automated generator tests, not human sign-off.)
|
|
12
|
+
|
|
13
|
+
**Commit policy:** Per the user's explicit instruction ("commit at the end"), individual tasks do **NOT** commit. Each task ends by running its tests green. A single final commit happens in the last task. This overrides the skill's default frequent-commit guidance.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## File Structure
|
|
18
|
+
|
|
19
|
+
| Action | Path | Responsibility |
|
|
20
|
+
|---|---|---|
|
|
21
|
+
| Create | `lib/generators/pu/lib/plutonium_generators/concerns/configures_recurring.rb` | Shared `recurring.yml` injection (concern + pure `RecurringYAML`) |
|
|
22
|
+
| Modify | `lib/generators/pu/lite/rails_pulse/rails_pulse_generator.rb` | Use the shared concern instead of private methods |
|
|
23
|
+
| Create | `lib/generators/pu/lite/tune/tune_generator.rb` | `pu:lite:tune` — insert tuned pragmas into `database.yml` |
|
|
24
|
+
| Create | `lib/generators/pu/lite/maintenance/maintenance_generator.rb` | `pu:lite:maintenance` — install + schedule the job |
|
|
25
|
+
| Create | `lib/generators/pu/lite/maintenance/templates/app/jobs/sqlite_maintenance_job.rb.tt` | The ported maintenance job |
|
|
26
|
+
| Modify | `docs/public/templates/lite.rb` | Chain the two new generators after bundle |
|
|
27
|
+
| Create | `docs/reference/generators/lite.md` | Reference page for `pu:lite:*` |
|
|
28
|
+
| Modify | `docs/.vitepress/config.ts` | Sidebar entry for the new page |
|
|
29
|
+
| Create | `test/generators/configures_recurring_test.rb` | Unit tests for `RecurringYAML` |
|
|
30
|
+
| Modify | `test/generators/lite_generators_test.rb` | Shape tests for tune + maintenance generators |
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
### Task 1: Extract `ConfiguresRecurring` concern and refactor rails_pulse
|
|
35
|
+
|
|
36
|
+
**Goal:** Move the env-aware `recurring.yml` injection out of the rails_pulse generator into a reusable, unit-testable concern; rails_pulse keeps identical behavior.
|
|
37
|
+
|
|
38
|
+
**Files:**
|
|
39
|
+
- Create: `lib/generators/pu/lib/plutonium_generators/concerns/configures_recurring.rb`
|
|
40
|
+
- Modify: `lib/generators/pu/lite/rails_pulse/rails_pulse_generator.rb`
|
|
41
|
+
- Create: `test/generators/configures_recurring_test.rb`
|
|
42
|
+
|
|
43
|
+
**Acceptance Criteria:**
|
|
44
|
+
- [ ] `PlutoniumGenerators::Concerns::ConfiguresRecurring` autoloads (Zeitwerk) and exposes `add_recurring_tasks(tasks_yaml, marker:)`.
|
|
45
|
+
- [ ] Nested `RecurringYAML` performs the pure content transform and is tested for env-scoped and flat layouts.
|
|
46
|
+
- [ ] rails_pulse generator includes the concern and no longer defines `inject_rails_pulse_under_envs`; its existing tests still pass.
|
|
47
|
+
|
|
48
|
+
**Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/generators/configures_recurring_test.rb test/generators/lite_generators_test.rb` → all PASS
|
|
49
|
+
|
|
50
|
+
**Steps:**
|
|
51
|
+
|
|
52
|
+
- [ ] **Step 1: Write the failing test**
|
|
53
|
+
|
|
54
|
+
Create `test/generators/configures_recurring_test.rb`:
|
|
55
|
+
|
|
56
|
+
```ruby
|
|
57
|
+
# frozen_string_literal: true
|
|
58
|
+
|
|
59
|
+
require "test_helper"
|
|
60
|
+
require "generators/pu/lib/plutonium_generators"
|
|
61
|
+
|
|
62
|
+
class ConfiguresRecurringTest < ActiveSupport::TestCase
|
|
63
|
+
RecurringYAML = PlutoniumGenerators::Concerns::ConfiguresRecurring::RecurringYAML
|
|
64
|
+
|
|
65
|
+
ENV_SCOPED = <<~YAML
|
|
66
|
+
production:
|
|
67
|
+
existing_task:
|
|
68
|
+
class: ExistingJob
|
|
69
|
+
schedule: every hour
|
|
70
|
+
|
|
71
|
+
development:
|
|
72
|
+
existing_task:
|
|
73
|
+
class: ExistingJob
|
|
74
|
+
schedule: every hour
|
|
75
|
+
YAML
|
|
76
|
+
|
|
77
|
+
FLAT = <<~YAML
|
|
78
|
+
existing_task:
|
|
79
|
+
class: ExistingJob
|
|
80
|
+
schedule: every hour
|
|
81
|
+
YAML
|
|
82
|
+
|
|
83
|
+
TASKS = <<~YAML
|
|
84
|
+
sqlite_maintenance:
|
|
85
|
+
class: SqliteMaintenanceJob
|
|
86
|
+
schedule: every day at 3:30am
|
|
87
|
+
YAML
|
|
88
|
+
|
|
89
|
+
test "injects tasks under every environment in an env-scoped file" do
|
|
90
|
+
result = RecurringYAML.new.inject(ENV_SCOPED, TASKS)
|
|
91
|
+
|
|
92
|
+
# one occurrence per environment (production + development)
|
|
93
|
+
assert_equal 2, result.scan(/sqlite_maintenance:/).length
|
|
94
|
+
# nested two spaces under the environment, matching siblings
|
|
95
|
+
assert_match(/^ sqlite_maintenance:$/, result)
|
|
96
|
+
# original tasks preserved
|
|
97
|
+
assert_includes result, "existing_task:"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
test "appends tasks to a flat (non-env-scoped) file" do
|
|
101
|
+
result = RecurringYAML.new.inject(FLAT, TASKS)
|
|
102
|
+
|
|
103
|
+
assert_equal 1, result.scan(/sqlite_maintenance:/).length
|
|
104
|
+
# flat layout keeps tasks at column 0
|
|
105
|
+
assert_match(/^sqlite_maintenance:$/, result)
|
|
106
|
+
assert_includes result, "existing_task:"
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
- [ ] **Step 2: Run the test to verify it fails**
|
|
112
|
+
|
|
113
|
+
Run: `bundle exec appraisal rails-8.1 ruby -Itest test/generators/configures_recurring_test.rb`
|
|
114
|
+
Expected: FAIL — `uninitialized constant PlutoniumGenerators::Concerns::ConfiguresRecurring`
|
|
115
|
+
|
|
116
|
+
- [ ] **Step 3: Create the concern**
|
|
117
|
+
|
|
118
|
+
Create `lib/generators/pu/lib/plutonium_generators/concerns/configures_recurring.rb`:
|
|
119
|
+
|
|
120
|
+
```ruby
|
|
121
|
+
# frozen_string_literal: true
|
|
122
|
+
|
|
123
|
+
module PlutoniumGenerators
|
|
124
|
+
module Concerns
|
|
125
|
+
module ConfiguresRecurring
|
|
126
|
+
ENV_KEYS = %w[production development staging test].freeze
|
|
127
|
+
|
|
128
|
+
# Pure transform of a config/recurring.yml string. No file IO — testable
|
|
129
|
+
# in isolation, mirroring ConfiguresSqlite::DatabaseYAML.
|
|
130
|
+
class RecurringYAML
|
|
131
|
+
# Returns new content with `tasks_yaml` injected. If the file is
|
|
132
|
+
# env-scoped (has top-level production:/development:/... keys), the
|
|
133
|
+
# tasks are inserted under each environment at the siblings' indent.
|
|
134
|
+
# Otherwise they are appended at column 0.
|
|
135
|
+
def inject(content, tasks_yaml)
|
|
136
|
+
if env_scoped?(content)
|
|
137
|
+
inject_under_envs(content, tasks_yaml)
|
|
138
|
+
else
|
|
139
|
+
content.rstrip + "\n\n" + indent(tasks_yaml, 0)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
private
|
|
144
|
+
|
|
145
|
+
def env_scoped?(content)
|
|
146
|
+
content.lines.any? { |l| l.match?(env_re) }
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def env_re
|
|
150
|
+
/^(#{ENV_KEYS.join("|")}):\s*$/
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def indent(yaml, n)
|
|
154
|
+
pad = " " * n
|
|
155
|
+
yaml.gsub(/^(?=.)/, pad)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def inject_under_envs(content, tasks_yaml)
|
|
159
|
+
lines = content.lines
|
|
160
|
+
env_starts = lines.each_with_index.select { |l, _| env_re.match?(l) }.map(&:last)
|
|
161
|
+
|
|
162
|
+
env_starts.reverse_each do |start|
|
|
163
|
+
end_idx = lines.length
|
|
164
|
+
((start + 1)...lines.length).each do |i|
|
|
165
|
+
if lines[i].match?(/^[^\s#]/)
|
|
166
|
+
end_idx = i
|
|
167
|
+
break
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
child_indent = 2
|
|
172
|
+
((start + 1)...end_idx).each do |i|
|
|
173
|
+
if (m = lines[i].match(/^(\s+)\S/))
|
|
174
|
+
child_indent = m[1].length
|
|
175
|
+
break
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
insert_at = end_idx
|
|
180
|
+
while insert_at > start + 1 && lines[insert_at - 1].strip.empty?
|
|
181
|
+
insert_at -= 1
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
lines.insert(insert_at, "\n", indent(tasks_yaml, child_indent))
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
lines.join
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
private
|
|
192
|
+
|
|
193
|
+
# Inject recurring task YAML into config/recurring.yml. Returns true when
|
|
194
|
+
# written, false when the file is missing or the marker already present
|
|
195
|
+
# (idempotent). `marker` is matched with file_includes? to avoid dupes.
|
|
196
|
+
def add_recurring_tasks(tasks_yaml, marker:)
|
|
197
|
+
recurring_file = "config/recurring.yml"
|
|
198
|
+
full_path = File.expand_path(recurring_file, destination_root)
|
|
199
|
+
return false unless File.exist?(full_path)
|
|
200
|
+
return false if file_includes?(recurring_file, marker)
|
|
201
|
+
|
|
202
|
+
new_content = RecurringYAML.new.inject(File.read(full_path), tasks_yaml)
|
|
203
|
+
create_file recurring_file, new_content, force: true
|
|
204
|
+
say_status :recurring, "#{marker} (config/recurring.yml)"
|
|
205
|
+
true
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
- [ ] **Step 4: Run the test to verify it passes**
|
|
213
|
+
|
|
214
|
+
Run: `bundle exec appraisal rails-8.1 ruby -Itest test/generators/configures_recurring_test.rb`
|
|
215
|
+
Expected: PASS (2 runs, 0 failures)
|
|
216
|
+
|
|
217
|
+
- [ ] **Step 5: Refactor rails_pulse to use the concern**
|
|
218
|
+
|
|
219
|
+
In `lib/generators/pu/lite/rails_pulse/rails_pulse_generator.rb`:
|
|
220
|
+
|
|
221
|
+
Add the include near the other includes:
|
|
222
|
+
|
|
223
|
+
```ruby
|
|
224
|
+
include PlutoniumGenerators::Concerns::ConfiguresSqlite
|
|
225
|
+
include PlutoniumGenerators::Concerns::ConfiguresRecurring
|
|
226
|
+
include PlutoniumGenerators::Concerns::MountsEngines
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Replace the entire `setup_recurring_tasks` method AND the `inject_rails_pulse_under_envs` method with:
|
|
230
|
+
|
|
231
|
+
```ruby
|
|
232
|
+
def setup_recurring_tasks
|
|
233
|
+
add_recurring_tasks(rails_pulse_tasks_yaml, marker: "rails_pulse")
|
|
234
|
+
end
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
Replace `rails_pulse_tasks_yaml(indent)` (which took an indent arg) with the no-arg version:
|
|
238
|
+
|
|
239
|
+
```ruby
|
|
240
|
+
def rails_pulse_tasks_yaml
|
|
241
|
+
<<~YAML
|
|
242
|
+
rails_pulse_summary:
|
|
243
|
+
class: RailsPulse::SummaryJob
|
|
244
|
+
queue: default
|
|
245
|
+
schedule: every hour at minute 5
|
|
246
|
+
description: "Roll up Rails Pulse raw records into summary tables"
|
|
247
|
+
|
|
248
|
+
rails_pulse_cleanup:
|
|
249
|
+
class: RailsPulse::CleanupJob
|
|
250
|
+
queue: default
|
|
251
|
+
schedule: every day at 1am
|
|
252
|
+
description: "Archive/purge old Rails Pulse data"
|
|
253
|
+
YAML
|
|
254
|
+
end
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
Delete the now-unused private helpers from the file: the old `setup_recurring_tasks` body that read the file / branched on `env_scoped` and called `inject_rails_pulse_under_envs`, and the entire `inject_rails_pulse_under_envs` method. (The `solid_queue_installed?` gate in `start` stays.)
|
|
258
|
+
|
|
259
|
+
- [ ] **Step 6: Require the concern in the lite generators test loader**
|
|
260
|
+
|
|
261
|
+
In `test/generators/lite_generators_test.rb`, add to `self.load_generators` (so the rails_pulse require resolves the new concern via Zeitwerk it already does, but add the rails_pulse generator to the loader to cover the refactor):
|
|
262
|
+
|
|
263
|
+
```ruby
|
|
264
|
+
require "generators/pu/lite/rails_pulse/rails_pulse_generator"
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
- [ ] **Step 7: Run tests to confirm no regression**
|
|
268
|
+
|
|
269
|
+
Run: `bundle exec appraisal rails-8.1 ruby -Itest test/generators/configures_recurring_test.rb test/generators/lite_generators_test.rb`
|
|
270
|
+
Expected: PASS
|
|
271
|
+
|
|
272
|
+
---
|
|
273
|
+
|
|
274
|
+
### Task 2: `pu:lite:tune` generator (SQLite pragmas)
|
|
275
|
+
|
|
276
|
+
**Goal:** Add a generator that inserts tuned, version-aware performance pragmas into `config/database.yml`'s `default: &default` block, idempotently.
|
|
277
|
+
|
|
278
|
+
**Files:**
|
|
279
|
+
- Create: `lib/generators/pu/lite/tune/tune_generator.rb`
|
|
280
|
+
- Modify: `test/generators/lite_generators_test.rb`
|
|
281
|
+
|
|
282
|
+
**Acceptance Criteria:**
|
|
283
|
+
- [ ] `Pu::Lite::TuneGenerator < Rails::Generators::Base`, includes `PlutoniumGenerators::Generator`.
|
|
284
|
+
- [ ] On Rails 8.1+, the inserted pragma block contains exactly the four deltas (`cache_size`, `temp_store`, `mmap_size`, `wal_autocheckpoint`) and no baseline keys.
|
|
285
|
+
- [ ] On Rails < 8.1, the block additionally contains the baseline set (`journal_mode`, `synchronous`, `foreign_keys`, `journal_size_limit`).
|
|
286
|
+
- [ ] Includes the `busy_timeout` rationale comment and never emits a `busy_timeout` key.
|
|
287
|
+
|
|
288
|
+
**Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/generators/lite_generators_test.rb` → PASS
|
|
289
|
+
|
|
290
|
+
**Steps:**
|
|
291
|
+
|
|
292
|
+
- [ ] **Step 1: Write the failing test**
|
|
293
|
+
|
|
294
|
+
Add to `test/generators/lite_generators_test.rb` — first add the require in `self.load_generators`:
|
|
295
|
+
|
|
296
|
+
```ruby
|
|
297
|
+
require "generators/pu/lite/tune/tune_generator"
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
Then add these tests inside the class:
|
|
301
|
+
|
|
302
|
+
```ruby
|
|
303
|
+
# Test Tune Generator
|
|
304
|
+
test "tune generator exists and has correct namespace" do
|
|
305
|
+
assert defined?(Pu::Lite::TuneGenerator)
|
|
306
|
+
assert Pu::Lite::TuneGenerator < Rails::Generators::Base
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
test "tune generator includes PlutoniumGenerators::Generator" do
|
|
310
|
+
assert Pu::Lite::TuneGenerator.include?(PlutoniumGenerators::Generator)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
test "tune pragma block on rails 8.1 contains the four deltas and no baseline" do
|
|
314
|
+
gen = Pu::Lite::TuneGenerator.new([], {}, {})
|
|
315
|
+
block = gen.send(:pragma_block, ::Gem::Version.new("8.1.0"))
|
|
316
|
+
|
|
317
|
+
%w[cache_size temp_store mmap_size wal_autocheckpoint].each do |key|
|
|
318
|
+
assert_match(/#{key}:/, block)
|
|
319
|
+
end
|
|
320
|
+
refute_match(/journal_mode:/, block)
|
|
321
|
+
refute_match(/busy_timeout/, block)
|
|
322
|
+
assert_match(/pragmas:/, block)
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
test "tune pragma block on rails < 8.1 also contains baseline pragmas" do
|
|
326
|
+
gen = Pu::Lite::TuneGenerator.new([], {}, {})
|
|
327
|
+
block = gen.send(:pragma_block, ::Gem::Version.new("8.0.0"))
|
|
328
|
+
|
|
329
|
+
%w[journal_mode synchronous foreign_keys journal_size_limit].each do |key|
|
|
330
|
+
assert_match(/#{key}:/, block)
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
- [ ] **Step 2: Run the test to verify it fails**
|
|
336
|
+
|
|
337
|
+
Run: `bundle exec appraisal rails-8.1 ruby -Itest test/generators/lite_generators_test.rb`
|
|
338
|
+
Expected: FAIL — `uninitialized constant Pu::Lite::TuneGenerator`
|
|
339
|
+
|
|
340
|
+
- [ ] **Step 3: Create the generator**
|
|
341
|
+
|
|
342
|
+
Create `lib/generators/pu/lite/tune/tune_generator.rb`:
|
|
343
|
+
|
|
344
|
+
```ruby
|
|
345
|
+
# frozen_string_literal: true
|
|
346
|
+
|
|
347
|
+
require_relative "../../lib/plutonium_generators"
|
|
348
|
+
|
|
349
|
+
module Pu
|
|
350
|
+
module Lite
|
|
351
|
+
class TuneGenerator < Rails::Generators::Base
|
|
352
|
+
include PlutoniumGenerators::Generator
|
|
353
|
+
|
|
354
|
+
desc "Tune config/database.yml with performance pragmas for SQLite"
|
|
355
|
+
|
|
356
|
+
RAILS_8_1 = ::Gem::Version.new("8.1.0")
|
|
357
|
+
DATABASE_YML = "config/database.yml"
|
|
358
|
+
|
|
359
|
+
def start
|
|
360
|
+
unless File.exist?(File.expand_path(DATABASE_YML, destination_root))
|
|
361
|
+
log :skip, "#{DATABASE_YML} not found"
|
|
362
|
+
return
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
if file_includes?(DATABASE_YML, "wal_autocheckpoint")
|
|
366
|
+
log :skip, "pragmas already tuned in #{DATABASE_YML}"
|
|
367
|
+
return
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
if file_includes?(DATABASE_YML, /^\s+pragmas:\s*$/)
|
|
371
|
+
# default block already has a pragmas: mapping — add our keys under it
|
|
372
|
+
insert_into_file DATABASE_YML, pragma_keys(rails_version),
|
|
373
|
+
after: /^\s+pragmas:\s*\n/, verbose: false
|
|
374
|
+
else
|
|
375
|
+
insert_into_file DATABASE_YML, pragma_block(rails_version),
|
|
376
|
+
after: /^default: &default\n/, verbose: false
|
|
377
|
+
end
|
|
378
|
+
say_status :tune, "added SQLite pragmas to #{DATABASE_YML}"
|
|
379
|
+
rescue => e
|
|
380
|
+
exception "#{self.class} failed:", e
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
private
|
|
384
|
+
|
|
385
|
+
# Full insertion: comment header + ` pragmas:` line + indented keys.
|
|
386
|
+
def pragma_block(version)
|
|
387
|
+
<<~YAML
|
|
388
|
+
\s\s# Plutonium-tuned SQLite pragmas (pu:lite:tune).
|
|
389
|
+
\s\s# Rails 8.1+ already sets WAL, synchronous=NORMAL, foreign_keys,
|
|
390
|
+
\s\s# mmap=128MB and journal_size_limit by default; only deltas are added
|
|
391
|
+
\s\s# there. We intentionally do NOT set busy_timeout — Rails routes the
|
|
392
|
+
\s\s# `timeout:` key to the sqlite3 gem's constant-poll busy_handler_timeout,
|
|
393
|
+
\s\s# which has better tail-latency than SQLite's internal backoff.
|
|
394
|
+
\s\spragmas:
|
|
395
|
+
YAML
|
|
396
|
+
.gsub(/\\s/, " ") + pragma_keys(version)
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
# Just the indented key lines (4-space indent, under pragmas:).
|
|
400
|
+
def pragma_keys(version)
|
|
401
|
+
keys = +""
|
|
402
|
+
if version < RAILS_8_1
|
|
403
|
+
keys << <<~YAML.gsub(/^/, " ")
|
|
404
|
+
journal_mode: WAL
|
|
405
|
+
synchronous: NORMAL
|
|
406
|
+
foreign_keys: true
|
|
407
|
+
journal_size_limit: 67108864 # 64 MB
|
|
408
|
+
YAML
|
|
409
|
+
end
|
|
410
|
+
keys << <<~YAML.gsub(/^/, " ")
|
|
411
|
+
cache_size: -64000 # 64 MB page cache (default ~2 MB is too small)
|
|
412
|
+
temp_store: 2 # MEMORY — sorts/temp indexes stay off disk
|
|
413
|
+
mmap_size: 536870912 # 512 MB (override the 128 MB default)
|
|
414
|
+
wal_autocheckpoint: 10000 # checkpoint every ~40 MB of WAL, fewer pauses
|
|
415
|
+
YAML
|
|
416
|
+
keys
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
def rails_version
|
|
420
|
+
@rails_version ||= ::Gem::Version.new(Rails::VERSION::STRING).release
|
|
421
|
+
end
|
|
422
|
+
end
|
|
423
|
+
end
|
|
424
|
+
end
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
> Implementation note for the executor: the `\s` escapes above are a readable way to keep the 2-space indent visible in the heredoc; if you prefer, write the literal spaces directly and drop the `.gsub(/\\s/, " ")`. What matters is that `pragma_block` returns the comment lines + ` pragmas:` (2-space indent) followed by `pragma_keys` (4-space indent). Verify by eye that the emitted YAML nests `pragmas:` under `default:` and the keys under `pragmas:`.
|
|
428
|
+
|
|
429
|
+
- [ ] **Step 4: Run the test to verify it passes**
|
|
430
|
+
|
|
431
|
+
Run: `bundle exec appraisal rails-8.1 ruby -Itest test/generators/lite_generators_test.rb`
|
|
432
|
+
Expected: PASS
|
|
433
|
+
|
|
434
|
+
- [ ] **Step 5: Manual sanity check in the dummy app (no commit)**
|
|
435
|
+
|
|
436
|
+
Run:
|
|
437
|
+
```bash
|
|
438
|
+
cd test/dummy && cp config/database.yml /tmp/database.yml.bak && \
|
|
439
|
+
bundle exec rails g pu:lite:tune && sed -n '1,20p' config/database.yml; \
|
|
440
|
+
cp /tmp/database.yml.bak config/database.yml
|
|
441
|
+
```
|
|
442
|
+
Expected: a `pragmas:` block appears nested under `default: &default`; restore leaves the dummy app unchanged. Confirm the YAML still parses: `ruby -ryaml -e 'YAML.load(ERB.new(File.read("config/database.yml")).result)' ` (run from the dummy dir before restoring, if desired).
|
|
443
|
+
|
|
444
|
+
---
|
|
445
|
+
|
|
446
|
+
### Task 3: `pu:lite:maintenance` generator (job + schedule)
|
|
447
|
+
|
|
448
|
+
**Goal:** Add a generator that installs `app/jobs/sqlite_maintenance_job.rb` and schedules it in `config/recurring.yml` via the shared concern.
|
|
449
|
+
|
|
450
|
+
**Files:**
|
|
451
|
+
- Create: `lib/generators/pu/lite/maintenance/maintenance_generator.rb`
|
|
452
|
+
- Create: `lib/generators/pu/lite/maintenance/templates/app/jobs/sqlite_maintenance_job.rb.tt`
|
|
453
|
+
- Modify: `test/generators/lite_generators_test.rb`
|
|
454
|
+
|
|
455
|
+
**Acceptance Criteria:**
|
|
456
|
+
- [ ] `Pu::Lite::MaintenanceGenerator < Rails::Generators::Base`, includes `PlutoniumGenerators::Generator` and `PlutoniumGenerators::Concerns::ConfiguresRecurring`.
|
|
457
|
+
- [ ] Has a `--schedule` option defaulting to `"every day at 3:30am"`.
|
|
458
|
+
- [ ] The job template defines `MaintenanceConnection`, `OPTIMIZE_DBS`, and `VACUUM_DBS = %w[primary errors rails_pulse]`, runs `PRAGMA optimize` everywhere and `VACUUM` only on `VACUUM_DBS`, skips missing DBs, and reports errors via `Rails.error.report`.
|
|
459
|
+
|
|
460
|
+
**Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/generators/lite_generators_test.rb` → PASS
|
|
461
|
+
|
|
462
|
+
**Steps:**
|
|
463
|
+
|
|
464
|
+
- [ ] **Step 1: Write the failing test**
|
|
465
|
+
|
|
466
|
+
Add the require to `self.load_generators` in `test/generators/lite_generators_test.rb`:
|
|
467
|
+
|
|
468
|
+
```ruby
|
|
469
|
+
require "generators/pu/lite/maintenance/maintenance_generator"
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
Add these tests:
|
|
473
|
+
|
|
474
|
+
```ruby
|
|
475
|
+
# Test Maintenance Generator
|
|
476
|
+
test "maintenance generator exists and has correct namespace" do
|
|
477
|
+
assert defined?(Pu::Lite::MaintenanceGenerator)
|
|
478
|
+
assert Pu::Lite::MaintenanceGenerator < Rails::Generators::Base
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
test "maintenance generator includes ConfiguresRecurring concern" do
|
|
482
|
+
assert Pu::Lite::MaintenanceGenerator.include?(PlutoniumGenerators::Concerns::ConfiguresRecurring)
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
test "maintenance generator has schedule option defaulting to daily 3:30am" do
|
|
486
|
+
options = Pu::Lite::MaintenanceGenerator.class_options
|
|
487
|
+
assert options.key?(:schedule)
|
|
488
|
+
assert_equal "every day at 3:30am", options[:schedule].default
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
test "maintenance job template defines the expected constants and behavior" do
|
|
492
|
+
path = File.expand_path(
|
|
493
|
+
"../../lib/generators/pu/lite/maintenance/templates/app/jobs/sqlite_maintenance_job.rb.tt",
|
|
494
|
+
__dir__
|
|
495
|
+
)
|
|
496
|
+
job = File.read(path)
|
|
497
|
+
|
|
498
|
+
assert_includes job, "class MaintenanceConnection < ActiveRecord::Base"
|
|
499
|
+
assert_includes job, "OPTIMIZE_DBS"
|
|
500
|
+
assert_includes job, "VACUUM_DBS = %w[primary errors rails_pulse]"
|
|
501
|
+
assert_includes job, "PRAGMA optimize"
|
|
502
|
+
assert_includes job, "VACUUM"
|
|
503
|
+
assert_includes job, "Rails.error.report"
|
|
504
|
+
end
|
|
505
|
+
```
|
|
506
|
+
|
|
507
|
+
- [ ] **Step 2: Run the test to verify it fails**
|
|
508
|
+
|
|
509
|
+
Run: `bundle exec appraisal rails-8.1 ruby -Itest test/generators/lite_generators_test.rb`
|
|
510
|
+
Expected: FAIL — `uninitialized constant Pu::Lite::MaintenanceGenerator`
|
|
511
|
+
|
|
512
|
+
- [ ] **Step 3: Create the job template**
|
|
513
|
+
|
|
514
|
+
Create `lib/generators/pu/lite/maintenance/templates/app/jobs/sqlite_maintenance_job.rb.tt`:
|
|
515
|
+
|
|
516
|
+
```ruby
|
|
517
|
+
class SqliteMaintenanceJob < ApplicationJob
|
|
518
|
+
queue_as :default
|
|
519
|
+
|
|
520
|
+
# Isolated connection for maintenance. Establishing on this dedicated
|
|
521
|
+
# abstract class (instead of ActiveRecord::Base) means we never mutate
|
|
522
|
+
# the global primary connection — a sibling job on the other worker
|
|
523
|
+
# thread keeps talking to the right database.
|
|
524
|
+
class MaintenanceConnection < ActiveRecord::Base
|
|
525
|
+
self.abstract_class = true
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
# Names match the keys in config/database.yml. Add your own database
|
|
529
|
+
# names here if you run extra SQLite databases.
|
|
530
|
+
#
|
|
531
|
+
# PRAGMA optimize is cheap (just refreshes query-planner stats, brief
|
|
532
|
+
# shared lock) so it runs everywhere. Full VACUUM rewrites the file
|
|
533
|
+
# under a global *exclusive* lock for its whole duration, so it only
|
|
534
|
+
# runs on databases without live 24/7 writers.
|
|
535
|
+
OPTIMIZE_DBS = %w[primary queue cache cable errors rails_pulse].freeze
|
|
536
|
+
|
|
537
|
+
# queue/cable/cache are deliberately excluded: SolidQueue, Solid Cable
|
|
538
|
+
# and Solid Cache write to them constantly, and a VACUUM lock there
|
|
539
|
+
# stalls (and errors out) those processes — e.g. SolidQueue's process
|
|
540
|
+
# deregistration hitting "database is locked". They also barely benefit:
|
|
541
|
+
# in WAL mode deleted pages land on the freelist and get reused, so a
|
|
542
|
+
# churning DB sits at a steady-state size without nightly reclamation.
|
|
543
|
+
VACUUM_DBS = %w[primary errors rails_pulse].freeze
|
|
544
|
+
|
|
545
|
+
def perform
|
|
546
|
+
OPTIMIZE_DBS.each { |db_name| run_maintenance(db_name) }
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
private
|
|
550
|
+
|
|
551
|
+
def run_maintenance(db_name)
|
|
552
|
+
config = ActiveRecord::Base.configurations.configs_for(
|
|
553
|
+
env_name: Rails.env,
|
|
554
|
+
name: db_name,
|
|
555
|
+
include_hidden: true
|
|
556
|
+
)
|
|
557
|
+
return unless config
|
|
558
|
+
|
|
559
|
+
MaintenanceConnection.establish_connection(config)
|
|
560
|
+
MaintenanceConnection.connection_pool.with_connection do |conn|
|
|
561
|
+
Rails.logger.info { "[SqliteMaintenance] PRAGMA optimize on #{db_name}" }
|
|
562
|
+
conn.execute("PRAGMA optimize")
|
|
563
|
+
|
|
564
|
+
next unless VACUUM_DBS.include?(db_name)
|
|
565
|
+
|
|
566
|
+
Rails.logger.info { "[SqliteMaintenance] VACUUM on #{db_name}" }
|
|
567
|
+
started = Time.current
|
|
568
|
+
conn.execute("VACUUM")
|
|
569
|
+
Rails.logger.info { "[SqliteMaintenance] VACUUM on #{db_name} done in #{(Time.current - started).round(2)}s" }
|
|
570
|
+
end
|
|
571
|
+
rescue => e
|
|
572
|
+
Rails.error.report(e, context: {db: db_name, action: "sqlite_maintenance"})
|
|
573
|
+
ensure
|
|
574
|
+
MaintenanceConnection.remove_connection
|
|
575
|
+
end
|
|
576
|
+
end
|
|
577
|
+
```
|
|
578
|
+
|
|
579
|
+
- [ ] **Step 4: Create the generator**
|
|
580
|
+
|
|
581
|
+
Create `lib/generators/pu/lite/maintenance/maintenance_generator.rb`:
|
|
582
|
+
|
|
583
|
+
```ruby
|
|
584
|
+
# frozen_string_literal: true
|
|
585
|
+
|
|
586
|
+
require_relative "../../lib/plutonium_generators"
|
|
587
|
+
|
|
588
|
+
module Pu
|
|
589
|
+
module Lite
|
|
590
|
+
class MaintenanceGenerator < Rails::Generators::Base
|
|
591
|
+
include PlutoniumGenerators::Generator
|
|
592
|
+
include PlutoniumGenerators::Concerns::ConfiguresRecurring
|
|
593
|
+
|
|
594
|
+
source_root File.expand_path("templates", __dir__)
|
|
595
|
+
|
|
596
|
+
desc "Install a nightly SqliteMaintenanceJob (PRAGMA optimize + VACUUM)"
|
|
597
|
+
|
|
598
|
+
class_option :schedule, type: :string, default: "every day at 3:30am",
|
|
599
|
+
desc: "Cron-style schedule for the maintenance job"
|
|
600
|
+
|
|
601
|
+
def start
|
|
602
|
+
template "app/jobs/sqlite_maintenance_job.rb"
|
|
603
|
+
|
|
604
|
+
if gem_in_bundle?("solid_queue")
|
|
605
|
+
unless add_recurring_tasks(maintenance_task_yaml, marker: "sqlite_maintenance")
|
|
606
|
+
log :skip, "could not schedule (config/recurring.yml missing or already scheduled)"
|
|
607
|
+
end
|
|
608
|
+
else
|
|
609
|
+
log :info, "solid_queue not found — job installed but not scheduled. Add a 'sqlite_maintenance' entry to your scheduler."
|
|
610
|
+
end
|
|
611
|
+
rescue => e
|
|
612
|
+
exception "#{self.class} failed:", e
|
|
613
|
+
end
|
|
614
|
+
|
|
615
|
+
private
|
|
616
|
+
|
|
617
|
+
def maintenance_task_yaml
|
|
618
|
+
<<~YAML
|
|
619
|
+
sqlite_maintenance:
|
|
620
|
+
class: SqliteMaintenanceJob
|
|
621
|
+
queue: default
|
|
622
|
+
schedule: #{options[:schedule]}
|
|
623
|
+
description: "VACUUM + PRAGMA optimize across SQLite databases"
|
|
624
|
+
YAML
|
|
625
|
+
end
|
|
626
|
+
end
|
|
627
|
+
end
|
|
628
|
+
end
|
|
629
|
+
```
|
|
630
|
+
|
|
631
|
+
- [ ] **Step 5: Run the test to verify it passes**
|
|
632
|
+
|
|
633
|
+
Run: `bundle exec appraisal rails-8.1 ruby -Itest test/generators/lite_generators_test.rb`
|
|
634
|
+
Expected: PASS
|
|
635
|
+
|
|
636
|
+
- [ ] **Step 6: Manual sanity check in the dummy app (no commit)**
|
|
637
|
+
|
|
638
|
+
Run:
|
|
639
|
+
```bash
|
|
640
|
+
cd test/dummy && bundle exec rails g pu:lite:maintenance && \
|
|
641
|
+
test -f app/jobs/sqlite_maintenance_job.rb && echo "JOB OK" && \
|
|
642
|
+
grep -q sqlite_maintenance config/recurring.yml && echo "SCHEDULE OK"; \
|
|
643
|
+
git checkout -- app config 2>/dev/null; rm -f app/jobs/sqlite_maintenance_job.rb
|
|
644
|
+
```
|
|
645
|
+
Expected: `JOB OK` and (if recurring.yml + solid_queue present) `SCHEDULE OK`; dummy app restored afterward.
|
|
646
|
+
|
|
647
|
+
---
|
|
648
|
+
|
|
649
|
+
### Task 4: Wire both generators into the `lite.rb` app template
|
|
650
|
+
|
|
651
|
+
**Goal:** The lite app template runs `pu:lite:tune` right after setup and `pu:lite:maintenance` after the solid/rails_pulse stack.
|
|
652
|
+
|
|
653
|
+
**Files:**
|
|
654
|
+
- Modify: `docs/public/templates/lite.rb`
|
|
655
|
+
|
|
656
|
+
**Acceptance Criteria:**
|
|
657
|
+
- [ ] `pu:lite:tune` is generated immediately after `pu:lite:setup`, with its own commit guard.
|
|
658
|
+
- [ ] `pu:lite:maintenance` is generated after the rails_pulse block, with its own commit guard.
|
|
659
|
+
- [ ] Only `docs/public/templates/lite.rb` is edited (not the `dist` copies).
|
|
660
|
+
|
|
661
|
+
**Verify:** `ruby -c docs/public/templates/lite.rb` → `Syntax OK`
|
|
662
|
+
|
|
663
|
+
**Steps:**
|
|
664
|
+
|
|
665
|
+
- [ ] **Step 1: Add the tune step after setup**
|
|
666
|
+
|
|
667
|
+
In `docs/public/templates/lite.rb`, immediately after the `pu:lite:setup` block:
|
|
668
|
+
|
|
669
|
+
```ruby
|
|
670
|
+
generate "pu:lite:setup"
|
|
671
|
+
git add: "."
|
|
672
|
+
git commit: %( -m 'setup sqlite') if `git status --porcelain`.present?
|
|
673
|
+
|
|
674
|
+
generate "pu:lite:tune"
|
|
675
|
+
git add: "."
|
|
676
|
+
git commit: %( -m 'tune sqlite pragmas') if `git status --porcelain`.present?
|
|
677
|
+
```
|
|
678
|
+
|
|
679
|
+
- [ ] **Step 2: Add the maintenance step after the rails_pulse block**
|
|
680
|
+
|
|
681
|
+
Inside `after_bundle`, after the closing `end` of the `unless ENV["SKIP_RAILS_PULSE"]` block and before the final `end`:
|
|
682
|
+
|
|
683
|
+
```ruby
|
|
684
|
+
unless ENV["SKIP_SQLITE_MAINTENANCE"]
|
|
685
|
+
generate "pu:lite:maintenance"
|
|
686
|
+
git add: "."
|
|
687
|
+
git commit: %( -m 'add sqlite maintenance job') if `git status --porcelain`.present?
|
|
688
|
+
end
|
|
689
|
+
```
|
|
690
|
+
|
|
691
|
+
- [ ] **Step 3: Verify syntax**
|
|
692
|
+
|
|
693
|
+
Run: `ruby -c docs/public/templates/lite.rb`
|
|
694
|
+
Expected: `Syntax OK`
|
|
695
|
+
|
|
696
|
+
---
|
|
697
|
+
|
|
698
|
+
### Task 5: Documentation page + sidebar entry
|
|
699
|
+
|
|
700
|
+
**Goal:** Add a reference page documenting `pu:lite:tune` and `pu:lite:maintenance` and link it in the VitePress sidebar.
|
|
701
|
+
|
|
702
|
+
**Files:**
|
|
703
|
+
- Create: `docs/reference/generators/lite.md`
|
|
704
|
+
- Modify: `docs/.vitepress/config.ts`
|
|
705
|
+
|
|
706
|
+
**Acceptance Criteria:**
|
|
707
|
+
- [ ] `docs/reference/generators/lite.md` documents both generators (purpose, options, idempotency, busy_timeout rationale, VACUUM live-writer exclusion rationale).
|
|
708
|
+
- [ ] A sidebar item links to `/reference/generators/lite` near the existing `/reference/app/generators` entry.
|
|
709
|
+
- [ ] `yarn docs:build` completes without broken-link errors.
|
|
710
|
+
|
|
711
|
+
**Verify:** `yarn docs:build` → completes successfully
|
|
712
|
+
|
|
713
|
+
**Steps:**
|
|
714
|
+
|
|
715
|
+
- [ ] **Step 1: Create the docs page**
|
|
716
|
+
|
|
717
|
+
Create `docs/reference/generators/lite.md`:
|
|
718
|
+
|
|
719
|
+
```markdown
|
|
720
|
+
# Lite (SQLite) Generators
|
|
721
|
+
|
|
722
|
+
The `pu:lite:*` generators configure a SQLite-first production stack. This page
|
|
723
|
+
covers the two tuning/maintenance generators; the solid_queue / solid_cache /
|
|
724
|
+
solid_cable / solid_errors / litestream / rails_pulse generators are run the
|
|
725
|
+
same way (`rails g pu:lite:<name>`).
|
|
726
|
+
|
|
727
|
+
## `pu:lite:tune`
|
|
728
|
+
|
|
729
|
+
Adds tuned performance pragmas to the `default: &default` block of
|
|
730
|
+
`config/database.yml`.
|
|
731
|
+
|
|
732
|
+
```bash
|
|
733
|
+
rails g pu:lite:tune
|
|
734
|
+
```
|
|
735
|
+
|
|
736
|
+
It writes a `pragmas:` mapping:
|
|
737
|
+
|
|
738
|
+
- `cache_size: -64000` — 64 MB page cache (the ~2 MB default is too small).
|
|
739
|
+
- `temp_store: 2` — MEMORY; sorts and temp indexes stay off disk.
|
|
740
|
+
- `mmap_size: 536870912` — 512 MB memory-mapped I/O.
|
|
741
|
+
- `wal_autocheckpoint: 10000` — checkpoint roughly every 40 MB of WAL.
|
|
742
|
+
|
|
743
|
+
On Rails < 8.1 it also writes the baseline pragmas (`journal_mode: WAL`,
|
|
744
|
+
`synchronous: NORMAL`, `foreign_keys: true`, `journal_size_limit`) that Rails 8.1+
|
|
745
|
+
already sets by default.
|
|
746
|
+
|
|
747
|
+
**Why no `busy_timeout`?** Rails routes the `timeout:` key to the sqlite3 gem's
|
|
748
|
+
constant-poll `busy_handler_timeout`, which has better tail-latency than SQLite's
|
|
749
|
+
internal exponential backoff. Setting `busy_timeout` in pragmas would replace the
|
|
750
|
+
better handler with the worse one, so this generator never emits it.
|
|
751
|
+
|
|
752
|
+
The generator is idempotent — re-running it detects the existing pragmas and skips.
|
|
753
|
+
|
|
754
|
+
## `pu:lite:maintenance`
|
|
755
|
+
|
|
756
|
+
Installs `app/jobs/sqlite_maintenance_job.rb` and (when `solid_queue` is present)
|
|
757
|
+
schedules it in `config/recurring.yml`.
|
|
758
|
+
|
|
759
|
+
```bash
|
|
760
|
+
rails g pu:lite:maintenance
|
|
761
|
+
# custom schedule:
|
|
762
|
+
rails g pu:lite:maintenance --schedule="every day at 4am"
|
|
763
|
+
```
|
|
764
|
+
|
|
765
|
+
The job runs `PRAGMA optimize` on every configured SQLite database and `VACUUM`
|
|
766
|
+
only on databases without live 24/7 writers (`primary`, `errors`, `rails_pulse`
|
|
767
|
+
by default — edit `VACUUM_DBS` in the generated job to suit your app).
|
|
768
|
+
|
|
769
|
+
**Why VACUUM only some databases?** SolidQueue, Solid Cache and Solid Cable write
|
|
770
|
+
to their databases constantly. `VACUUM` takes a global *exclusive* lock for its
|
|
771
|
+
whole duration, which stalls and errors those processes (e.g. SolidQueue process
|
|
772
|
+
deregistration failing with "database is locked"). They also barely benefit: in
|
|
773
|
+
WAL mode, freed pages land on the freelist and are reused, so a churning database
|
|
774
|
+
stays at a steady-state size without nightly reclamation. `PRAGMA optimize`, which
|
|
775
|
+
only takes a brief shared lock, still runs everywhere.
|
|
776
|
+
|
|
777
|
+
Databases listed in the job that don't exist in `config/database.yml` are skipped
|
|
778
|
+
at runtime, so the same job is safe regardless of which `pu:lite:*` generators you
|
|
779
|
+
have run.
|
|
780
|
+
```
|
|
781
|
+
|
|
782
|
+
- [ ] **Step 2: Add the sidebar entry**
|
|
783
|
+
|
|
784
|
+
In `docs/.vitepress/config.ts`, find the line:
|
|
785
|
+
|
|
786
|
+
```ts
|
|
787
|
+
{ text: "Generators", link: "/reference/app/generators" },
|
|
788
|
+
```
|
|
789
|
+
|
|
790
|
+
Add directly after it:
|
|
791
|
+
|
|
792
|
+
```ts
|
|
793
|
+
{ text: "Lite (SQLite) Generators", link: "/reference/generators/lite" },
|
|
794
|
+
```
|
|
795
|
+
|
|
796
|
+
- [ ] **Step 3: Build the docs**
|
|
797
|
+
|
|
798
|
+
Run: `yarn docs:build`
|
|
799
|
+
Expected: build completes; no dead-link errors referencing `/reference/generators/lite`.
|
|
800
|
+
|
|
801
|
+
---
|
|
802
|
+
|
|
803
|
+
### Task 6: Full suite + single final commit
|
|
804
|
+
|
|
805
|
+
**Goal:** Run the full generator test suite, then make one commit containing all changes (per the user's "commit at the end" instruction).
|
|
806
|
+
|
|
807
|
+
**Files:** none (commit only)
|
|
808
|
+
|
|
809
|
+
**Acceptance Criteria:**
|
|
810
|
+
- [ ] Generator tests pass on Rails 8.1.
|
|
811
|
+
- [ ] A single commit contains the two generators, the concern, the rails_pulse refactor, the template change, and the docs.
|
|
812
|
+
|
|
813
|
+
**Verify:** `git log -1 --stat` shows all the new/modified files in one commit.
|
|
814
|
+
|
|
815
|
+
**Steps:**
|
|
816
|
+
|
|
817
|
+
- [ ] **Step 1: Run the generator test suite**
|
|
818
|
+
|
|
819
|
+
Run: `bundle exec appraisal rails-8.1 ruby -Itest test/generators/lite_generators_test.rb test/generators/configures_recurring_test.rb`
|
|
820
|
+
Expected: PASS (0 failures, 0 errors)
|
|
821
|
+
|
|
822
|
+
- [ ] **Step 2: Review the working tree**
|
|
823
|
+
|
|
824
|
+
Run: `git status` and `git diff --stat`
|
|
825
|
+
Expected: only the files listed in this plan's File Structure table appear (plus the spec/plan docs). Confirm no `docs/dist` or `docs/.vitepress/dist` lite.rb copies were hand-edited.
|
|
826
|
+
|
|
827
|
+
- [ ] **Step 3: Commit everything**
|
|
828
|
+
|
|
829
|
+
```bash
|
|
830
|
+
git add lib/generators/pu/lite/tune \
|
|
831
|
+
lib/generators/pu/lite/maintenance \
|
|
832
|
+
lib/generators/pu/lib/plutonium_generators/concerns/configures_recurring.rb \
|
|
833
|
+
lib/generators/pu/lite/rails_pulse/rails_pulse_generator.rb \
|
|
834
|
+
docs/public/templates/lite.rb \
|
|
835
|
+
docs/reference/generators/lite.md \
|
|
836
|
+
docs/.vitepress/config.ts \
|
|
837
|
+
test/generators/lite_generators_test.rb \
|
|
838
|
+
test/generators/configures_recurring_test.rb \
|
|
839
|
+
docs/superpowers/specs/2026-06-04-sqlite-tune-maintenance-generators-design.md \
|
|
840
|
+
docs/superpowers/plans/2026-06-04-sqlite-tune-maintenance-generators.md
|
|
841
|
+
git commit -m "feat(generators/lite): add pu:lite:tune and pu:lite:maintenance for SQLite tuning + maintenance"
|
|
842
|
+
```
|
|
843
|
+
|
|
844
|
+
- [ ] **Step 4: Confirm the commit**
|
|
845
|
+
|
|
846
|
+
Run: `git log -1 --stat`
|
|
847
|
+
Expected: one commit with all the above files.
|
|
848
|
+
|
|
849
|
+
---
|
|
850
|
+
|
|
851
|
+
## Self-Review notes
|
|
852
|
+
|
|
853
|
+
- **Spec coverage:** tune (Task 2), maintenance job + schedule (Task 3), VACUUM list confirm-exists behavior (job template `return unless config` + editable `VACUUM_DBS`), `ConfiguresRecurring` extraction + rails_pulse refactor (Task 1), lite.rb wiring (Task 4), docs page + sidebar (Task 5), version-aware pragmas (Task 2), single commit (Task 6). All spec sections map to a task.
|
|
854
|
+
- **Placeholder scan:** none — all code is concrete.
|
|
855
|
+
- **Type/name consistency:** `add_recurring_tasks(tasks_yaml, marker:)` and `RecurringYAML#inject(content, tasks_yaml)` used consistently across Tasks 1/3; `pragma_block`/`pragma_keys` consistent in Task 2; `VACUUM_DBS`/`OPTIMIZE_DBS` consistent across Task 3 and the docs.
|
|
856
|
+
- **Verification requirement scan:** NO user verification required (automated tests). No verification task needed.
|
|
857
|
+
```
|