plutonium 0.54.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 (79) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium-behavior/SKILL.md +22 -0
  3. data/.claude/skills/plutonium-resource/SKILL.md +76 -2
  4. data/.claude/skills/plutonium-ui/SKILL.md +17 -3
  5. data/CHANGELOG.md +45 -0
  6. data/app/assets/plutonium.css +1 -1
  7. data/app/assets/plutonium.js +112 -26
  8. data/app/assets/plutonium.js.map +4 -4
  9. data/app/assets/plutonium.min.js +31 -31
  10. data/app/assets/plutonium.min.js.map +4 -4
  11. data/config/initializers/rabl.rb +16 -0
  12. data/docs/.vitepress/config.ts +1 -0
  13. data/docs/public/images/reference/structured-inputs-removed.png +0 -0
  14. data/docs/public/images/reference/structured-inputs.png +0 -0
  15. data/docs/public/templates/lite.rb +10 -0
  16. data/docs/reference/generators/lite.md +65 -0
  17. data/docs/reference/resource/definition.md +128 -2
  18. data/docs/reference/ui/assets.md +14 -0
  19. data/docs/reference/ui/displays.md +27 -1
  20. data/docs/reference/ui/forms.md +2 -1
  21. data/docs/reference/ui/layouts.md +33 -0
  22. data/docs/superpowers/plans/2026-06-02-structured-inputs.md +1061 -0
  23. data/docs/superpowers/plans/2026-06-02-structured-inputs.md.tasks.json +60 -0
  24. data/docs/superpowers/plans/2026-06-04-sqlite-tune-maintenance-generators.md +857 -0
  25. data/docs/superpowers/plans/2026-06-04-sqlite-tune-maintenance-generators.md.tasks.json +45 -0
  26. data/docs/superpowers/specs/2026-06-01-structured-inputs-design.md +191 -0
  27. data/docs/superpowers/specs/2026-06-04-sqlite-tune-maintenance-generators-design.md +238 -0
  28. data/gemfiles/rails_7.gemfile.lock +1 -1
  29. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  30. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  31. data/lib/generators/pu/core/update/update_generator.rb +4 -1
  32. data/lib/generators/pu/lib/plutonium_generators/concerns/configures_recurring.rb +89 -0
  33. data/lib/generators/pu/lite/maintenance/maintenance_generator.rb +45 -0
  34. data/lib/generators/pu/lite/maintenance/templates/app/jobs/sqlite_maintenance_job.rb.tt +60 -0
  35. data/lib/generators/pu/lite/rails_pulse/rails_pulse_generator.rb +4 -51
  36. data/lib/generators/pu/lite/rails_pulse/templates/config/initializers/rails_pulse.rb.tt +1 -1
  37. data/lib/generators/pu/lite/tune/tune_generator.rb +105 -0
  38. data/lib/plutonium/definition/base.rb +1 -0
  39. data/lib/plutonium/definition/structured_inputs.rb +67 -0
  40. data/lib/plutonium/interaction/README.md +24 -78
  41. data/lib/plutonium/interaction/base.rb +10 -2
  42. data/lib/plutonium/models/has_cents.rb +10 -0
  43. data/lib/plutonium/resource/controller.rb +6 -1
  44. data/lib/plutonium/resource/controllers/interactive_actions.rb +27 -6
  45. data/lib/plutonium/routing/mapper_extensions.rb +5 -0
  46. data/lib/plutonium/structured_inputs/param_cleaner.rb +36 -0
  47. data/lib/plutonium/structured_inputs/params_concern.rb +36 -0
  48. data/lib/plutonium/ui/display/base.rb +9 -0
  49. data/lib/plutonium/ui/display/components/badge.rb +83 -0
  50. data/lib/plutonium/ui/display/components/boolean.rb +28 -6
  51. data/lib/plutonium/ui/display/components/currency.rb +50 -0
  52. data/lib/plutonium/ui/display/options/inferred_types.rb +13 -0
  53. data/lib/plutonium/ui/display/theme.rb +5 -0
  54. data/lib/plutonium/ui/form/base.rb +5 -0
  55. data/lib/plutonium/ui/form/components/toggle.rb +14 -0
  56. data/lib/plutonium/ui/form/concerns/renders_nested_resource_fields.rb +17 -28
  57. data/lib/plutonium/ui/form/concerns/renders_repeater_row_controls.rb +67 -0
  58. data/lib/plutonium/ui/form/concerns/renders_structured_inputs.rb +145 -0
  59. data/lib/plutonium/ui/form/concerns/repeater_field_styles.rb +24 -0
  60. data/lib/plutonium/ui/form/interaction.rb +7 -2
  61. data/lib/plutonium/ui/form/options/inferred_types.rb +2 -0
  62. data/lib/plutonium/ui/form/resource.rb +5 -1
  63. data/lib/plutonium/ui/form/theme.rb +12 -0
  64. data/lib/plutonium/ui/grid/card.rb +58 -21
  65. data/lib/plutonium/ui/layout/icon_rail.rb +29 -9
  66. data/lib/plutonium/ui/modal/slideover.rb +9 -3
  67. data/lib/plutonium/ui/sidebar_menu.rb +29 -0
  68. data/lib/plutonium/version.rb +1 -1
  69. data/package.json +1 -1
  70. data/plutonium.gemspec +5 -4
  71. data/src/css/components.css +136 -5
  72. data/src/js/controllers/dirty_form_guard_controller.js +55 -4
  73. data/src/js/controllers/nested_resource_form_fields_controller.js +35 -12
  74. data/src/js/controllers/register_controllers.js +2 -0
  75. data/src/js/controllers/resource_drop_down_controller.js +49 -14
  76. data/src/js/controllers/structured_input_row_controller.js +26 -0
  77. metadata +30 -8
  78. data/docs/superpowers/specs/2026-06-01-interaction-repeater-inputs-design.md +0 -178
  79. data/lib/plutonium/interaction/nested_attributes.rb +0 -93
@@ -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,191 @@
1
+ # Structured Inputs
2
+
3
+ **Date:** 2026-06-01 (updated 2026-06-02)
4
+ **Status:** Approved — DSL name `structured_input` final; ready for implementation plan
5
+
6
+ ## Problem
7
+
8
+ There is no first-class way to collect a **structured input** — a group of
9
+ fields gathered into one object, or a variable-length list of such objects —
10
+ without an ActiveRecord association behind it:
11
+
12
+ - **Interactions** declare scalar `attribute`s only. They cannot collect a
13
+ structured object (`address => {street:, city:}`) or a list of them. The
14
+ included `nested_input` is model-backed and silently renders nothing for the
15
+ classless case (characterized in
16
+ `test/plutonium/ui/form/interaction_nested_input_test.rb`).
17
+ - **Resources** can edit a JSON/jsonb column only as a raw JSON textarea or a
18
+ flat `key_value_store` — no structured UI for `{a, b}` or `[{a, b}, …]` stored
19
+ in a JSON column. Association-backed repetition is `nested_input`'s job; JSON
20
+ columns have nothing.
21
+
22
+ ## Goal
23
+
24
+ One DSL — **`structured_input`** — for a *classless* group of fields, collected
25
+ as:
26
+
27
+ - a **hash** (single object) by default, or
28
+ - an **array of hashes** when `repeat:` is given (the repeater),
29
+
30
+ rendered with a fields group, and feeding either an **interaction attribute** or
31
+ a **resource JSON column**. The single object is the base; the repeater is the
32
+ same group rendered N times.
33
+
34
+ ### Non-goals
35
+
36
+ - Type coercion of values. Rows/objects are plain hashes; the host validates.
37
+ - Replacing model-backed `nested_input` (stays the resource association feature).
38
+
39
+ ## Feasibility anchors
40
+
41
+ This design follows the grain of phlexi-form rather than fighting it:
42
+
43
+ 1. **`nest_one` → hash, `nest_many` → array.** The library's own
44
+ `extract_input` already produces exactly these shapes, and `nest_many` is
45
+ literally `nest_one` repeated:
46
+
47
+ ```ruby
48
+ # Namespace#extract_input (nest_one): { key => { field => value, … } }
49
+ # NamespaceCollection#extract_input (nest_many):
50
+ params = params[key]
51
+ params = params.values if params.is_a?(Hash) # index-hash → array, free
52
+ { key => Array(params).map { |p| namespace.extract_input([p]) } }
53
+ ```
54
+
55
+ 2. **Hash-backed rendering.** `Phlexi::Field::Support::Value.from` reads
56
+ `object[key]` for a `Hash`, so a row/object is a plain hash and the blank
57
+ template is `{}`. No synthesized classes.
58
+
59
+ 3. **Form-driven param extraction.** Plutonium extracts submitted resource
60
+ params via `build_form(record).extract_input(params)`
61
+ (`controller.rb#submitted_resource_params`). Nesting with `as: :<name>` makes
62
+ the extracted hash/array land under `:<name>`, which assigns **directly** to
63
+ the JSON column / interaction attribute — no `_attributes=` setter, no manual
64
+ strong-params.
65
+
66
+ ## Design
67
+
68
+ ### 1. The DSL — `structured_input`
69
+
70
+ ```ruby
71
+ # single → hash
72
+ structured_input :address do |f|
73
+ f.input :street
74
+ f.input :city
75
+ end
76
+ # value: address => { street:, city: }
77
+
78
+ # repeater → array of hashes (max 10 rows)
79
+ structured_input :contacts, repeat: 10 do |f|
80
+ f.input :label
81
+ f.input :phone_number
82
+ end
83
+ # value: contacts => [ { label:, phone_number: }, … ]
84
+ ```
85
+
86
+ - `repeat:` semantics: **`repeat: <int>` = maximum rows**; `repeat: true` =
87
+ repeater with the default cap; **absent = single** (hash). Presence of
88
+ `repeat:` always means an array — `repeat: 1` is "array, max one row", *not*
89
+ the single form.
90
+ - Fields come from a block or `using:`/`fields:` (the existing lightweight
91
+ `NestedInputsDefinition` holder, `defineable_props :field, :input`).
92
+ - Registers config in a `defined_structured_inputs` registry, via a shared
93
+ module included by both resource definitions (`Plutonium::Definition::Base`)
94
+ and interactions (`Plutonium::Interaction::Base`) — both already mix in
95
+ `DefineableProps`.
96
+
97
+ ### 2. Rendering (shared, classless)
98
+
99
+ The unit is the **fields group** — the declared inputs rendered over a nested
100
+ builder. Single and repeater differ only in `nest_one` vs `nest_many` and the
101
+ surrounding chrome:
102
+
103
+ - **Single:** `nest_one(:address, as: :address)` over the current value (or `{}`)
104
+ → one fieldset of inputs. No add/remove chrome, no `<template>`, no hidden
105
+ `id`/`_destroy`.
106
+ - **Repeater:** `nest_many(:contacts, as: :contacts)` → the existing repeater
107
+ chrome (the `nested-resource-form-fields` Stimulus controller, `limit` = the
108
+ `repeat` cap, a `<template>` holding a blank `{}` row, existing rows from the
109
+ array value, an add button, a per-row delete control). No hidden `id`/`_destroy`.
110
+
111
+ This reuses the same Stimulus controller and visual structure as the model-backed
112
+ repeater, but is a **separate classless code path**. Model-backed `nested_input`
113
+ rendering is untouched (still covered by
114
+ `test/integration/admin_portal/nested_form_rendering_test.rb`).
115
+
116
+ ### 3. Param flow (shared)
117
+
118
+ - Nesting with `as: :<name>` → `extract_input` yields `{name => {…}}` (single) or
119
+ `{name => [{…}, …]}` (repeater, index-hash already normalized to array).
120
+ - The value assigns **directly** to the JSON column / interaction attribute.
121
+ - A shared **clean step**, keyed off `defined_structured_inputs`, runs
122
+ post-extract: for repeaters, drop all-blank rows and strip `_destroy`; single
123
+ passes through. Rows are positional — **no ids**.
124
+
125
+ ### 4. Host — interactions
126
+
127
+ `structured_input :name` declares `attribute :name` defaulting to `{}` (single)
128
+ or `[]` (repeater). The extracted+cleaned value flows into the attribute;
129
+ `execute` sees a hash or an array.
130
+
131
+ ### 5. Host — resources (JSON, no model macro)
132
+
133
+ Because it is classless, the resource side needs **no `accepts_nested_attributes_for`-style
134
+ model declaration** — just:
135
+
136
+ - **Model:** a `json`/`jsonb` column named `name`.
137
+ - **Definition:** `structured_input :name …`.
138
+ - **Policy:** permit `:name` in `permitted_attributes_for_*` so the form renders
139
+ it.
140
+
141
+ Params then flow via the form's `extract_input` → clean → `record.name = hash/array`
142
+ → persisted in the JSON column. The `nested_input` model/definition split
143
+ dissolves here; classless JSON needs only the column + the definition DSL.
144
+
145
+ ### 6. Remove the broken nested surface from interactions
146
+
147
+ - `nested_input` is **removed** from interactions (`Interaction::Base` stops
148
+ mixing in `Plutonium::Definition::NestedInputs`) → `NoMethodError` at
149
+ class-load. `defined_nested_inputs` is likewise undefined; the inherited form
150
+ branch is guarded by `respond_to?` and simply skips.
151
+ - `accepts_nested_attributes_for` is **removed** from interactions
152
+ (`Interaction::Base` stops mixing in `Plutonium::Interaction::NestedAttributes`).
153
+ Build models explicitly in `execute` if ever needed.
154
+
155
+ Resources keep both model-backed `nested_input` and AR `accepts_nested_attributes_for`
156
+ unchanged, and additionally gain `structured_input`.
157
+
158
+ ## Testing
159
+
160
+ - **Clean step** (unit): array and index-keyed-hash → array; all-blank rows
161
+ dropped; `_destroy` stripped; single passes through; no ids.
162
+ - **Single** (interaction + resource): renders one fieldset; round-trip → a hash
163
+ value on the attribute / JSON column.
164
+ - **Repeater** (interaction + resource): renders the repeater chrome with
165
+ `host[contacts][NEW_RECORD][label]` naming, add/delete, no hidden id/`_destroy`;
166
+ round-trip → an array value; blank rows rejected; edit repopulates.
167
+ - **Guard** (unit): interactions do not respond to `nested_input` /
168
+ `accepts_nested_attributes_for` / `defined_nested_inputs` (replaces the current
169
+ `interaction_nested_input_test.rb`).
170
+ - **Regression**: resource `nested_input` unchanged — existing characterization
171
+ tests stay green.
172
+ - Fixtures via the `pu:*` generators; the resource fixture uses a JSON column.
173
+
174
+ ## Open questions (resolve in plan)
175
+
176
+ - **Clean-step placement:** one shared function, two call sites (the resource
177
+ controller param path; the interaction's param assignment). Confirm the exact
178
+ hooks.
179
+ - **Field naming:** `as: :<name>` (direct assignment, recommended) vs a
180
+ `_attributes` suffix. Verify no collision with form/extract internals.
181
+ - **JSON column type in the dummy fixture:** SQLite `json`/serialized text for
182
+ the test; document `jsonb` as the production recommendation.
183
+
184
+ ## Risk / breaking change
185
+
186
+ - Removing `nested_input` + `accepts_nested_attributes_for` from interactions is
187
+ a deliberate breaking change; the only previously-working case (an interaction
188
+ mirroring a real resource association) is redundant with resource forms.
189
+ Offenders get a `NoMethodError` naming the method; the README is reworked to
190
+ `structured_input`.
191
+ - `structured_input` is additive on resources and new on interactions.
@@ -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.53.1)
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