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.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium-resource/SKILL.md +21 -2
  3. data/.claude/skills/plutonium-ui/SKILL.md +15 -2
  4. data/CHANGELOG.md +31 -0
  5. data/app/assets/plutonium.css +1 -1
  6. data/app/assets/plutonium.js +94 -26
  7. data/app/assets/plutonium.js.map +2 -2
  8. data/app/assets/plutonium.min.js +9 -9
  9. data/app/assets/plutonium.min.js.map +3 -3
  10. data/config/initializers/rabl.rb +16 -0
  11. data/docs/.vitepress/config.ts +1 -0
  12. data/docs/public/templates/lite.rb +10 -0
  13. data/docs/reference/generators/lite.md +65 -0
  14. data/docs/reference/resource/definition.md +18 -2
  15. data/docs/reference/ui/assets.md +14 -0
  16. data/docs/reference/ui/displays.md +27 -1
  17. data/docs/reference/ui/forms.md +2 -1
  18. data/docs/reference/ui/layouts.md +33 -0
  19. data/docs/superpowers/plans/2026-06-04-sqlite-tune-maintenance-generators.md +857 -0
  20. data/docs/superpowers/plans/2026-06-04-sqlite-tune-maintenance-generators.md.tasks.json +45 -0
  21. data/docs/superpowers/specs/2026-06-04-sqlite-tune-maintenance-generators-design.md +238 -0
  22. data/gemfiles/rails_7.gemfile.lock +1 -1
  23. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  24. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  25. data/lib/generators/pu/core/update/update_generator.rb +4 -1
  26. data/lib/generators/pu/lib/plutonium_generators/concerns/configures_recurring.rb +89 -0
  27. data/lib/generators/pu/lite/maintenance/maintenance_generator.rb +45 -0
  28. data/lib/generators/pu/lite/maintenance/templates/app/jobs/sqlite_maintenance_job.rb.tt +60 -0
  29. data/lib/generators/pu/lite/rails_pulse/rails_pulse_generator.rb +4 -51
  30. data/lib/generators/pu/lite/rails_pulse/templates/config/initializers/rails_pulse.rb.tt +1 -1
  31. data/lib/generators/pu/lite/tune/tune_generator.rb +105 -0
  32. data/lib/plutonium/models/has_cents.rb +10 -0
  33. data/lib/plutonium/resource/controllers/interactive_actions.rb +19 -2
  34. data/lib/plutonium/routing/mapper_extensions.rb +5 -0
  35. data/lib/plutonium/ui/display/base.rb +9 -0
  36. data/lib/plutonium/ui/display/components/badge.rb +83 -0
  37. data/lib/plutonium/ui/display/components/boolean.rb +28 -6
  38. data/lib/plutonium/ui/display/components/currency.rb +50 -0
  39. data/lib/plutonium/ui/display/options/inferred_types.rb +13 -0
  40. data/lib/plutonium/ui/display/theme.rb +5 -0
  41. data/lib/plutonium/ui/form/base.rb +5 -0
  42. data/lib/plutonium/ui/form/components/toggle.rb +14 -0
  43. data/lib/plutonium/ui/form/concerns/renders_nested_resource_fields.rb +14 -25
  44. data/lib/plutonium/ui/form/concerns/renders_repeater_row_controls.rb +67 -0
  45. data/lib/plutonium/ui/form/concerns/renders_structured_inputs.rb +5 -38
  46. data/lib/plutonium/ui/form/interaction.rb +7 -2
  47. data/lib/plutonium/ui/form/options/inferred_types.rb +2 -0
  48. data/lib/plutonium/ui/form/resource.rb +1 -0
  49. data/lib/plutonium/ui/form/theme.rb +12 -0
  50. data/lib/plutonium/ui/grid/card.rb +58 -21
  51. data/lib/plutonium/ui/layout/icon_rail.rb +29 -9
  52. data/lib/plutonium/ui/sidebar_menu.rb +29 -0
  53. data/lib/plutonium/version.rb +1 -1
  54. data/package.json +1 -1
  55. data/plutonium.gemspec +5 -4
  56. data/src/css/components.css +126 -0
  57. data/src/js/controllers/dirty_form_guard_controller.js +55 -4
  58. data/src/js/controllers/nested_resource_form_fields_controller.js +35 -12
  59. data/src/js/controllers/resource_drop_down_controller.js +49 -14
  60. 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 &lt; 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
+ ```