plutonium 0.62.2 → 0.63.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/SKILL.md +44 -0
- data/.claude/skills/plutonium-app/SKILL.md +3 -3
- data/.claude/skills/plutonium-async-interactions/SKILL.md +191 -0
- data/.claude/skills/plutonium-auth/SKILL.md +36 -0
- data/.claude/skills/plutonium-behavior/SKILL.md +121 -24
- data/.claude/skills/plutonium-kanban/SKILL.md +17 -3
- data/.claude/skills/plutonium-resource/SKILL.md +259 -12
- data/.claude/skills/plutonium-tenancy/SKILL.md +32 -3
- data/.claude/skills/plutonium-ui/SKILL.md +115 -14
- data/.claude/skills/plutonium-wizard/SKILL.md +73 -4
- data/CHANGELOG.md +67 -0
- data/CLAUDE.md +87 -0
- data/Rakefile +34 -0
- data/SECURITY.md +1 -1
- data/app/assets/plutonium.css +1 -1
- data/app/assets/plutonium.js +685 -102
- data/app/assets/plutonium.js.map +4 -4
- data/app/assets/plutonium.min.js +53 -53
- data/app/assets/plutonium.min.js.map +4 -4
- data/app/views/rodauth/_login_form.html.erb +13 -0
- data/db/migrate/async_interactions/20260817000001_create_plutonium_async_runs.rb +170 -0
- data/docs/.vitepress/config.ts +81 -3
- data/docs/.vitepress/theme/blog.data.ts +44 -0
- data/docs/.vitepress/theme/components/BlogIndex.vue +87 -0
- data/docs/.vitepress/theme/components/BlogMeta.vue +47 -0
- data/docs/.vitepress/theme/components/HomeFeatureTour.vue +293 -0
- data/docs/.vitepress/theme/components/HomeHero.vue +3 -3
- data/docs/.vitepress/theme/components/HomeInTheBox.vue +8 -0
- data/docs/.vitepress/theme/components/HomeStopWriting.vue +1 -0
- data/docs/.vitepress/theme/components/HomeWhyPlutonium.vue +84 -0
- data/docs/.vitepress/theme/index.ts +8 -4
- data/docs/blog/association-inputs-post-signed-ids.md +70 -0
- data/docs/blog/fix-the-model-not-the-policy.md +122 -0
- data/docs/blog/fractional-ordering-runs-out-of-room.md +67 -0
- data/docs/blog/half-finished-forms-are-pii.md +76 -0
- data/docs/blog/index.md +13 -0
- data/docs/blog/interactions-are-presentation-objects.md +152 -0
- data/docs/blog/introducing-plutonium.md +253 -0
- data/docs/blog/jobs-are-not-permission-snapshots.md +100 -0
- data/docs/blog/plutonium-and-ai-agents.md +48 -0
- data/docs/blog/realtime-is-one-line-and-four-dependencies.md +70 -0
- data/docs/blog/two-forms-one-dom-id.md +69 -0
- data/docs/blog/whats-new-async-kanban-wizards.md +130 -0
- data/docs/getting-started/tutorial/04-authorization.md +12 -3
- data/docs/getting-started/tutorial/06-nested-resources.md +3 -1
- data/docs/getting-started/tutorial/07-author-portal.md +2 -2
- data/docs/guides/authentication.md +73 -0
- data/docs/guides/authorization.md +2 -0
- data/docs/guides/creating-packages.md +5 -3
- data/docs/guides/custom-actions.md +74 -12
- data/docs/guides/customizing-ui.md +9 -2
- data/docs/guides/index.md +1 -0
- data/docs/guides/kanban.md +7 -5
- data/docs/guides/nested-resources.md +11 -1
- data/docs/guides/performance.md +104 -0
- data/docs/guides/user-invites.md +1 -1
- data/docs/guides/wizards.md +9 -1
- data/docs/index.md +3 -3
- data/docs/public/images/home/tour-actions.png +0 -0
- data/docs/public/images/home/tour-async.png +0 -0
- data/docs/public/images/home/tour-kanban.png +0 -0
- data/docs/public/images/home/tour-tenancy.png +0 -0
- data/docs/public/images/home/tour-wizard.png +0 -0
- data/docs/public/images/reference/async-progress-page.png +0 -0
- data/docs/public/images/reference/async-running-banner.png +0 -0
- data/docs/public/templates/experimental.rb +34 -0
- data/docs/public/templates/pluton8.rb +14 -0
- data/docs/reference/app/portals.md +15 -3
- data/docs/reference/auth/accounts.md +19 -0
- data/docs/reference/behavior/async-interactions.md +295 -0
- data/docs/reference/behavior/controllers.md +17 -4
- data/docs/reference/behavior/index.md +7 -1
- data/docs/reference/behavior/interactions.md +152 -22
- data/docs/reference/configuration.md +5 -0
- data/docs/reference/index.md +1 -0
- data/docs/reference/kanban/dsl.md +7 -4
- data/docs/reference/kanban/index.md +1 -1
- data/docs/reference/kanban/positioning.md +26 -4
- data/docs/reference/positioning.md +568 -0
- data/docs/reference/resource/actions.md +97 -4
- data/docs/reference/resource/definition.md +181 -9
- data/docs/reference/tenancy/invites.md +1 -1
- data/docs/reference/tenancy/nested-resources.md +60 -2
- data/docs/reference/ui/assets.md +4 -0
- data/docs/reference/ui/components.md +57 -4
- data/docs/reference/ui/displays.md +20 -10
- data/docs/reference/ui/index.md +1 -1
- data/docs/reference/wizard/dsl.md +33 -0
- data/docs/reference/wizard/storage-config.md +1 -0
- data/docs/superpowers/plans/2026-07-16-homepage-depth-upgrade.md +624 -0
- data/docs/superpowers/plans/2026-07-16-homepage-depth-upgrade.md.tasks.json +32 -0
- data/docs/superpowers/plans/2026-07-31-positioned-drag-and-drop.md +1787 -0
- data/docs/superpowers/plans/2026-07-31-positioned-drag-and-drop.md.tasks.json +91 -0
- data/docs/superpowers/plans/2026-08-17-async-interactions.md +1414 -0
- data/docs/superpowers/plans/2026-08-17-async-interactions.md.tasks.json +66 -0
- data/docs/superpowers/specs/2026-07-16-homepage-depth-upgrade-design.md +111 -0
- data/docs/superpowers/specs/2026-07-17-action-html-attributes-design.md +124 -0
- data/docs/superpowers/specs/2026-07-31-positioned-drag-and-drop-design.md +506 -0
- data/docs/superpowers/specs/2026-08-17-async-interactions-design.md +185 -0
- data/gemfiles/postgres.gemfile.lock +85 -85
- data/gemfiles/rails_7.gemfile.lock +322 -140
- data/gemfiles/rails_8.0.gemfile.lock +125 -115
- data/gemfiles/rails_8.1.gemfile.lock +126 -116
- data/lib/generators/pu/async_interactions/install_generator.rb +111 -0
- data/lib/generators/pu/async_interactions/templates/app/controllers/async_runs_controller.rb.tt +15 -0
- data/lib/generators/pu/core/typespec/typespec_generator.rb +7 -4
- data/lib/generators/pu/invites/install_generator.rb +3 -3
- data/lib/generators/pu/invites/templates/packages/invites/app/views/layouts/invites/invitation.html.erb.tt +2 -2
- data/lib/generators/pu/lib/plutonium_generators/concerns/mounts_engines.rb +47 -2
- data/lib/generators/pu/lib/plutonium_generators/concerns/resource_registration.rb +41 -0
- data/lib/generators/pu/lite/litestream/litestream_generator.rb +1 -1
- data/lib/generators/pu/lite/solid_queue/solid_queue_generator.rb +1 -1
- data/lib/generators/pu/res/conn/conn_generator.rb +19 -39
- data/lib/generators/pu/res/conn/templates/app/controllers/resource_controller.rb.tt +4 -0
- data/lib/generators/pu/rodauth/templates/app/rodauth/account_rodauth_plugin.rb.tt +15 -6
- data/lib/generators/pu/rodauth/templates/app/rodauth/rodauth_plugin.rb.tt +7 -0
- data/lib/generators/pu/saas/welcome/templates/app/views/layouts/welcome.html.erb.tt +2 -2
- data/lib/generators/pu/wizards/install_generator.rb +78 -0
- data/lib/plutonium/action/base.rb +71 -9
- data/lib/plutonium/action/interactive.rb +9 -0
- data/lib/plutonium/attachments.rb +254 -0
- data/lib/plutonium/configuration.rb +82 -1
- data/lib/plutonium/core/controller.rb +50 -7
- data/lib/plutonium/core/controllers/authorizable.rb +16 -0
- data/lib/plutonium/core/controllers/entity_scoping.rb +12 -2
- data/lib/plutonium/definition/base.rb +51 -0
- data/lib/plutonium/definition/display_layout.rb +112 -0
- data/lib/plutonium/definition/index_views.rb +8 -7
- data/lib/plutonium/definition/input_aliases.rb +38 -0
- data/lib/plutonium/definition/page_widths.rb +65 -0
- data/lib/plutonium/definition/positioning.rb +126 -0
- data/lib/plutonium/definition/sorting.rb +17 -2
- data/lib/plutonium/helpers/turbo_helper.rb +7 -0
- data/lib/plutonium/interaction/README.md +61 -24
- data/lib/plutonium/interaction/async/configuration.rb +38 -0
- data/lib/plutonium/interaction/async/context.rb +419 -0
- data/lib/plutonium/interaction/async/executor.rb +422 -0
- data/lib/plutonium/interaction/async/job.rb +80 -0
- data/lib/plutonium/interaction/async/reap_job.rb +81 -0
- data/lib/plutonium/interaction/async/run.rb +394 -0
- data/lib/plutonium/interaction/async/run_definition.rb +155 -0
- data/lib/plutonium/interaction/async/run_policy.rb +86 -0
- data/lib/plutonium/interaction/base.rb +34 -7
- data/lib/plutonium/interaction/concerns/dispatchable.rb +518 -0
- data/lib/plutonium/interaction/concerns/scoping.rb +70 -9
- data/lib/plutonium/interaction/response/redirect.rb +11 -3
- data/lib/plutonium/kanban/board.rb +14 -0
- data/lib/plutonium/kanban/column.rb +4 -2
- data/lib/plutonium/kanban/dsl.rb +4 -1
- data/lib/plutonium/kanban/grouping.rb +9 -22
- data/lib/plutonium/kanban/positioning.rb +5 -65
- data/lib/plutonium/positioning/config.rb +94 -0
- data/lib/plutonium/positioning/model.rb +128 -0
- data/lib/plutonium/positioning.rb +25 -86
- data/lib/plutonium/railtie.rb +1 -0
- data/lib/plutonium/resource/controller.rb +118 -38
- data/lib/plutonium/resource/controllers/crud_actions/index_action.rb +32 -2
- data/lib/plutonium/resource/controllers/crud_actions.rb +30 -2
- data/lib/plutonium/resource/controllers/eager_loading.rb +87 -0
- data/lib/plutonium/resource/controllers/export_csv.rb +10 -1
- data/lib/plutonium/resource/controllers/kanban_actions.rb +53 -14
- data/lib/plutonium/resource/controllers/position_actions.rb +390 -0
- data/lib/plutonium/resource/controllers/presentable.rb +19 -13
- data/lib/plutonium/resource/controllers/queryable.rb +5 -1
- data/lib/plutonium/resource/controllers/wizard_actions.rb +21 -0
- data/lib/plutonium/resource/policy.rb +33 -0
- data/lib/plutonium/resource/query_object.rb +36 -0
- data/lib/plutonium/routing/mapper_extensions.rb +100 -8
- data/lib/plutonium/routing/route_set_extensions.rb +15 -1
- data/lib/plutonium/routing/wizard_registration.rb +4 -0
- data/lib/plutonium/testing/resource_policy.rb +6 -2
- data/lib/plutonium/ui/action_button.rb +12 -7
- data/lib/plutonium/ui/actions_dropdown.rb +1 -1
- data/lib/plutonium/ui/block.rb +21 -1
- data/lib/plutonium/ui/breadcrumbs.rb +187 -55
- data/lib/plutonium/ui/component/methods.rb +5 -0
- data/lib/plutonium/ui/component/positionable.rb +112 -0
- data/lib/plutonium/ui/component/resolves_tags.rb +57 -0
- data/lib/plutonium/ui/component/section.rb +185 -0
- data/lib/plutonium/ui/display/base.rb +13 -1
- data/lib/plutonium/ui/display/components/formatted_value.rb +26 -0
- data/lib/plutonium/ui/display/components/section.rb +18 -0
- data/lib/plutonium/ui/display/resource.rb +141 -22
- data/lib/plutonium/ui/display/theme.rb +20 -1
- data/lib/plutonium/ui/export_button.rb +1 -1
- data/lib/plutonium/ui/form/base.rb +8 -7
- data/lib/plutonium/ui/form/components/intl_tel_input.rb +1 -1
- data/lib/plutonium/ui/form/components/section.rb +7 -62
- data/lib/plutonium/ui/form/components/uppy.rb +12 -1
- data/lib/plutonium/ui/form/concerns/renders_nested_resource_fields.rb +16 -3
- data/lib/plutonium/ui/form/concerns/renders_structured_inputs.rb +5 -1
- data/lib/plutonium/ui/form/query.rb +2 -4
- data/lib/plutonium/ui/form/resource.rb +92 -15
- data/lib/plutonium/ui/form/theme.rb +17 -0
- data/lib/plutonium/ui/form/wizard.rb +25 -1
- data/lib/plutonium/ui/grid/card.rb +79 -11
- data/lib/plutonium/ui/grid/resource.rb +47 -5
- data/lib/plutonium/ui/interaction/async/run_progress.rb +227 -0
- data/lib/plutonium/ui/interaction/async/running_banner.rb +65 -0
- data/lib/plutonium/ui/kanban/card.rb +2 -1
- data/lib/plutonium/ui/kanban/column.rb +12 -6
- data/lib/plutonium/ui/kanban/resource.rb +6 -7
- data/lib/plutonium/ui/layout/base.rb +10 -3
- data/lib/plutonium/ui/nav_grid_menu.rb +1 -0
- data/lib/plutonium/ui/page/base.rb +19 -0
- data/lib/plutonium/ui/page/edit.rb +4 -1
- data/lib/plutonium/ui/page/index.rb +69 -18
- data/lib/plutonium/ui/page/interactive_action.rb +5 -1
- data/lib/plutonium/ui/page/new.rb +4 -1
- data/lib/plutonium/ui/page/show.rb +27 -10
- data/lib/plutonium/ui/page/wizard.rb +10 -1
- data/lib/plutonium/ui/page/wizard_chooser.rb +36 -11
- data/lib/plutonium/ui/page_width.rb +58 -0
- data/lib/plutonium/ui/table/base.rb +34 -1
- data/lib/plutonium/ui/table/components/attachment.rb +1 -1
- data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +32 -8
- data/lib/plutonium/ui/table/components/drag_handle.rb +120 -0
- data/lib/plutonium/ui/table/components/filter_form.rb +1 -4
- data/lib/plutonium/ui/table/components/filter_pills.rb +1 -1
- data/lib/plutonium/ui/table/components/row_actions_dropdown.rb +1 -1
- data/lib/plutonium/ui/table/resource.rb +50 -5
- data/lib/plutonium/ui/table/theme.rb +59 -2
- data/lib/plutonium/ui/wizard/review.rb +4 -2
- data/lib/plutonium/ui/wizard/summary_display.rb +42 -14
- data/lib/plutonium/version.rb +1 -1
- data/lib/plutonium/wizard/attachments.rb +32 -197
- data/lib/plutonium/wizard/base.rb +6 -1
- data/lib/plutonium/wizard/configuration.rb +12 -0
- data/lib/plutonium/wizard/controller.rb +14 -0
- data/lib/plutonium/wizard/driving.rb +99 -15
- data/lib/plutonium/wizard/dsl.rb +23 -0
- data/lib/plutonium/wizard/resume.rb +127 -49
- data/lib/plutonium/wizard/runner.rb +46 -2
- data/lib/plutonium/wizard/step_adapter.rb +1 -1
- data/lib/plutonium/wizard/sweep_job.rb +16 -0
- data/lib/plutonium.rb +21 -0
- data/lib/rodauth/features/session_isolation.rb +92 -0
- data/lib/rodauth/plugins.rb +1 -0
- data/package.json +2 -1
- data/plutonium.gemspec +29 -11
- data/src/css/components.css +89 -1
- data/src/css/slim_select.css +20 -0
- data/src/js/controllers/breadcrumbs_controller.js +112 -0
- data/src/js/controllers/bulk_actions_controller.js +10 -2
- data/src/js/controllers/kanban_controller.js +30 -21
- data/src/js/controllers/positioned_controller.js +452 -0
- data/src/js/controllers/register_controllers.js +6 -0
- data/src/js/controllers/resource_drop_down_controller.js +5 -0
- data/src/js/controllers/run_progress_controller.js +73 -0
- data/src/js/drag/sortable.js +186 -0
- data/yarn.lock +108 -63
- metadata +103 -16
- data/docs/.vitepress/theme/components/HomeAudienceSplit.vue +0 -53
- data/docs/.vitepress/theme/components/HomePillars.vue +0 -42
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Plutonium drag-to-reorder: what happens when fractional ordering runs out of room"
|
|
3
|
+
titleTemplate: "Plutonium Blog"
|
|
4
|
+
date: 2026-09-21
|
|
5
|
+
description: Halving the gap between two decimals works about twenty times. The interesting engineering is in the twenty-first.
|
|
6
|
+
author: Stefan Froelich
|
|
7
|
+
tags: [positioning, rails, database]
|
|
8
|
+
draft: true
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# Plutonium drag-to-reorder: what happens when fractional ordering runs out of room
|
|
12
|
+
|
|
13
|
+
<BlogMeta />
|
|
14
|
+
|
|
15
|
+
The appeal of fractional ordering is obvious once you have written the alternative. To drop a row between two others, you write the midpoint of their positions. One row changes. No `UPDATE … SET position = position + 1` sweeping the table, no lock contention, no re-sending a list to the client.
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
Plutonium::Positioning.position_between(1.0, 3.0) # => 2.0
|
|
19
|
+
Plutonium::Positioning.position_between(nil, 5.0) # => 4.0 (prepend)
|
|
20
|
+
Plutonium::Positioning.position_between(5.0, nil) # => 6.0 (append)
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The part people skip is that midpoints are a finite resource, and running out is a correctness problem rather than a performance one.
|
|
24
|
+
|
|
25
|
+
## Twenty drops into the same slot
|
|
26
|
+
|
|
27
|
+
Every insert into the *same* gap halves it: `1.0`, then `0.5`, then `0.25`. After about twenty consecutive drops between the same two neighbours, the gap is below `1e-6`. Keep going and you are asking the database to distinguish decimals it cannot store, at which point two rows share a position and your ordering is whatever the query planner feels like today.
|
|
28
|
+
|
|
29
|
+
Twenty sounds like a lot until you picture a user tidying a backlog, repeatedly dragging things to the same spot near the top.
|
|
30
|
+
|
|
31
|
+
## The threshold and the column
|
|
32
|
+
|
|
33
|
+
Two numbers hold this together, and they are related in a way that is easy to get wrong.
|
|
34
|
+
|
|
35
|
+
`EPSILON` is `1e-6`. When a gap drops below it, `reposition!` rebalances **just that scope group**: it renumbers the group's rows to fresh integers (`1.0, 2.0, 3.0, …`) in current-position order, inside a transaction, reloads the two neighbours, and writes the new midpoint. Other scope groups are untouched, so rebalancing a "Doing" column does not disturb "Done".
|
|
36
|
+
|
|
37
|
+
The column is `decimal(16, 8)`, which the `t.position` migration helper emits for you:
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
create_table :tasks do |t|
|
|
41
|
+
t.position # decimal :position, precision: 16, scale: 8
|
|
42
|
+
end
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The relationship: the column needs **at least two more decimal places than `EPSILON`**. Rebalancing triggers at `1e-6`, so a `scale: 8` column still has room to write that final midpoint cleanly before the rebalance happens. Give it `scale: 6` and the last subdivision before a rebalance can round into a neighbour and momentarily collide, which is a rare, data-dependent bug that will not reproduce on your machine.
|
|
46
|
+
|
|
47
|
+
This is why the helper exists. `t.position` is not sugar for `decimal :position`; it is the framework refusing to let you pick a scale that appears to work.
|
|
48
|
+
|
|
49
|
+
## The bit I did not expect to matter
|
|
50
|
+
|
|
51
|
+
`reposition!` returns a `Plutonium::Positioning::Result`, and the useful thing on it is `rebalanced?`: did rows *other than this one* move?
|
|
52
|
+
|
|
53
|
+
That single boolean decides the HTTP response. If nothing else moved, the client's optimistic update is already correct and the endpoint answers `204 No Content`. If a rebalance happened, every row in that group has a new number, the client's model of the world is stale, and the endpoint sends the whole collection back.
|
|
54
|
+
|
|
55
|
+
Without it you have two bad options: always return the collection, and pay a full re-render on every drag; or never return it, and let the client drift out of sync on the one drag in twenty that renumbers everything. The flag turns a rare event into a cheap one instead of taxing the common case for it.
|
|
56
|
+
|
|
57
|
+
## End moves never rebalance
|
|
58
|
+
|
|
59
|
+
A drop at either end passes a `nil` neighbour, and `position_between` answers with `prev + 1` or `next - 1`. Integers, always room, no halving. So "drag to the top" and "drag to the bottom", which are the two most common gestures in any list, cannot trigger a rebalance at all.
|
|
60
|
+
|
|
61
|
+
That is a nice property that falls out of the arithmetic rather than being designed in, and it means the pathological case requires deliberately dropping into the same interior gap twenty times.
|
|
62
|
+
|
|
63
|
+
## The general lesson
|
|
64
|
+
|
|
65
|
+
Fractional ordering is usually presented as a trick: store decimals, write midpoints, done. It is a good trick. But it comes with a resource that depletes, and a system that uses it without a rebalance path is not simpler, it is unfinished. It just fails later, on a table someone has been reordering for two years, in a way that looks like the database losing your data.
|
|
66
|
+
|
|
67
|
+
The work is not the midpoint. It is knowing when you have run out of them, having somewhere to put the rows when you do, and telling the client which of those two things just happened.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Plutonium wizards: a half-finished form is PII sitting in your database"
|
|
3
|
+
titleTemplate: "Plutonium Blog"
|
|
4
|
+
date: 2026-09-24
|
|
5
|
+
description: Multi-step flows have to put the partial answers somewhere. Once that somewhere is a table instead of a cookie, it is data you are responsible for.
|
|
6
|
+
author: Stefan Froelich
|
|
7
|
+
tags: [wizards, security, rails]
|
|
8
|
+
draft: true
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# Plutonium wizards: a half-finished form is PII sitting in your database
|
|
12
|
+
|
|
13
|
+
<BlogMeta />
|
|
14
|
+
|
|
15
|
+
A single-page form has a comfortable property nobody thinks about: until the user hits submit, the data is in their browser. If they wander off, it evaporates. You never held it, so you never had to protect it, retain it, or explain it in a privacy policy.
|
|
16
|
+
|
|
17
|
+
Split that form across five screens and the property is gone. The answers from step two have to live somewhere while the user is on step three, and now you are storing them.
|
|
18
|
+
|
|
19
|
+
Plutonium wizards store staged step values in a `data` column on a single framework table. Which raises a question the framework should not answer silently: what is in that column?
|
|
20
|
+
|
|
21
|
+
## What is actually in there
|
|
22
|
+
|
|
23
|
+
Whatever the flow collects, from every step the user has completed so far. For an onboarding wizard that is a company name. For a checkout, an address. For an insurance quote, a date of birth and a medical history, sitting in a row belonging to someone who abandoned the flow at step four and is never coming back.
|
|
24
|
+
|
|
25
|
+
The rows are not long-lived by design; there is a sweep for abandoned sessions. But "not long-lived" is measured in your configured TTL, which is days, not the milliseconds a browser-held form would have been.
|
|
26
|
+
|
|
27
|
+
## The opt-in
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
class CheckoutWizard < Plutonium::Wizard::Base
|
|
31
|
+
encrypt_data
|
|
32
|
+
# ...
|
|
33
|
+
end
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
That encrypts the `data` column. Deliberately *not* encrypted: `tracked_records` (record GlobalIDs only), and the `owner`, `anchor`, `scope` and `token` columns, which have to stay queryable for the wizard to find and resume a session at all.
|
|
37
|
+
|
|
38
|
+
Once your app has ActiveRecord encryption keys, you can flip it on globally and opt out per wizard:
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
config.wizards.encrypt_data = true
|
|
42
|
+
|
|
43
|
+
class PublicSurveyWizard < Plutonium::Wizard::Base
|
|
44
|
+
encrypt_data false # explicit opt-out even when the global default is on
|
|
45
|
+
end
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
An explicit declaration on the wizard always wins. A wizard that declares neither inherits the global flag.
|
|
49
|
+
|
|
50
|
+
## Why it is off by default
|
|
51
|
+
|
|
52
|
+
Because it needs ActiveRecord encryption keys configured, and a framework that turns itself on and then raises on a missing key during someone's first `rails g` is a framework people uninstall. Off-by-default here is not an opinion that encryption is optional. It is an acknowledgement that the prerequisite is not in the framework's gift.
|
|
53
|
+
|
|
54
|
+
That is worth being honest about rather than dressing up, because "secure by default" is the answer everyone wants to give and it would have been the wrong one.
|
|
55
|
+
|
|
56
|
+
## The implementation detail that turned out to matter
|
|
57
|
+
|
|
58
|
+
`data` is one shared `jsonb` column across every wizard, some encrypted and some not. A model-level `encrypts :data` does not fit: it would encrypt every row regardless of the wizard's preference, and it fights the `jsonb` type.
|
|
59
|
+
|
|
60
|
+
So the store encrypts at write time with ActiveRecord's configured encryptor, the same keys `encrypts` would use, and writes a self-describing envelope:
|
|
61
|
+
|
|
62
|
+
```json
|
|
63
|
+
{ "_enc": "<ciphertext>" }
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
A row therefore decrypts based on **its own shape**, not on what the wizard's `encrypt_data?` currently says.
|
|
67
|
+
|
|
68
|
+
That is the part worth stealing if you are building something similar. It means toggling the flag never strands existing rows. Turn encryption on and the sessions already in flight keep working, plaintext, until they finish or get swept. Turn it off and the encrypted ones still decrypt. There is no migration, no backfill, and no window where a config change breaks resumption for everyone mid-flow.
|
|
69
|
+
|
|
70
|
+
The alternative, deciding how to read a row based on current configuration, works perfectly until the first time someone changes the configuration, which is precisely when you least want a surprise.
|
|
71
|
+
|
|
72
|
+
## The question the feature is really asking
|
|
73
|
+
|
|
74
|
+
Not "should I turn on encryption." It is "did I notice that this form now persists?"
|
|
75
|
+
|
|
76
|
+
Most multi-step flows get built by someone porting a long single-page form, and the persistence arrives as an implementation detail of the step mechanism rather than a decision anybody made. The flag exists mostly to prompt the thought. If your wizard collects nothing sensitive, ignore it. If it collects a date of birth, you are storing dates of birth, and that was true before anyone thought to ask.
|
data/docs/blog/index.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
---
|
|
2
|
+
layout: page
|
|
3
|
+
sidebar: false
|
|
4
|
+
aside: false
|
|
5
|
+
title: Blog
|
|
6
|
+
description: Release notes, design notes, and what's new in Plutonium.
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
<BlogIndex
|
|
10
|
+
eyebrow="Blog"
|
|
11
|
+
title="What we're building, and why."
|
|
12
|
+
lede="Release notes, design notes, and the reasoning behind the parts of Plutonium that aren't obvious from the reference."
|
|
13
|
+
/>
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Plutonium interactions are presentation objects, not service objects"
|
|
3
|
+
titleTemplate: "Plutonium Blog"
|
|
4
|
+
date: 2026-08-31
|
|
5
|
+
description: One required keyword argument in the constructor decides where your business logic is allowed to live, and it is not in the interaction.
|
|
6
|
+
author: Stefan Froelich
|
|
7
|
+
tags: [architecture, interactions, rails]
|
|
8
|
+
draft: true
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# Plutonium interactions are presentation objects, not service objects
|
|
12
|
+
|
|
13
|
+
<BlogMeta />
|
|
14
|
+
|
|
15
|
+
Every Rails codebase eventually grows a folder for "the logic that isn't a model and isn't a controller." It gets called `app/services`, or `app/operations`, or `app/commands`. Plutonium has a folder that looks like that. It is called `app/interactions`, and putting your business logic in it is usually a mistake.
|
|
16
|
+
|
|
17
|
+
Here is why, in one line of the base class:
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
def initialize(view_context:, **attributes)
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`view_context:` is required. Not optional, not defaulted. An interaction cannot be constructed without one.
|
|
24
|
+
|
|
25
|
+
That single constraint decides the whole architecture, so it is worth being precise about what it means.
|
|
26
|
+
|
|
27
|
+
## What the constraint rules out
|
|
28
|
+
|
|
29
|
+
A `view_context` comes from a Rails view render. A background job does not have one. Neither does an API controller responding with JSON, a rake task, a seeds script, or the console.
|
|
30
|
+
|
|
31
|
+
So the moment a second caller needs the operation, you have exactly two options, and both are bad:
|
|
32
|
+
|
|
33
|
+
1. **Duplicate the logic** in the job, and now two implementations of "publish a post" drift apart.
|
|
34
|
+
2. **Manufacture a `view_context`** so the job can build the interaction, which means a background job now owns a rendering context it has no business owning, purely to satisfy a constructor.
|
|
35
|
+
|
|
36
|
+
`view_context` is the tell. If an object requires a view to exist, that object is part of the presentation layer, whatever folder you filed it in.
|
|
37
|
+
|
|
38
|
+
## What an interaction is actually for
|
|
39
|
+
|
|
40
|
+
An interaction is the entry point from a Plutonium page into an operation. Its job is:
|
|
41
|
+
|
|
42
|
+
- declaring the inputs, which become a form
|
|
43
|
+
- rendering as a button in the right place, decided by whether it declares `:resource`, `:resources`, or neither
|
|
44
|
+
- validating the shape of what the user typed
|
|
45
|
+
- being gated by a policy method
|
|
46
|
+
- returning an outcome the controller turns into a flash message and a redirect
|
|
47
|
+
|
|
48
|
+
That is a complete and useful job. It is also entirely presentational. None of it is "what publishing a post means."
|
|
49
|
+
|
|
50
|
+
## So where does the logic go
|
|
51
|
+
|
|
52
|
+
On the model, per ordinary Rails convention. Named in domain language, not persistence language: `publish!`, `archive!`, `register!`, never `update_published_at`.
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
# app/models/post.rb
|
|
56
|
+
class Post < ApplicationRecord
|
|
57
|
+
def publish!(on: Time.current)
|
|
58
|
+
update!(published: true, published_at: on)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
# app/interactions/publish_post_interaction.rb
|
|
65
|
+
class PublishPostInteraction < ResourceInteraction
|
|
66
|
+
presents label: "Publish", icon: Phlex::TablerIcons::Send
|
|
67
|
+
|
|
68
|
+
attribute :resource
|
|
69
|
+
attribute :publish_date, :datetime, default: -> { Time.current }
|
|
70
|
+
|
|
71
|
+
input :publish_date
|
|
72
|
+
validates :publish_date, presence: true
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def execute
|
|
77
|
+
resource.publish!(on: publish_date)
|
|
78
|
+
succeed(resource).with_message("Post published!")
|
|
79
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
80
|
+
failed(e.record.errors)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
The interaction is three lines of doing and a lot of declaring. That ratio is the point.
|
|
86
|
+
|
|
87
|
+
## The rule, stated properly
|
|
88
|
+
|
|
89
|
+
**Logic may start in `execute`.** A one-off with a single caller is fine inline. Do not pre-extract, and do not invent a service layer for it. Plutonium deliberately ships nowhere to put one, and YAGNI applies here as much as anywhere.
|
|
90
|
+
|
|
91
|
+
**The trigger to extract is the second caller.** A background job, an API controller, a rake task, the console, another interaction. Not a line count, not a sense that the method is getting long.
|
|
92
|
+
|
|
93
|
+
**The destination is the model.** Fat models, the way Rails always meant it.
|
|
94
|
+
|
|
95
|
+
## The chain that gives it away
|
|
96
|
+
|
|
97
|
+
Here is the shape that tells you the logic ended up in the wrong layer:
|
|
98
|
+
|
|
99
|
+
```ruby
|
|
100
|
+
# Three interactions, three view_contexts.
|
|
101
|
+
CreateUserInteraction.call(view_context:, **user_params)
|
|
102
|
+
.and_then { |user| SendWelcomeEmail.call(view_context:, user:) }
|
|
103
|
+
.and_then { |user| LogActivity.call(view_context:, user:) }
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Now write the signup API endpoint. Or the seeds script. Or the admin rake task that backfills accounts. None of them can supply a `view_context`, so none of them can send the welcome email or write the audit row. The email and the audit trail are stranded inside the presentation layer, reachable only by someone clicking a button.
|
|
107
|
+
|
|
108
|
+
The same thing, put where it belongs:
|
|
109
|
+
|
|
110
|
+
```ruby
|
|
111
|
+
def execute
|
|
112
|
+
user = User.register!(**attributes) # welcome email + audit row live in here
|
|
113
|
+
succeed(user).with_message("Welcome aboard!")
|
|
114
|
+
end
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Chaining three interactions is usually one model method wearing three presentation costumes.
|
|
118
|
+
|
|
119
|
+
## What genuinely does belong inline
|
|
120
|
+
|
|
121
|
+
Not everything in `execute` is misplaced. This is correct:
|
|
122
|
+
|
|
123
|
+
```ruby
|
|
124
|
+
def execute
|
|
125
|
+
resource.update!(updated_by: current_user)
|
|
126
|
+
succeed(resource)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
def current_user = view_context.controller.helpers.current_user
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
"Who clicked the button" is context that only the presentation layer holds. A job has no answer for it. There is no second caller to extract for, because the fact being recorded is itself presentational.
|
|
135
|
+
|
|
136
|
+
## Validations split the same way
|
|
137
|
+
|
|
138
|
+
The split shows up again in validations, and it is worth getting right because the two kinds surface differently.
|
|
139
|
+
|
|
140
|
+
An **interaction validation** asks "can I read this input?" Is it present, does it parse, is the format plausible. It attaches to a declared attribute, so the re-rendered form shows the error inline against that field.
|
|
141
|
+
|
|
142
|
+
A **model validation** asks "is this record legal?" It holds no matter who is calling, including the job that has no form to render errors into. `failed(record.errors)` flattens `ActiveModel::Errors` into full messages on `:base`, so those land in the error summary rather than against a field.
|
|
143
|
+
|
|
144
|
+
Which means it is often right to duplicate a cheap invariant: the model keeps the authoritative copy, and the interaction keeps a copy purely so the message lands on the right input. What must never happen is the reverse, where only the interaction enforces it, because the day a job calls the model directly, the rule is not in the picture at all.
|
|
145
|
+
|
|
146
|
+
## Why a required keyword argument is a good design
|
|
147
|
+
|
|
148
|
+
It would have been easy to make `view_context:` optional. Default it to `nil`, let the interaction work headlessly, and interactions become service objects that happen to render forms.
|
|
149
|
+
|
|
150
|
+
Leaving it required is a constraint that answers an architectural question every Rails team argues about, permanently, at the point where the question is cheapest to answer: when you try to call the thing from a job and it will not build.
|
|
151
|
+
|
|
152
|
+
You can still write a service layer if you want one. Plutonium is plain Rails underneath and nothing stops you. But you will not drift into one by accident, which is how most of them get started.
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Introducing Plutonium: Rails conventions, past CRUD"
|
|
3
|
+
titleTemplate: "Plutonium Blog"
|
|
4
|
+
date: 2026-08-24
|
|
5
|
+
description: Rails made a bargain. Follow the conventions and the framework carries you. Plutonium makes the same bargain about the layer Rails deliberately left alone.
|
|
6
|
+
author: Stefan Froelich
|
|
7
|
+
tags: [announcement, rails, architecture]
|
|
8
|
+
draft: true
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# Introducing Plutonium: Rails conventions, past CRUD
|
|
12
|
+
|
|
13
|
+
<BlogMeta />
|
|
14
|
+
|
|
15
|
+
Rails made a bargain with you, and the bargain is the whole reason it works.
|
|
16
|
+
|
|
17
|
+
Name the column `user_id` and the association resolves itself. Name the file `posts_controller.rb` and the routing finds it. Put the partial where Rails expects it and it renders. You give up the freedom to arrange things however you like, and in exchange the framework stops asking you questions. Follow the conventions and the code nearly writes itself. Fight them and every line becomes a negotiation.
|
|
18
|
+
|
|
19
|
+
The second half of the bargain matters just as much. Rails never locks the door. Every convention has a configuration point behind it: `foreign_key:`, `class_name:`, `to_prepare`, a custom inflection, a controller that renders whatever you want. You get to ninety percent without making a decision, and the rest stays possible.
|
|
20
|
+
|
|
21
|
+
That bargain stops at the framework layer, on purpose. Rails gives you models, controllers, views, routing, migrations. It does not give you an opinion about authentication flows, authorization rules, admin interfaces, multi-tenancy, or the difference between "a record" and "a thing a user is allowed to act on." Those are application concerns. Rails leaves them to you, which is the right call for a general-purpose framework and also the reason you have written them four times.
|
|
22
|
+
|
|
23
|
+
Plutonium makes the same bargain about that layer: follow the convention and it carries you, reach for an escape hatch when you need one.
|
|
24
|
+
|
|
25
|
+
## Design the model correctly and multi-tenancy stops being work
|
|
26
|
+
|
|
27
|
+
Multi-tenancy is the clearest example, because it's the feature most often implemented as a discipline problem. The usual approach: add a tenant filter to every query, scope every controller action, then review every pull request for the one place someone forgot. The failure mode is a data leak.
|
|
28
|
+
|
|
29
|
+
In Plutonium you don't write filters. You declare where the tenant lives, once, on the portal:
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
# packages/admin_portal/lib/engine.rb
|
|
33
|
+
module AdminPortal
|
|
34
|
+
class Engine < ::Rails::Engine
|
|
35
|
+
include Plutonium::Portal::Engine
|
|
36
|
+
|
|
37
|
+
scope_to_entity Organization, strategy: :path
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Then you design your models the way you would have anyway:
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
class Post < ApplicationRecord
|
|
46
|
+
belongs_to :organization # a direct child of the tenant
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
class Comment < ApplicationRecord
|
|
50
|
+
belongs_to :post
|
|
51
|
+
has_one :organization, through: :post # reachable through an association
|
|
52
|
+
end
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
That's the entire setup. Every query in that portal is scoped, every create is associated, every nested route is constrained, because Plutonium can read the path from a record to its tenant out of your associations. Resolution happens in a defined order: a direct `belongs_to` first, then `has_one` or `has_one :through`, then a reverse `has_many` from the entity if it has to.
|
|
56
|
+
|
|
57
|
+
What matters more is the case where your schema is complicated, because real ones are. When no association expresses the relationship, you don't abandon the convention and start hand-scoping controllers. You write the resolution yourself, on the model:
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
class Invoice < ApplicationRecord
|
|
61
|
+
scope :associated_with_organization, ->(org) {
|
|
62
|
+
joins(:contract).where(contracts: {organization_id: org.id})
|
|
63
|
+
}
|
|
64
|
+
end
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Now `Invoice` scopes exactly the way `Post` does. The index, the show page, nested routes, the authorization scope: all of it behaves identically. You replaced one lookup, not the mechanism.
|
|
68
|
+
|
|
69
|
+
The convention covers the common shapes, and the exception gets a named place to live. It lives on the *model* rather than in a policy or a controller, so it applies everywhere the record is read.
|
|
70
|
+
|
|
71
|
+
## Nothing gets declared twice
|
|
72
|
+
|
|
73
|
+
A definition can be empty:
|
|
74
|
+
|
|
75
|
+
```ruby
|
|
76
|
+
class PostDefinition < ResourceDefinition
|
|
77
|
+
end
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
That already renders an index table, a form and a show page. Plutonium reads the model for the rest: column types become field types, `belongs_to` and `has_many` become pickers and nested tables, an attached file becomes an upload. Validations carry through too, so `validates :title, presence: true` marks the field required and `validates :status, inclusion: {in: %w[draft published archived]}` supplies the select's choices.
|
|
81
|
+
|
|
82
|
+
That move is not a definition feature. It's how the whole framework is built:
|
|
83
|
+
|
|
84
|
+
- The tenant scope is read off your associations, which is why the multi-tenancy above needed no filters.
|
|
85
|
+
- A collection's preloads are read off the policy's permitted fields, so there is no `includes` list to maintain.
|
|
86
|
+
- An association picker's typeahead is read off the target resource's own `search` block.
|
|
87
|
+
- Whether an interaction is a record, bulk or resource action is read off whether it declares `:resource`, `:resources`, or neither.
|
|
88
|
+
- CRUD routes, nested routes and action routes are read off the resource registration.
|
|
89
|
+
|
|
90
|
+
None of these is a default someone picked for you. Each is computed from a declaration you already made for another reason, which is the part that matters: there is one place to change it, and nothing downstream to keep in step. Add a column, and the form, the table, the export and the preloads all move together.
|
|
91
|
+
|
|
92
|
+
So you declare only what differs. A `field :title` matching what was detected is dead code.
|
|
93
|
+
|
|
94
|
+
That leaves the differences, and two portals rarely want the same ones.
|
|
95
|
+
|
|
96
|
+
## Overrides follow the inheritance you already understand
|
|
97
|
+
|
|
98
|
+
Rails view lookup walks an inheritance chain. A template in your controller's own directory wins, otherwise it falls back to the parent's.
|
|
99
|
+
|
|
100
|
+
Plutonium customization works the same way, because it is the same thing: plain Ruby inheritance, across three levels.
|
|
101
|
+
|
|
102
|
+
Your resource has an app-wide definition:
|
|
103
|
+
|
|
104
|
+
```ruby
|
|
105
|
+
# app/definitions/post_definition.rb
|
|
106
|
+
class PostDefinition < ResourceDefinition
|
|
107
|
+
input :content, as: :markdown
|
|
108
|
+
|
|
109
|
+
display :status, as: :badge, colors: {published: :accent, draft: :neutral}
|
|
110
|
+
end
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
A portal that needs something different inherits from it:
|
|
114
|
+
|
|
115
|
+
```ruby
|
|
116
|
+
# packages/admin_portal/app/definitions/admin_portal/post_definition.rb
|
|
117
|
+
class AdminPortal::PostDefinition < ::PostDefinition
|
|
118
|
+
scope :pending_review
|
|
119
|
+
input :internal_notes, hint: "Not shown to the author"
|
|
120
|
+
end
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
The policy for that portal inherits the same way, and decides a different question:
|
|
124
|
+
|
|
125
|
+
```ruby
|
|
126
|
+
# packages/admin_portal/app/policies/admin_portal/post_policy.rb
|
|
127
|
+
class AdminPortal::PostPolicy < ::PostPolicy
|
|
128
|
+
include AdminPortal::ResourcePolicy
|
|
129
|
+
|
|
130
|
+
def permitted_attributes_for_create = %i[title content internal_notes]
|
|
131
|
+
end
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
That split is worth being precise about. The definition says *how* a field renders. The policy says *whether it appears at all*. `internal_notes` shows up in the admin portal because the admin policy permits it, not because the admin definition mentions it, and a customer-facing portal that inherits from the same two base classes makes both decisions independently.
|
|
135
|
+
|
|
136
|
+
Neither portal knows about the other. There's no registry of overrides, no configuration DSL for precedence, no merge semantics to learn. It's a superclass and a subclass, so the answer to "why is this field showing up here but not there" is readable as a class hierarchy.
|
|
137
|
+
|
|
138
|
+
## Escape hatches, sized to the problem
|
|
139
|
+
|
|
140
|
+
Plutonium's exits are graduated. You climb only as far as the problem requires.
|
|
141
|
+
|
|
142
|
+
**Change an option.** Most customization is a keyword:
|
|
143
|
+
|
|
144
|
+
```ruby
|
|
145
|
+
input :content, as: :markdown
|
|
146
|
+
display :status, as: :badge, colors: {published: :accent, draft: :neutral}
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
**Render it inline.** A display takes a block, or a proc that runs inside a Phlex context, so tag methods and Tailwind classes are available without defining a class:
|
|
150
|
+
|
|
151
|
+
```ruby
|
|
152
|
+
display :priority, as: :phlexi_render, with: ->(value, attrs) do
|
|
153
|
+
span(class: "pu-badge") { value.humanize }
|
|
154
|
+
end
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
**Write a component.** It's Phlex. A field component reads everything off `field` and plugs straight into `as:`:
|
|
158
|
+
|
|
159
|
+
```ruby
|
|
160
|
+
class ChartComponent < Phlexi::Display::Components::Base
|
|
161
|
+
def view_template
|
|
162
|
+
div(**attributes, data: {controller: "chart", chart_value: field.value.to_json})
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# in the definition
|
|
167
|
+
display :revenue, as: ChartComponent
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
A component with its own constructor renders from a block instead:
|
|
171
|
+
|
|
172
|
+
```ruby
|
|
173
|
+
display :card do |field|
|
|
174
|
+
PostCardComponent.new(post: field.object)
|
|
175
|
+
end
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
**Implement a hook.** Once the change is about a page or a request rather than a field, there is usually a named seam waiting. Controllers expose hooks rather than asking you to reopen the CRUD actions:
|
|
179
|
+
|
|
180
|
+
```ruby
|
|
181
|
+
class PostsController < ::ResourceController
|
|
182
|
+
private
|
|
183
|
+
|
|
184
|
+
def redirect_url_after_submit = posts_path
|
|
185
|
+
|
|
186
|
+
def resource_params
|
|
187
|
+
params = super
|
|
188
|
+
params[:tags] = params[:tags].split(",") if params[:tags].is_a?(String)
|
|
189
|
+
params
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Pages expose a matching set for markup: `render_before_content` and `render_after_content`, plus the same pairs around the header, breadcrumbs, toolbar and footer.
|
|
195
|
+
|
|
196
|
+
```ruby
|
|
197
|
+
class PostDefinition < ResourceDefinition
|
|
198
|
+
class ShowPage < ShowPage
|
|
199
|
+
private
|
|
200
|
+
|
|
201
|
+
def page_title = "Post: #{object.title}"
|
|
202
|
+
|
|
203
|
+
def render_after_content
|
|
204
|
+
render RelatedPostsComponent.new(post: object)
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Both families exist for the reason Rails gives you `before_action` instead of asking you to rewrite dispatch. Overriding a whole method means inheriting responsibility for everything else it did: a page's `view_template` also renders breadcrumbs, the header, and the frame wiring that makes turbo navigation work. Implement `render_after_content` and all of that keeps working.
|
|
211
|
+
|
|
212
|
+
**Replace the page.** When no hook sits where you need it, override `view_template` on the nested class and take over the whole body. When Phlex is the wrong tool entirely, a designer's HTML or an existing layout you're keeping, drop an ERB view at the controller path instead. ERB wins over the page class when both exist for the same action:
|
|
213
|
+
|
|
214
|
+
```erb
|
|
215
|
+
<%-# app/views/posts/show.html.erb %>
|
|
216
|
+
<div class="announcement-banner">Special announcement</div>
|
|
217
|
+
<%= render current_definition.show_page_class.new %>
|
|
218
|
+
<%= render partial: "related" %>
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
That middle line is the generated page. Keep it and wrap it, or delete it and write the whole thing yourself.
|
|
222
|
+
|
|
223
|
+
Underneath all five rungs it stays Rails. Your models are plain ActiveRecord. Your controllers inherit from Rails controllers. Your views resolve through Rails view paths. Hotwire works as it does in any Rails app. A Plutonium resource and a hand-written controller can sit in the same app.
|
|
224
|
+
|
|
225
|
+
## What comes in the box
|
|
226
|
+
|
|
227
|
+
- **Resources.** One line of registration generates CRUD routes, nested routes and action routes.
|
|
228
|
+
- **Authentication.** Rodauth integration, or bring your own.
|
|
229
|
+
- **Authorization.** ActionPolicy-backed, with per-field read and write permissions.
|
|
230
|
+
- **Multi-tenancy.** Entity scoping declared per portal, resolved through your associations.
|
|
231
|
+
- **Queries.** Search, filters, scopes and sorting declared on the definition.
|
|
232
|
+
- **Exports.** Streamed CSV on every resource, opt-in through the policy, with columns drawn from the same permitted-attributes list.
|
|
233
|
+
- **Eager loading.** Index pages, kanban boards and exports preload the associations and attachments they are about to render. The field set comes from the policy, so there is no `includes` list to declare or keep in step.
|
|
234
|
+
- **Positioning.** `positioned_on` on the model and `position_on` in the definition put drag handles on index tables, card grids and nested association tables. A drop writes one decimal, so there is no renumbering sweep.
|
|
235
|
+
- **Generators.** Scaffolding for resources, packages, portals, auth and tests, so the conventional files start out conventional.
|
|
236
|
+
- **Testing.** Concerns for CRUD, policy, definition, interaction and portal-access tests.
|
|
237
|
+
- **Forms.** Nested attributes, markdown editors, date pickers and drag-and-drop uploads.
|
|
238
|
+
- **Association inputs.** Server-side typeahead by default, searching through the target resource's own `search` block, with the options filtered by its policy and the values protected by signed global IDs.
|
|
239
|
+
- **Interactions.** A business operation gets a button, a form, a policy gate and a typed outcome instead of a bespoke controller action.
|
|
240
|
+
|
|
241
|
+
Newer, and still experimental:
|
|
242
|
+
|
|
243
|
+
- **Kanban boards.** Drag between columns, WIP limits, opt-in realtime.
|
|
244
|
+
- **Wizards.** Multi-step flows with branching and a built-in review step.
|
|
245
|
+
- **Async interactions.** Persisted, resumable runs for work that outlives a request.
|
|
246
|
+
|
|
247
|
+
## Where it actually is
|
|
248
|
+
|
|
249
|
+
Plutonium is pre-1.0 and MIT licensed. The core (resources, definitions, policies, portals, packages, entity scoping) is stable and running in production. The newer surfaces are marked experimental in the docs, individually, because their DSLs are young enough that I'd rather rename a method in response to real use than freeze a first guess into a compatibility promise. Pin your version if that makes you nervous.
|
|
250
|
+
|
|
251
|
+
None of this does something Rails can't. You can build all of it by hand, and you probably have. The argument is that you shouldn't have to build it a fifth time.
|
|
252
|
+
|
|
253
|
+
Start with the [tutorial](/getting-started/tutorial/), or read [Core Concepts](/getting-started/) to see whether the shape fits how you already think. If it fights you somewhere, I'd like to hear where. That feedback still changes things.
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Plutonium async runs: a queued job is not a permission snapshot"
|
|
3
|
+
titleTemplate: "Plutonium Blog"
|
|
4
|
+
date: 2026-09-03
|
|
5
|
+
description: The usual bulk-job pattern decides who is allowed at enqueue time and then trusts that decision minutes later. Plutonium re-derives it, per target, right before doing the work.
|
|
6
|
+
author: Stefan Froelich
|
|
7
|
+
tags: [async, authorization, rails]
|
|
8
|
+
draft: true
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# Plutonium async runs: a queued job is not a permission snapshot
|
|
12
|
+
|
|
13
|
+
<BlogMeta />
|
|
14
|
+
|
|
15
|
+
Here is the bulk operation everyone writes at least once:
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
def archive_all
|
|
19
|
+
ids = params[:ids]
|
|
20
|
+
authorize_all!(ids)
|
|
21
|
+
ArchivePostsJob.perform_later(current_user.id, ids)
|
|
22
|
+
redirect_to posts_path, notice: "Archiving in the background"
|
|
23
|
+
end
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
It is a reasonable piece of code and it contains a bug that is easy to miss, because the bug is not in any line. It is in the gap between two of them.
|
|
27
|
+
|
|
28
|
+
Authority is decided in the controller, at enqueue time. The work happens somewhere else, minutes or hours later. In between, the queue is holding a list of record IDs and a user ID, and nothing in that payload is a permission. It is a claim that a permission existed once.
|
|
29
|
+
|
|
30
|
+
## What can change in the gap
|
|
31
|
+
|
|
32
|
+
Quite a lot, and none of it is exotic:
|
|
33
|
+
|
|
34
|
+
- The initiator loses access. They are removed from the team, their role is downgraded, their account is suspended. The job proceeds anyway, applying an authority that was revoked before it ran.
|
|
35
|
+
- A record moves to another tenant, or gets reparented, or is deleted. The job loads it by ID and acts on it, because an ID does not carry a tenant.
|
|
36
|
+
- Someone renames or re-namespaces the model class between deploy and drain. The job deserializes into a class whose policy the initiator was never subject to.
|
|
37
|
+
- The initiator, the tenant, or the parent record is deleted outright. Now the job is holding a foreign key to nothing.
|
|
38
|
+
|
|
39
|
+
Each of these is individually unlikely on any given day. Collectively, across a queue that runs every day for years, they are certainties. And every one of them fails in the same direction: the job does the work.
|
|
40
|
+
|
|
41
|
+
## What Plutonium persists instead
|
|
42
|
+
|
|
43
|
+
An `async` interaction does not serialize a decision. It persists a run, and the run records the *inputs to* a decision: who initiated it, which tenant they were in, which parent record scoped the dispatch if it was nested, and the validated attributes.
|
|
44
|
+
|
|
45
|
+
When the job picks it up, `Async::Context` rebuilds the authorization triple from that row and re-asks every question from scratch. It trusts nothing the dispatching request already concluded.
|
|
46
|
+
|
|
47
|
+
There are four separate checks, and the interesting part is that they are separate.
|
|
48
|
+
|
|
49
|
+
**The scope check** re-runs the same `associated_with` filtering the index would use. A target that left the tenant, left the parent, or was deleted between dispatch and perform is reported as `missing` or `unauthorized`. It is not silently skipped, which matters: a run that quietly processes 47 of 50 records and reports success is worse than one that tells you which three it refused.
|
|
50
|
+
|
|
51
|
+
**The predicate check** re-asks the policy the same question dispatch asked, per target, immediately before each `perform_on`. Not once up front for the batch. That distinction is the whole feature: an initiator whose permission is revoked halfway through a run stops applying to the remaining targets, mid-run.
|
|
52
|
+
|
|
53
|
+
**The policy mismatch check** refuses to run at all if the class has been renamed, reparented, or re-namespaced since dispatch. The alternative is authorizing under a policy the initiator was never subject to, which is the kind of thing that is invisible until it is an incident.
|
|
54
|
+
|
|
55
|
+
**The deleted subject check** refuses the run if the initiator, tenant, or parent is gone. This one has a subtlety worth spelling out, because it is where a naive implementation quietly breaks. When the association nils out, `nil` reads as "there was never one here": no tenant, or not a nested dispatch. Both of those interpretations *drop a filter* rather than narrowing one. So a deleted tenant would widen the run's reach rather than stopping it. The `*_type` column is what distinguishes "this run carries no tenant" from "this run carries a tenant that is gone."
|
|
56
|
+
|
|
57
|
+
## Failing closed
|
|
58
|
+
|
|
59
|
+
The rule underneath all four is that resolution failure always fails **closed**. Refuse, or report missing. Never "assume permitted."
|
|
60
|
+
|
|
61
|
+
That sounds obvious written down. It is not what most hand-rolled jobs do, because most hand-rolled jobs do not re-resolve anything at all, and code that never asks a question cannot fail its answer closed. The default behaviour of `find(id)` plus a serialized `user_id` is to fail open, silently, in exactly the cases you would most want to know about.
|
|
62
|
+
|
|
63
|
+
## What you write
|
|
64
|
+
|
|
65
|
+
None of the above appears in your code. The interaction declares the work:
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
class Blogging::ArchivePosts < ResourceInteraction
|
|
69
|
+
presents label: "Archive", icon: Phlex::TablerIcons::Archive
|
|
70
|
+
|
|
71
|
+
attribute :resources # bulk, so perform_on runs once per record
|
|
72
|
+
attribute :reason, :string
|
|
73
|
+
|
|
74
|
+
async do
|
|
75
|
+
on_failure :continue # :halt (default) | :continue | :transactional
|
|
76
|
+
|
|
77
|
+
def perform_on(post)
|
|
78
|
+
post.archive!(reason: options["reason"])
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`async` replaces `execute`. Everything in front of it is unchanged: same form, same validations, same policy gate. Only what happens on submit changes. Instead of doing the work in the request, it persists a run, enqueues it, and redirects to a progress page you did not write.
|
|
85
|
+
|
|
86
|
+
The block is a class body rather than a closure, and that is deliberate for the same reason the row stores an initiator instead of a serialized user object. The work happens in a process with no controller, no request, and no `view_context` to capture. `def` opens a fresh scope, so those method bodies cannot accidentally close over the interaction's locals, which is the one thing that would make writing them there misleading.
|
|
87
|
+
|
|
88
|
+
## The part that is genuinely hard
|
|
89
|
+
|
|
90
|
+
Re-deriving permissions is the easy half. The hard half is that a worker crash mid-batch leaves a run marked `running` forever, and nothing revisits it on its own.
|
|
91
|
+
|
|
92
|
+
Plutonium ships `Async::ReapJob` for that: it finds runs with no recorded activity past `config.async_interactions.stall_after`, resets them to `pending`, and re-enqueues. The install generator schedules it automatically when Solid Queue is in the bundle. On any other scheduler **you have to schedule it yourself**, and if you do not, a crash mid-batch is a row that says `running` until someone goes looking.
|
|
93
|
+
|
|
94
|
+
That is worth stating plainly rather than burying, because it is the failure mode the feature cannot solve for you.
|
|
95
|
+
|
|
96
|
+
## Why bother
|
|
97
|
+
|
|
98
|
+
Because the alternative is a permission model where the answer to "can this user do this?" is computed once, in a controller, and then trusted by a different process at an unbounded later time. That is a snapshot, and a snapshot of an authorization decision is not an authorization decision. It is a cached one, with no invalidation.
|
|
99
|
+
|
|
100
|
+
Async interactions are marked experimental, so the DSL may still move. The property described here is not the part I expect to change.
|