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,45 @@
1
+ {
2
+ "planPath": "docs/superpowers/plans/2026-06-04-sqlite-tune-maintenance-generators.md",
3
+ "tasks": [
4
+ {
5
+ "id": 1,
6
+ "subject": "Task 1: Extract ConfiguresRecurring concern + refactor rails_pulse",
7
+ "status": "completed",
8
+ "description": "Move env-aware recurring.yml injection into a reusable, unit-testable ConfiguresRecurring concern (pure nested RecurringYAML); rails_pulse keeps identical behavior.\n\n```json:metadata\n{\"files\": [\"lib/generators/pu/lib/plutonium_generators/concerns/configures_recurring.rb\", \"lib/generators/pu/lite/rails_pulse/rails_pulse_generator.rb\", \"test/generators/configures_recurring_test.rb\"], \"verifyCommand\": \"bundle exec appraisal rails-8.1 ruby -Itest test/generators/configures_recurring_test.rb test/generators/lite_generators_test.rb\", \"acceptanceCriteria\": [\"ConfiguresRecurring autoloads and exposes add_recurring_tasks(tasks_yaml, marker:)\", \"RecurringYAML tested for env-scoped and flat layouts\", \"rails_pulse uses concern, old private method removed, tests still pass\"], \"requiresUserVerification\": false}\n```"
9
+ },
10
+ {
11
+ "id": 2,
12
+ "subject": "Task 2: pu:lite:tune generator (SQLite pragmas)",
13
+ "status": "completed",
14
+ "description": "Generator that inserts tuned, version-aware performance pragmas into config/database.yml default block, idempotently.\n\n```json:metadata\n{\"files\": [\"lib/generators/pu/lite/tune/tune_generator.rb\", \"test/generators/lite_generators_test.rb\"], \"verifyCommand\": \"bundle exec appraisal rails-8.1 ruby -Itest test/generators/lite_generators_test.rb\", \"acceptanceCriteria\": [\"TuneGenerator < Rails::Generators::Base, includes Generator\", \"Rails 8.1+ block = four deltas, no baseline, no busy_timeout\", \"Rails <8.1 also includes baseline pragmas\", \"idempotent re-run\"], \"requiresUserVerification\": false}\n```"
15
+ },
16
+ {
17
+ "id": 3,
18
+ "subject": "Task 3: pu:lite:maintenance generator (job + schedule)",
19
+ "status": "completed",
20
+ "blockedBy": [1],
21
+ "description": "Generator that installs app/jobs/sqlite_maintenance_job.rb and schedules it in config/recurring.yml via ConfiguresRecurring.\n\n```json:metadata\n{\"files\": [\"lib/generators/pu/lite/maintenance/maintenance_generator.rb\", \"lib/generators/pu/lite/maintenance/templates/app/jobs/sqlite_maintenance_job.rb.tt\", \"test/generators/lite_generators_test.rb\"], \"verifyCommand\": \"bundle exec appraisal rails-8.1 ruby -Itest test/generators/lite_generators_test.rb\", \"acceptanceCriteria\": [\"MaintenanceGenerator includes Generator + ConfiguresRecurring\", \"--schedule option defaults to 'every day at 3:30am'\", \"job template defines MaintenanceConnection/OPTIMIZE_DBS/VACUUM_DBS, PRAGMA optimize + VACUUM, Rails.error.report\"], \"requiresUserVerification\": false}\n```"
22
+ },
23
+ {
24
+ "id": 4,
25
+ "subject": "Task 4: Wire generators into lite.rb app template",
26
+ "status": "completed",
27
+ "blockedBy": [2, 3],
28
+ "description": "lite app template runs pu:lite:tune after setup and pu:lite:maintenance after the solid/rails_pulse stack, each with a commit guard. Only edit public source.\n\n```json:metadata\n{\"files\": [\"docs/public/templates/lite.rb\"], \"verifyCommand\": \"ruby -c docs/public/templates/lite.rb\", \"acceptanceCriteria\": [\"tune generated after setup with commit guard\", \"maintenance generated after rails_pulse with commit guard\", \"only public/templates/lite.rb edited\"], \"requiresUserVerification\": false}\n```"
29
+ },
30
+ {
31
+ "id": 5,
32
+ "subject": "Task 5: Docs page + sidebar entry",
33
+ "status": "completed",
34
+ "description": "Add docs/reference/generators/lite.md documenting both generators and link it in the VitePress sidebar.\n\n```json:metadata\n{\"files\": [\"docs/reference/generators/lite.md\", \"docs/.vitepress/config.ts\"], \"verifyCommand\": \"yarn docs:build\", \"acceptanceCriteria\": [\"lite.md documents both generators with rationale\", \"sidebar links /reference/generators/lite\", \"docs build passes with no broken links\"], \"requiresUserVerification\": false}\n```"
35
+ },
36
+ {
37
+ "id": 6,
38
+ "subject": "Task 6: Full suite + single final commit",
39
+ "status": "pending",
40
+ "blockedBy": [1, 2, 3, 4, 5],
41
+ "description": "Run full generator test suite, then make ONE commit with all changes (per user's 'commit at the end').\n\n```json:metadata\n{\"files\": [], \"verifyCommand\": \"bundle exec appraisal rails-8.1 ruby -Itest test/generators/lite_generators_test.rb test/generators/configures_recurring_test.rb\", \"acceptanceCriteria\": [\"generator tests pass on Rails 8.1\", \"single commit contains all generators, concern, refactor, template, docs\"], \"requiresUserVerification\": false}\n```"
42
+ }
43
+ ],
44
+ "lastUpdated": "2026-06-04"
45
+ }
@@ -0,0 +1,238 @@
1
+ # SQLite Tuning & Maintenance Generators — Design
2
+
3
+ **Date:** 2026-06-04
4
+ **Status:** Approved (design); pending implementation plan
5
+
6
+ ## Summary
7
+
8
+ Port two production-proven SQLite improvements from `radioactive_labs/universal_chatbot`
9
+ into the Plutonium generator template:
10
+
11
+ 1. **Config tuning** — performance pragmas in `config/database.yml`.
12
+ 2. **Maintenance** — a scheduled `SqliteMaintenanceJob` (`PRAGMA optimize` everywhere,
13
+ `VACUUM` on safe databases only).
14
+
15
+ Both ship as **two new generators** under the existing `pu:lite` namespace:
16
+ `pu:lite:tune` and `pu:lite:maintenance`. The existing `pu:lite:setup` generator is
17
+ left untouched.
18
+
19
+ ## Background
20
+
21
+ ### What the reference project does
22
+
23
+ `config/database.yml` `default: &default` block carries tuned pragmas:
24
+
25
+ ```yaml
26
+ pragmas:
27
+ cache_size: -64000 # 64 MB page cache (default ~2 MB is too small)
28
+ temp_store: 2 # MEMORY — sorts/temp indexes stay off disk
29
+ mmap_size: 536870912 # 512 MB (override the 128 MB default)
30
+ wal_autocheckpoint: 10000 # checkpoint every ~40 MB of WAL, fewer pauses
31
+ ```
32
+
33
+ It deliberately does **not** set `busy_timeout` — Rails routes the `timeout:` key to
34
+ the sqlite3 gem's constant-poll `busy_handler_timeout`, which has better tail-latency
35
+ than SQLite's internal backoff. Adding `busy_timeout` to pragmas would replace that with
36
+ the worse handler.
37
+
38
+ `app/jobs/sqlite_maintenance_job.rb` runs nightly via `config/recurring.yml`:
39
+
40
+ - Uses an isolated abstract connection class (`MaintenanceConnection`) so it never
41
+ mutates the global primary connection that sibling jobs rely on.
42
+ - `PRAGMA optimize` on all databases (cheap, brief shared lock).
43
+ - `VACUUM` only on databases without live 24/7 writers. Excludes `queue`/`cache`/`cable`
44
+ because SolidQueue / Solid Cache / Solid Cable write to them constantly, and VACUUM's
45
+ global exclusive lock stalls and errors those processes (e.g. SolidQueue process
46
+ deregistration hitting "database is locked"). This is the `2b813bdd` fix
47
+ ("stop nightly VACUUM from locking the live queue DB").
48
+ - Errors are reported via `Rails.error.report` and the connection is always removed in
49
+ an `ensure` block.
50
+
51
+ ### Current Plutonium state
52
+
53
+ - `pu:lite:setup` only ensures the `sqlite3` gem version and adds the Rails 7 enhanced
54
+ adapter. **No pragma tuning.**
55
+ - `pu:lite:*` generators (solid_queue, solid_cache, solid_cable, solid_errors,
56
+ rails_pulse, litestream) wire up extra databases via the `ConfiguresSqlite` concern.
57
+ - `rails_pulse_generator.rb` already contains working, env-aware `config/recurring.yml`
58
+ injection logic (handles both env-scoped and flat recurring files).
59
+ - **No pragma tuning and no maintenance job exist in the template today.**
60
+
61
+ ## Design Decisions (confirmed)
62
+
63
+ | Decision | Choice |
64
+ |---|---|
65
+ | Packaging | Two new generators: `pu:lite:tune`, `pu:lite:maintenance`. `pu:lite:setup` untouched. |
66
+ | Pragma values | Port reference values **verbatim**. |
67
+ | VACUUM target selection | Editable list in the generated job; runtime confirms each DB exists (`configs_for ... return unless config`); user can extend the list. |
68
+ | `recurring.yml` editing | Extract the rails_pulse injection logic into a shared `ConfiguresRecurring` concern; rails_pulse refactored to use it. |
69
+ | Generator names | `pu:lite:tune` and `pu:lite:maintenance` (confirmed). |
70
+
71
+ ## Component 1: `pu:lite:tune`
72
+
73
+ **File:** `lib/generators/pu/lite/tune/tune_generator.rb`
74
+
75
+ **Purpose:** Insert tuned performance pragmas into `config/database.yml`'s
76
+ `default: &default` block.
77
+
78
+ **Behavior:**
79
+
80
+ - Locate the `default: &default` mapping in `config/database.yml`.
81
+ - If a `pragmas:` key already exists under `default`, merge our keys, skipping any key
82
+ already present (no clobbering user values).
83
+ - If no `pragmas:` key exists, insert a new `pragmas:` block containing the four reference
84
+ deltas plus the reference's explanatory comments, including the `busy_timeout` note.
85
+ - **Version-aware pragma set:**
86
+ - Rails **8.1+**: write only the four deltas (`cache_size`, `temp_store`, `mmap_size`,
87
+ `wal_autocheckpoint`) — Rails already sets WAL / synchronous=NORMAL / foreign_keys /
88
+ mmap=128MB / journal_size_limit by default.
89
+ - Rails **< 8.1**: also emit the baseline set (`journal_mode: WAL`,
90
+ `synchronous: NORMAL`, `foreign_keys: true`, `journal_size_limit`) since these are not
91
+ guaranteed there. The Rails 7 path already pulls in
92
+ `activerecord-enhancedsqlite3-adapter` via `pu:lite:setup`, which supports pragmas.
93
+ - **Idempotent:** re-running skips keys already present; a marker (the comment header or
94
+ the presence of our keys) prevents duplicate insertion.
95
+ - Emits `say_status` lines for each key added.
96
+
97
+ **Reuse:** YAML location/insertion can lean on the existing `ConfiguresSqlite` concern's
98
+ file-manipulation helpers (`insert_into_file`, regex anchors) and `file_includes?`. The
99
+ default-block anchor is matched the same way the concern matches `default: &default`.
100
+
101
+ ## Component 2: `pu:lite:maintenance`
102
+
103
+ **File:** `lib/generators/pu/lite/maintenance/maintenance_generator.rb`
104
+ **Template:** `lib/generators/pu/lite/maintenance/templates/app/jobs/sqlite_maintenance_job.rb.tt`
105
+
106
+ **Purpose:** Install `SqliteMaintenanceJob` and schedule it in `config/recurring.yml`.
107
+
108
+ **Behavior:**
109
+
110
+ - `template` the job to `app/jobs/sqlite_maintenance_job.rb`, ported from the reference:
111
+ - Isolated `MaintenanceConnection < ActiveRecord::Base` (`abstract_class = true`).
112
+ - `OPTIMIZE_DBS` and `VACUUM_DBS = %w[primary errors rails_pulse]` as **editable
113
+ constants**, with comments instructing the user to add their own DB names.
114
+ - At runtime, `configs_for(env_name:, name:, include_hidden: true)` returns the config
115
+ or nil; `return unless config` silently skips DBs that don't exist — this is the
116
+ "keep a list, confirm they exist" behavior.
117
+ - `PRAGMA optimize` on every `OPTIMIZE_DBS` entry; `VACUUM` only on `VACUUM_DBS`.
118
+ - Preserve the live-writer exclusion rationale (queue/cache/cable) as comments.
119
+ - `rescue => e` → `Rails.error.report(e, context: {...})`; `ensure` removes the
120
+ connection.
121
+ - Add the recurring entry to `config/recurring.yml` using the shared
122
+ `ConfiguresRecurring` concern (see below):
123
+ ```yaml
124
+ sqlite_maintenance:
125
+ class: SqliteMaintenanceJob
126
+ queue: default
127
+ schedule: every day at 3:30am
128
+ description: "VACUUM + PRAGMA optimize across SQLite databases"
129
+ ```
130
+ - **Gating:** if solid_queue is not installed or `config/recurring.yml` is absent, still
131
+ write the job file but log that no scheduler was found (mirrors rails_pulse's
132
+ `solid_queue_installed?` gate).
133
+ - **Idempotent:** skip the recurring injection if `sqlite_maintenance` is already present;
134
+ `template` with conflict handling for the job file.
135
+
136
+ ## Component 3: `ConfiguresRecurring` concern (refactor)
137
+
138
+ **File:** `lib/generators/pu/lib/plutonium_generators/concerns/configures_recurring.rb`
139
+
140
+ Extract the env-aware `recurring.yml` injection currently private to
141
+ `rails_pulse_generator.rb` into a reusable concern. Concerns are autoloaded by Zeitwerk
142
+ (`Zeitwerk::Loader.for_gem` in `lib/generators/pu/lib/plutonium_generators.rb`), so a new
143
+ file at the conventional path with module nesting
144
+ `PlutoniumGenerators::Concerns::ConfiguresRecurring` is picked up automatically — no
145
+ manual `require` needed. Public surface:
146
+
147
+ ```ruby
148
+ # Inject one or more recurring task blocks into config/recurring.yml,
149
+ # handling both env-scoped (production:/development:) and flat layouts.
150
+ add_recurring_tasks(tasks_yaml, marker:)
151
+ ```
152
+
153
+ - `tasks_yaml`: the YAML body for the task(s), without leading indentation (the concern
154
+ re-indents per environment, as the existing code does).
155
+ - `marker`: a string used for the `file_includes?` idempotency guard (e.g.
156
+ `"rails_pulse"`, `"sqlite_maintenance"`).
157
+ - Internals are the existing `inject_*_under_envs` / indent-detection logic, generalized
158
+ to accept arbitrary task YAML instead of hardcoded rails_pulse tasks.
159
+
160
+ `rails_pulse_generator.rb` is refactored to call `add_recurring_tasks` with its existing
161
+ task YAML and `marker: "rails_pulse"`. No behavior change.
162
+
163
+ ## Testing
164
+
165
+ Add to `test/generators/` (alongside `lite_generators_test.rb`,
166
+ `configures_sqlite_test.rb`):
167
+
168
+ - **`pu:lite:tune`:**
169
+ - Inserts the `pragmas:` block with the four delta keys into the `default` anchor.
170
+ - Re-running is idempotent (no duplicate keys).
171
+ - Merges into an existing `pragmas:` block without clobbering user-set keys.
172
+ - (If feasible in the harness) version-aware: Rails < 8.1 emits the baseline set.
173
+ - **`pu:lite:maintenance`:**
174
+ - Creates `app/jobs/sqlite_maintenance_job.rb` with the expected constants.
175
+ - Injects the `sqlite_maintenance` entry under each environment in an env-scoped
176
+ `recurring.yml`; idempotent on re-run.
177
+ - When `recurring.yml` is absent, writes the job and logs the missing-scheduler notice.
178
+ - **`ConfiguresRecurring`:** the extracted concern behaves identically to the old
179
+ rails_pulse path (regression guard) — verify via the existing rails_pulse test still
180
+ passing plus a focused concern test.
181
+
182
+ ## App template integration (`lite.rb`)
183
+
184
+ `docs/public/templates/lite.rb` is the SQLite-stack app template that chains the
185
+ `pu:lite:*` generators `after_bundle` (setup → solid_queue/cache/cable/errors →
186
+ litestream → rails_pulse), each followed by a conditional git commit.
187
+
188
+ Wire **both** new generators into this template, matching the existing pattern (each with
189
+ its own `git add` / `git commit` guard):
190
+
191
+ - `pu:lite:tune` — immediately after `pu:lite:setup` (pragmas belong with the base SQLite
192
+ config, before the extra DBs are added).
193
+ - `pu:lite:maintenance` — after the solid stack and rails_pulse, so the extra databases
194
+ exist and `solid_queue` is present for scheduling.
195
+
196
+ Only edit the source template at `docs/public/templates/lite.rb`; the copies under
197
+ `docs/dist/` and `docs/.vitepress/dist/` are build artifacts regenerated by
198
+ `yarn docs:build`.
199
+
200
+ ## Documentation
201
+
202
+ - New standalone page **`docs/reference/generators/lite.md`** describing the `pu:lite:*`
203
+ generators, with full sections for `pu:lite:tune` and `pu:lite:maintenance` (purpose,
204
+ options, idempotency, the `busy_timeout` rationale, and the VACUUM live-writer
205
+ exclusion rationale). Documenting the other existing `pu:lite:*` generators on this page
206
+ is a nice-to-have but the two new ones are required.
207
+ - Add the new page to the VitePress sidebar in `docs/.vitepress/config.ts` (near the
208
+ existing `/reference/app/generators` entry) so it is reachable.
209
+ - Skills require a gem release to take effect; a skill update is **out of scope** for this
210
+ change (docs page + sidebar only).
211
+
212
+ ## Out of scope (YAGNI)
213
+
214
+ - App-specific extras from the reference (`sqlite_vec` extension loading, per-app
215
+ cleanup jobs, Rails Pulse summary/cleanup jobs) — those belong to their own generators
216
+ or the app, not this port.
217
+ - Changing `pu:lite:setup` behavior.
218
+ - Litestream / backup concerns (separate `pu:lite:litestream` generator already exists).
219
+
220
+ ## File change list
221
+
222
+ | Action | Path |
223
+ |---|---|
224
+ | Create | `lib/generators/pu/lite/tune/tune_generator.rb` |
225
+ | Create | `lib/generators/pu/lite/maintenance/maintenance_generator.rb` |
226
+ | Create | `lib/generators/pu/lite/maintenance/templates/app/jobs/sqlite_maintenance_job.rb.tt` |
227
+ | Create | `lib/generators/pu/lib/plutonium_generators/concerns/configures_recurring.rb` |
228
+ | Modify | `lib/generators/pu/lite/rails_pulse/rails_pulse_generator.rb` (use shared concern) |
229
+ | Modify | `docs/public/templates/lite.rb` (chain `pu:lite:tune` + `pu:lite:maintenance`) |
230
+ | Create | `test/generators/tune_generator_test.rb` (or extend `lite_generators_test.rb`) |
231
+ | Create | `test/generators/maintenance_generator_test.rb` (or extend `lite_generators_test.rb`) |
232
+ | Create | `docs/reference/generators/lite.md` |
233
+ | Modify | `docs/.vitepress/config.ts` (sidebar entry for the new page) |
234
+
235
+ > Zeitwerk autoloads concerns by path, so no edit to
236
+ > `lib/generators/pu/lib/plutonium_generators.rb` is required for the new concern.
237
+ > The `docs/dist/` and `docs/.vitepress/dist/` copies of `lite.rb` are build artifacts —
238
+ > do not hand-edit them.
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- plutonium (0.52.0)
4
+ plutonium (0.55.0)
5
5
  action_policy (~> 0.7.0)
6
6
  listen (~> 3.8)
7
7
  pagy (~> 43.0)
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- plutonium (0.52.0)
4
+ plutonium (0.55.0)
5
5
  action_policy (~> 0.7.0)
6
6
  listen (~> 3.8)
7
7
  pagy (~> 43.0)
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- plutonium (0.54.0)
4
+ plutonium (0.55.0)
5
5
  action_policy (~> 0.7.0)
6
6
  listen (~> 3.8)
7
7
  pagy (~> 43.0)
@@ -23,7 +23,10 @@ module Pu
23
23
  return unless File.file?(Rails.root.join(".claude", "skills", "plutonium", "SKILL.md"))
24
24
 
25
25
  say_status :update, "Syncing Plutonium Claude skills...", :green
26
- Rails::Generators.invoke("pu:skills:sync", [], destination_root: Rails.root)
26
+ # Shell out to a fresh process so the sync reads the newly-installed
27
+ # gem's skills. Invoking in-process would reuse the already-loaded
28
+ # (pre-update) gem, whose Plutonium.root still points at the old skills.
29
+ run "bin/rails generate pu:skills:sync"
27
30
  end
28
31
 
29
32
  def update_gem
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlutoniumGenerators
4
+ module Concerns
5
+ module ConfiguresRecurring
6
+ ENV_KEYS = %w[production development staging test].freeze
7
+
8
+ # Pure transform of a config/recurring.yml string. No file IO — testable
9
+ # in isolation, mirroring ConfiguresSqlite::DatabaseYAML.
10
+ class RecurringYAML
11
+ # Returns new content with `tasks_yaml` injected. If the file is
12
+ # env-scoped (has top-level production:/development:/... keys), the
13
+ # tasks are inserted under each environment at the siblings' indent.
14
+ # Otherwise they are appended at column 0.
15
+ def inject(content, tasks_yaml)
16
+ if env_scoped?(content)
17
+ inject_under_envs(content, tasks_yaml)
18
+ else
19
+ content.rstrip + "\n\n" + indent(tasks_yaml, 0)
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def env_scoped?(content)
26
+ content.lines.any? { |l| l.match?(env_re) }
27
+ end
28
+
29
+ def env_re
30
+ /^(#{ENV_KEYS.join("|")}):\s*$/
31
+ end
32
+
33
+ def indent(yaml, n)
34
+ pad = " " * n
35
+ yaml.gsub(/^(?=.)/, pad)
36
+ end
37
+
38
+ def inject_under_envs(content, tasks_yaml)
39
+ lines = content.lines
40
+ env_starts = lines.each_with_index.select { |l, _| env_re.match?(l) }.map(&:last)
41
+
42
+ env_starts.reverse_each do |start|
43
+ end_idx = lines.length
44
+ ((start + 1)...lines.length).each do |i|
45
+ if lines[i].match?(/^[^\s#]/)
46
+ end_idx = i
47
+ break
48
+ end
49
+ end
50
+
51
+ child_indent = 2
52
+ ((start + 1)...end_idx).each do |i|
53
+ if (m = lines[i].match(/^(\s+)\S/))
54
+ child_indent = m[1].length
55
+ break
56
+ end
57
+ end
58
+
59
+ insert_at = end_idx
60
+ while insert_at > start + 1 && lines[insert_at - 1].strip.empty?
61
+ insert_at -= 1
62
+ end
63
+
64
+ lines.insert(insert_at, "\n", indent(tasks_yaml, child_indent))
65
+ end
66
+
67
+ lines.join
68
+ end
69
+ end
70
+
71
+ private
72
+
73
+ # Inject recurring task YAML into config/recurring.yml. Returns true when
74
+ # written, false when the file is missing or the marker already present
75
+ # (idempotent). `marker` is matched with file_includes? to avoid dupes.
76
+ def add_recurring_tasks(tasks_yaml, marker:)
77
+ recurring_file = "config/recurring.yml"
78
+ full_path = File.expand_path(recurring_file, destination_root)
79
+ return false unless File.exist?(full_path)
80
+ return false if file_includes?(recurring_file, marker)
81
+
82
+ new_content = RecurringYAML.new.inject(File.read(full_path), tasks_yaml)
83
+ create_file recurring_file, new_content, force: true
84
+ say_status :recurring, "#{marker} (config/recurring.yml)"
85
+ true
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../lib/plutonium_generators"
4
+
5
+ module Pu
6
+ module Lite
7
+ class MaintenanceGenerator < Rails::Generators::Base
8
+ include PlutoniumGenerators::Generator
9
+ include PlutoniumGenerators::Concerns::ConfiguresRecurring
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ desc "Install a nightly SqliteMaintenanceJob (PRAGMA optimize + VACUUM)"
14
+
15
+ class_option :schedule, type: :string, default: "every day at 3:30am",
16
+ desc: "Cron-style schedule for the maintenance job"
17
+
18
+ def start
19
+ template "app/jobs/sqlite_maintenance_job.rb"
20
+
21
+ if gem_in_bundle?("solid_queue")
22
+ unless add_recurring_tasks(maintenance_task_yaml, marker: "sqlite_maintenance")
23
+ log :skip, "could not schedule (config/recurring.yml missing or already scheduled)"
24
+ end
25
+ else
26
+ log :info, "solid_queue not found — job installed but not scheduled. Add a 'sqlite_maintenance' entry to your scheduler."
27
+ end
28
+ rescue => e
29
+ exception "#{self.class} failed:", e
30
+ end
31
+
32
+ private
33
+
34
+ def maintenance_task_yaml
35
+ <<~YAML
36
+ sqlite_maintenance:
37
+ class: SqliteMaintenanceJob
38
+ queue: default
39
+ schedule: "#{options[:schedule]}"
40
+ description: "VACUUM + PRAGMA optimize across SQLite databases"
41
+ YAML
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,60 @@
1
+ class SqliteMaintenanceJob < ApplicationJob
2
+ queue_as :default
3
+
4
+ # Isolated connection for maintenance. Establishing on this dedicated
5
+ # abstract class (instead of ActiveRecord::Base) means we never mutate
6
+ # the global primary connection — a sibling job on the other worker
7
+ # thread keeps talking to the right database.
8
+ class MaintenanceConnection < ActiveRecord::Base
9
+ self.abstract_class = true
10
+ end
11
+
12
+ # Names match the keys in config/database.yml. Add your own database
13
+ # names here if you run extra SQLite databases.
14
+ #
15
+ # PRAGMA optimize is cheap (just refreshes query-planner stats, brief
16
+ # shared lock) so it runs everywhere. Full VACUUM rewrites the file
17
+ # under a global *exclusive* lock for its whole duration, so it only
18
+ # runs on databases without live 24/7 writers.
19
+ OPTIMIZE_DBS = %w[primary queue cache cable errors rails_pulse].freeze
20
+
21
+ # queue/cable/cache are deliberately excluded: SolidQueue, Solid Cable
22
+ # and Solid Cache write to them constantly, and a VACUUM lock there
23
+ # stalls (and errors out) those processes — e.g. SolidQueue's process
24
+ # deregistration hitting "database is locked". They also barely benefit:
25
+ # in WAL mode deleted pages land on the freelist and get reused, so a
26
+ # churning DB sits at a steady-state size without nightly reclamation.
27
+ VACUUM_DBS = %w[primary errors rails_pulse].freeze
28
+
29
+ def perform
30
+ OPTIMIZE_DBS.each { |db_name| run_maintenance(db_name) }
31
+ end
32
+
33
+ private
34
+
35
+ def run_maintenance(db_name)
36
+ config = ActiveRecord::Base.configurations.configs_for(
37
+ env_name: Rails.env,
38
+ name: db_name,
39
+ include_hidden: true
40
+ )
41
+ return unless config
42
+
43
+ MaintenanceConnection.establish_connection(config)
44
+ MaintenanceConnection.connection_pool.with_connection do |conn|
45
+ Rails.logger.info { "[SqliteMaintenance] PRAGMA optimize on #{db_name}" }
46
+ conn.execute("PRAGMA optimize")
47
+
48
+ next unless VACUUM_DBS.include?(db_name)
49
+
50
+ Rails.logger.info { "[SqliteMaintenance] VACUUM on #{db_name}" }
51
+ started = Time.current
52
+ conn.execute("VACUUM")
53
+ Rails.logger.info { "[SqliteMaintenance] VACUUM on #{db_name} done in #{(Time.current - started).round(2)}s" }
54
+ end
55
+ rescue => e
56
+ Rails.error.report(e, context: {db: db_name, action: "sqlite_maintenance"})
57
+ ensure
58
+ MaintenanceConnection.remove_connection
59
+ end
60
+ end
@@ -7,6 +7,7 @@ module Pu
7
7
  class RailsPulseGenerator < Rails::Generators::Base
8
8
  include PlutoniumGenerators::Generator
9
9
  include PlutoniumGenerators::Concerns::ConfiguresSqlite
10
+ include PlutoniumGenerators::Concerns::ConfiguresRecurring
10
11
  include PlutoniumGenerators::Concerns::MountsEngines
11
12
 
12
13
  source_root File.expand_path("templates", __dir__)
@@ -69,25 +70,11 @@ module Pu
69
70
  end
70
71
 
71
72
  def setup_recurring_tasks
72
- recurring_file = "config/recurring.yml"
73
- full_path = File.expand_path(recurring_file, destination_root)
74
- return unless File.exist?(full_path)
75
- return if file_includes?(recurring_file, "rails_pulse")
76
-
77
- content = File.read(full_path)
78
- env_keys = %w[production development staging test]
79
- env_scoped = content.lines.any? { |l| l.match?(/^(#{env_keys.join("|")}):\s*$/) }
80
-
81
- if env_scoped
82
- create_file recurring_file, inject_rails_pulse_under_envs(content, env_keys), force: true
83
- else
84
- append_to_file recurring_file, "\n" + rails_pulse_tasks_yaml(0)
85
- end
73
+ add_recurring_tasks(rails_pulse_tasks_yaml, marker: "rails_pulse")
86
74
  end
87
75
 
88
- def rails_pulse_tasks_yaml(indent)
89
- pad = " " * indent
90
- <<~YAML.gsub(/^(?=.)/, pad)
76
+ def rails_pulse_tasks_yaml
77
+ <<~YAML
91
78
  rails_pulse_summary:
92
79
  class: RailsPulse::SummaryJob
93
80
  queue: default
@@ -101,40 +88,6 @@ module Pu
101
88
  description: "Archive/purge old Rails Pulse data"
102
89
  YAML
103
90
  end
104
-
105
- def inject_rails_pulse_under_envs(content, env_keys)
106
- lines = content.lines
107
- env_re = /^(#{env_keys.join("|")}):\s*$/
108
-
109
- env_starts = lines.each_with_index.select { |l, _| env_re.match?(l) }.map(&:last)
110
-
111
- env_starts.reverse_each do |start|
112
- end_idx = lines.length
113
- ((start + 1)...lines.length).each do |i|
114
- if lines[i].match?(/^[^\s#]/)
115
- end_idx = i
116
- break
117
- end
118
- end
119
-
120
- indent = 2
121
- ((start + 1)...end_idx).each do |i|
122
- if (m = lines[i].match(/^(\s+)\S/))
123
- indent = m[1].length
124
- break
125
- end
126
- end
127
-
128
- insert_at = end_idx
129
- while insert_at > start + 1 && lines[insert_at - 1].strip.empty?
130
- insert_at -= 1
131
- end
132
-
133
- lines.insert(insert_at, "\n", rails_pulse_tasks_yaml(indent))
134
- end
135
-
136
- lines.join
137
- end
138
91
  end
139
92
  end
140
93
  end
@@ -27,6 +27,6 @@ RailsPulse.configure do |config|
27
27
  <%- if options[:database] -%>
28
28
 
29
29
  # Use separate database for performance data
30
- config.connects_to = {database: {writing: :<%= options[:database] %>}}
30
+ config.connects_to = {database: {writing: :<%= options[:database] %>, reading: :<%= options[:database] %>}}
31
31
  <%- end -%>
32
32
  end