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.
- checksums.yaml +4 -4
- data/.claude/skills/plutonium-behavior/SKILL.md +22 -0
- data/.claude/skills/plutonium-resource/SKILL.md +76 -2
- data/.claude/skills/plutonium-ui/SKILL.md +17 -3
- data/CHANGELOG.md +45 -0
- data/app/assets/plutonium.css +1 -1
- data/app/assets/plutonium.js +112 -26
- data/app/assets/plutonium.js.map +4 -4
- data/app/assets/plutonium.min.js +31 -31
- data/app/assets/plutonium.min.js.map +4 -4
- data/config/initializers/rabl.rb +16 -0
- data/docs/.vitepress/config.ts +1 -0
- data/docs/public/images/reference/structured-inputs-removed.png +0 -0
- data/docs/public/images/reference/structured-inputs.png +0 -0
- data/docs/public/templates/lite.rb +10 -0
- data/docs/reference/generators/lite.md +65 -0
- data/docs/reference/resource/definition.md +128 -2
- data/docs/reference/ui/assets.md +14 -0
- data/docs/reference/ui/displays.md +27 -1
- data/docs/reference/ui/forms.md +2 -1
- data/docs/reference/ui/layouts.md +33 -0
- data/docs/superpowers/plans/2026-06-02-structured-inputs.md +1061 -0
- data/docs/superpowers/plans/2026-06-02-structured-inputs.md.tasks.json +60 -0
- data/docs/superpowers/plans/2026-06-04-sqlite-tune-maintenance-generators.md +857 -0
- data/docs/superpowers/plans/2026-06-04-sqlite-tune-maintenance-generators.md.tasks.json +45 -0
- data/docs/superpowers/specs/2026-06-01-structured-inputs-design.md +191 -0
- data/docs/superpowers/specs/2026-06-04-sqlite-tune-maintenance-generators-design.md +238 -0
- data/gemfiles/rails_7.gemfile.lock +1 -1
- data/gemfiles/rails_8.0.gemfile.lock +1 -1
- data/gemfiles/rails_8.1.gemfile.lock +1 -1
- data/lib/generators/pu/core/update/update_generator.rb +4 -1
- data/lib/generators/pu/lib/plutonium_generators/concerns/configures_recurring.rb +89 -0
- data/lib/generators/pu/lite/maintenance/maintenance_generator.rb +45 -0
- data/lib/generators/pu/lite/maintenance/templates/app/jobs/sqlite_maintenance_job.rb.tt +60 -0
- data/lib/generators/pu/lite/rails_pulse/rails_pulse_generator.rb +4 -51
- data/lib/generators/pu/lite/rails_pulse/templates/config/initializers/rails_pulse.rb.tt +1 -1
- data/lib/generators/pu/lite/tune/tune_generator.rb +105 -0
- data/lib/plutonium/definition/base.rb +1 -0
- data/lib/plutonium/definition/structured_inputs.rb +67 -0
- data/lib/plutonium/interaction/README.md +24 -78
- data/lib/plutonium/interaction/base.rb +10 -2
- data/lib/plutonium/models/has_cents.rb +10 -0
- data/lib/plutonium/resource/controller.rb +6 -1
- data/lib/plutonium/resource/controllers/interactive_actions.rb +27 -6
- data/lib/plutonium/routing/mapper_extensions.rb +5 -0
- data/lib/plutonium/structured_inputs/param_cleaner.rb +36 -0
- data/lib/plutonium/structured_inputs/params_concern.rb +36 -0
- data/lib/plutonium/ui/display/base.rb +9 -0
- data/lib/plutonium/ui/display/components/badge.rb +83 -0
- data/lib/plutonium/ui/display/components/boolean.rb +28 -6
- data/lib/plutonium/ui/display/components/currency.rb +50 -0
- data/lib/plutonium/ui/display/options/inferred_types.rb +13 -0
- data/lib/plutonium/ui/display/theme.rb +5 -0
- data/lib/plutonium/ui/form/base.rb +5 -0
- data/lib/plutonium/ui/form/components/toggle.rb +14 -0
- data/lib/plutonium/ui/form/concerns/renders_nested_resource_fields.rb +17 -28
- data/lib/plutonium/ui/form/concerns/renders_repeater_row_controls.rb +67 -0
- data/lib/plutonium/ui/form/concerns/renders_structured_inputs.rb +145 -0
- data/lib/plutonium/ui/form/concerns/repeater_field_styles.rb +24 -0
- data/lib/plutonium/ui/form/interaction.rb +7 -2
- data/lib/plutonium/ui/form/options/inferred_types.rb +2 -0
- data/lib/plutonium/ui/form/resource.rb +5 -1
- data/lib/plutonium/ui/form/theme.rb +12 -0
- data/lib/plutonium/ui/grid/card.rb +58 -21
- data/lib/plutonium/ui/layout/icon_rail.rb +29 -9
- data/lib/plutonium/ui/modal/slideover.rb +9 -3
- data/lib/plutonium/ui/sidebar_menu.rb +29 -0
- data/lib/plutonium/version.rb +1 -1
- data/package.json +1 -1
- data/plutonium.gemspec +5 -4
- data/src/css/components.css +136 -5
- data/src/js/controllers/dirty_form_guard_controller.js +55 -4
- data/src/js/controllers/nested_resource_form_fields_controller.js +35 -12
- data/src/js/controllers/register_controllers.js +2 -0
- data/src/js/controllers/resource_drop_down_controller.js +49 -14
- data/src/js/controllers/structured_input_row_controller.js +26 -0
- metadata +30 -8
- data/docs/superpowers/specs/2026-06-01-interaction-repeater-inputs-design.md +0 -178
- 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.
|
|
@@ -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
|
-
|
|
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
|