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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0fb2dd2383544cabcb430a718bf139fa9f374d8d5156f15caadc47978395b112
|
|
4
|
+
data.tar.gz: 2927cca706c47e59b1f3c7f69114351430060536dc4db545221043566d80eb35
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f1f4caab482e175f4168c14c4b3930c9434e309a6fc880c4756babc2759081cf61aa95305af5502fb2f4db3b37e63d9fb4ddd1bfa42c7cc0b31d811427bf57c6
|
|
7
|
+
data.tar.gz: 9dbb8084dd3cccb38b3426d7990f016a9f6765685e4f7e332ec108ebb3f72680125c5d64abbcd4bc2a7e857ded64fad8ddd0644f5ea25b37e997b55f5c5af52b
|
|
@@ -20,6 +20,48 @@ Entry point for all Plutonium work. Does three things:
|
|
|
20
20
|
- **Unattended execution:** always pass `--dest=`, `--force` (when re-running meta-generators), `--auth=`, `--skip-bundle`, `--quiet` so generators don't block on prompts. See [Unattended execution](#unattended-execution).
|
|
21
21
|
- **Inspect before you act.** Every targeted skill now opens with a CHECK gate — read the relevant files yourself before scaffolding or editing. Don't ask the user to describe their app when you can read it.
|
|
22
22
|
|
|
23
|
+
## The mental model (read once — it decides what you should write)
|
|
24
|
+
|
|
25
|
+
Plutonium applies Rails' bargain — follow the convention and the framework carries you; reach for an escape hatch when you need one — to the layer **above CRUD**: auth, authorization, multi-tenancy, admin UI, business operations. Four consequences change what you should actually type.
|
|
26
|
+
|
|
27
|
+
### 1. Everything is derived from something you already declared
|
|
28
|
+
|
|
29
|
+
Not "defaults someone picked for you" — **computed from existing declarations**:
|
|
30
|
+
|
|
31
|
+
| Derived | From |
|
|
32
|
+
|---|---|
|
|
33
|
+
| Field types, required markers, select choices | model columns, associations, attachments, enums, **and validations** (`presence: true` → required; `inclusion:` → select choices) |
|
|
34
|
+
| A collection's preloads (index, kanban, export) | the policy's permitted field set — there is **no `includes` list to write or maintain** |
|
|
35
|
+
| Tenant scope | your associations — direct `belongs_to`, then `has_one`/`has_one :through`, then reverse `has_many` |
|
|
36
|
+
| Action type (record / bulk / resource) | whether the interaction declares `:resource`, `:resources`, or neither |
|
|
37
|
+
| An association input's typeahead | the **target resource's own `search` block** |
|
|
38
|
+
| CRUD, nested and action routes | one `register_resource` line |
|
|
39
|
+
|
|
40
|
+
⇒ **Declare only what differs.** A `field :title` matching the detected type is dead code — and one more line to fall out of step when the column changes. This is the single most common way generated-looking code goes wrong.
|
|
41
|
+
|
|
42
|
+
### 2. Definition and policy answer different questions
|
|
43
|
+
|
|
44
|
+
- **Definition** = *how* a field renders.
|
|
45
|
+
- **Policy** = *whether it appears at all*.
|
|
46
|
+
|
|
47
|
+
"Only admins see this field" is `permitted_attributes_for_*`. Never a definition declaration, and never a `condition:` (that only hides UI — the route stays live).
|
|
48
|
+
|
|
49
|
+
### 3. Overrides are plain Ruby inheritance
|
|
50
|
+
|
|
51
|
+
`AdminPortal::PostDefinition < ::PostDefinition`, and the same for policies and controllers. App-level default, portal-level subclass. No registry of overrides, no precedence DSL, no merge semantics — so "why does this field show here but not there" is always readable as a class hierarchy.
|
|
52
|
+
|
|
53
|
+
### 4. Climb the escape-hatch ladder only as far as the problem requires
|
|
54
|
+
|
|
55
|
+
1. **Change an option** — `input :content, as: :markdown`
|
|
56
|
+
2. **Render inline** — `display :priority, as: :phlexi_render, with: ->(value, attrs) do … end`
|
|
57
|
+
3. **Write a component** — a *field* component (subclasses the Phlexi base) plugs into `as:`; anything with its own constructor goes through a block (`display :card do |field| … end`)
|
|
58
|
+
4. **Implement a hook** — controller hooks instead of reopening `create`/`update`; page `render_before_*` / `render_after_*` instead of `view_template`
|
|
59
|
+
5. **Replace the page** — `view_template` on the nested class, or an ERB view at the controller path (ERB wins when both exist)
|
|
60
|
+
|
|
61
|
+
Reaching for rung 5 on a rung-1 problem is how you end up owning breadcrumbs, the header and turbo frame wiring you never meant to touch.
|
|
62
|
+
|
|
63
|
+
**Underneath all of it, it stays Rails.** Models are plain ActiveRecord, controllers inherit from Rails controllers, views resolve through Rails view paths. A Plutonium resource and a hand-written controller coexist in one app.
|
|
64
|
+
|
|
23
65
|
## ✅ Orient before you route (CHECK — read the app, don't assume)
|
|
24
66
|
|
|
25
67
|
A one-line request rarely says whether this is a new app, a half-built one, or a multi-tenant one — and those change which path you take. Spend 30 seconds reading the app **before** loading a bundle or running anything:
|
|
@@ -40,6 +82,7 @@ This is the global "look before you leap"; each targeted skill carries its own A
|
|
|
40
82
|
| **[[plutonium-app]]** | Installation, packages (feature + portal), portal engines, mounting, `register_resource` (including singular and custom routes), `pu:res:conn` |
|
|
41
83
|
| **[[plutonium-resource]]** | The resource itself — `pu:res:scaffold`, field types, model layer (`Plutonium::Resource::Record`, `has_cents`, SGID, routing), definition layer (fields/inputs/displays/columns, search/filters/scopes/sorting, custom actions, bulk actions, index views, page customization) |
|
|
42
84
|
| **[[plutonium-behavior]]** | Controllers (hooks, key methods, presentation), policies (action methods, `permitted_attributes_for_*`, `permitted_associations`), interactions (structure, outcomes, chaining, URL generation) |
|
|
85
|
+
| **[[plutonium-async-interactions]]** | Async interactions — `async`, the Run STI model, failure policies (`halt`/`continue`/`transactional`), authorization re-derivation at perform time, registering AsyncRun as a resource (progress page + running banner), scheduling `ReapJob` |
|
|
43
86
|
| **[[plutonium-ui]]** | Page classes, forms, displays, tables, custom Phlex components, layouts, modals & tabs, Tailwind config, Stimulus, design tokens, `.pu-*` classes, Phlexi themes |
|
|
44
87
|
| **[[plutonium-kanban]]** | `kanban do…end` DSL in a Definition — columns, `card_fields`, `position_on`, `realtime`, column actions, `kanban_move?` policy, quick-add, static vs dynamic boards |
|
|
45
88
|
| **[[plutonium-auth]]** | Rodauth install, account types (basic / admin / SaaS), profile resource, security section |
|
|
@@ -74,6 +117,7 @@ Add when relevant:
|
|
|
74
117
|
| Override a controller action, hook, redirect, or `resource_params` | **[[plutonium-behavior]]** |
|
|
75
118
|
| Write `relation_scope`, `permitted_attributes_for_*`, `permitted_associations`, action methods, or any policy override | **[[plutonium-behavior]]** (+ **[[plutonium-tenancy]]** if scoping) |
|
|
76
119
|
| Write an interaction class for business logic | **[[plutonium-behavior]]** |
|
|
120
|
+
| Make a bulk/long-running interaction async (`async`), or schedule the stalled-run reaper | **[[plutonium-async-interactions]]** |
|
|
77
121
|
| Scope a model to a tenant, write `associated_with`, set portal entity strategy | **[[plutonium-tenancy]]** |
|
|
78
122
|
| Configure parent/child nested routes, custom parent resolution | **[[plutonium-tenancy]]** |
|
|
79
123
|
| Set up user invitations or entity membership | **[[plutonium-tenancy]]** |
|
|
@@ -402,13 +402,13 @@ end
|
|
|
402
402
|
## Per-portal overrides
|
|
403
403
|
|
|
404
404
|
```ruby
|
|
405
|
-
# Definition
|
|
405
|
+
# Definition — how fields render (NOT whether they appear)
|
|
406
406
|
class AdminPortal::PostDefinition < ::PostDefinition
|
|
407
|
-
input :internal_notes, as: :text # admins see this; customers don't
|
|
408
407
|
scope :pending_review
|
|
408
|
+
input :internal_notes, hint: "Not shown to the author"
|
|
409
409
|
end
|
|
410
410
|
|
|
411
|
-
# Policy
|
|
411
|
+
# Policy — whether a field appears at all
|
|
412
412
|
class AdminPortal::PostPolicy < ::PostPolicy
|
|
413
413
|
include AdminPortal::ResourcePolicy
|
|
414
414
|
def destroy? = true
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: plutonium-async-interactions
|
|
3
|
+
description: Use BEFORE building any bulk operation or long-running interaction. Covers async, the Run STI model, failure policies (halt/continue/transactional), authorization re-derivation at perform time, registering AsyncRun as a resource (progress page + running banner), and scheduling ReapJob for stalled runs. The single source for "how do I make an interaction async".
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Plutonium Async Interactions
|
|
7
|
+
|
|
8
|
+
`async` turns an interaction from "does the work inline" into "persists a run, enqueues it, and redirects to it." Reach for it once the work is too slow to hold a request open — thousands of records, report generation, a third-party call.
|
|
9
|
+
|
|
10
|
+
For everything about the interaction itself (inputs, validation, outcomes, `execute`), load [[plutonium-behavior]] first. `async` only replaces what `execute` does, not the rest of the interaction's shape.
|
|
11
|
+
|
|
12
|
+
## 🚨 Critical (read first)
|
|
13
|
+
|
|
14
|
+
- **Experimental.** The DSL and behavior may change in a future release — same status as [[plutonium-wizard]] and [[plutonium-kanban]]. Fine to build on; expect to revisit it on upgrade.
|
|
15
|
+
- **Enable the subsystem first.** `rails g pu:async_interactions:install --dest=<portal>` flips `config.async_interactions.enabled = true`, schedules `ReapJob`, and connects the run resource to that portal; then `rails db:migrate`. Pass `--skip-portal` to enable it before any portal exists. Off by default, so no `plutonium_async_runs` table otherwise.
|
|
16
|
+
- **`async` fully replaces `#execute`.** An interaction either executes inline or runs async — declaring both raises `ArgumentError` at load.
|
|
17
|
+
- **Define `perform_on(record)` for targeted work, `perform` for opaque work.** A run class implementing neither fails loudly (naming the class) the first time it's performed, rather than a bare `NoMethodError`.
|
|
18
|
+
- **A nested dispatch records its parent.** `parent_type`/`parent_id`/`parent_association` join initiator and tenant on the row, because `Policy#default_relation_scope` picks parent scoping **or** entity scoping, not both — a nested run missing its parent re-derives targets under the wider tenant scope, and any predicate reading `parent` silently answers false. A parent deleted mid-run refuses the run, exactly like a deleted tenant.
|
|
19
|
+
- **Permissions are re-derived at perform time, never replayed from dispatch.** The job rebuilds `(initiator, tenant)` from the row and re-checks the policy scope and predicate per target, immediately before each `perform_on`. A permission revoked mid-run stops applying to what's left. Both failure directions (scope, predicate) fail closed (refuse/report), never open.
|
|
20
|
+
- **Register `Run` as a resource per portal.** Its show page IS the progress page, and other resources' index pages get a "runs in progress" banner for free. Nothing renders without registration.
|
|
21
|
+
- **Read `outcome`, never bare `state`, when displaying a run's result.** A `:continue` run that under-applied still has `state == "completed"`; only `outcome` says `"completed_with_errors"`.
|
|
22
|
+
- **Long work must call `heartbeat!`.** `stall_after` measures SILENCE, and the executor only writes per target — so opaque `perform` writes nothing at all between claim and finish. An opaque run longer than `stall_after` is reaped and, having no `handled_target_ids`, re-run from scratch. Call `heartbeat!` inside the loop. It also raises `StaleObjectError` if another worker took the run over, which for opaque work is the only way to find out.
|
|
23
|
+
- **A crashed/stalled run does not auto-heal.** Nothing revisits a `"running"` row on its own. `pu:async_interactions:install` schedules `Plutonium::Interaction::Async::ReapJob` for you when Solid Queue is in the bundle; otherwise (or on another scheduler) you must schedule it yourself. Unscheduled, a crash mid-batch leaves that row stuck forever.
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## ✅ Before you build: verify the ground truth (CHECK, don't ask for it)
|
|
28
|
+
|
|
29
|
+
| Check | How | Why it matters |
|
|
30
|
+
|---|---|---|
|
|
31
|
+
| Subsystem enabled | grep `config/initializers/plutonium.rb` for `async_runs.enabled` | Not enabled means no table; `async` raises `Dispatchable::NotEnabledError` the moment it dispatches, naming the flag |
|
|
32
|
+
| Run registered in the target portal | grep the portal's `config/routes.rb` for `register_resource ::Plutonium::Interaction::Async::Run` | Unregistered means dispatch redirects to a 404; the running banner silently skips that portal (by design, see below) |
|
|
33
|
+
| Existing run classes for the pattern | `ls app/runs/` or grep `< Plutonium::Interaction::Async::Run` | Match the host's existing `on_failure` conventions rather than guessing |
|
|
34
|
+
| ReapJob scheduled | grep `config/recurring.yml` / `config/schedule.rb` for `ReapJob` | An `on_submit`-shaped bulk workflow with no reaper leaves crashed runs stuck |
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## Declaring the work
|
|
39
|
+
|
|
40
|
+
`async` with a block. One file — the run is declared inline and needs no name:
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
class Blogging::ArchivePosts < ResourceInteraction
|
|
44
|
+
presents label: "Archive", icon: Phlex::TablerIcons::Archive
|
|
45
|
+
attribute :resources # bulk: perform_on runs once per record
|
|
46
|
+
attribute :reason, :string
|
|
47
|
+
|
|
48
|
+
async do
|
|
49
|
+
on_failure :continue # :halt (default) | :continue | :transactional
|
|
50
|
+
def perform_on(post) # targeted: called once per resolved target
|
|
51
|
+
post.archive!(reason: options["reason"])
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
class Reports::GenerateMonthly < ResourceInteraction
|
|
57
|
+
attribute :period, :string
|
|
58
|
+
|
|
59
|
+
async do
|
|
60
|
+
def perform # opaque: no target, called once
|
|
61
|
+
Reports::Monthly.generate!(options["period"])
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
**The block is the run's class body, not the body of `#execute`.** The work happens later, in a process with no controller and no `view_context`, so it cannot close over anything in the interaction — which is why it declares `perform_on`/`perform` rather than executing directly. Validated attributes arrive through `options`, and `def` opens a fresh scope, so those bodies can't accidentally capture the interaction's locals.
|
|
68
|
+
|
|
69
|
+
The block defines `<Interaction>::Run` — a real, named constant, because the class name is persisted in `type` and constantized in the job process.
|
|
70
|
+
|
|
71
|
+
**Pass a class instead** to share one run across several interactions that do the same kind of work:
|
|
72
|
+
|
|
73
|
+
```ruby
|
|
74
|
+
async Blogging::ArchivePostsRun
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
| `on_failure` | One target raises |
|
|
78
|
+
|---|---|
|
|
79
|
+
| `:halt` (default) | Stop immediately; run ends `"failed"`; remaining targets never attempted |
|
|
80
|
+
| `:continue` | Record the failure (`errors_log`), keep going; run ends `"completed"`. Check `outcome`, not `state`, to see it under-applied |
|
|
81
|
+
| `:transactional` | Whole batch in one DB transaction; any failure rolls back everything, including targets already applied |
|
|
82
|
+
|
|
83
|
+
`attribute :resource` (singular) means a one-target run; `attribute :resources` (plural) means bulk; neither means opaque. Same inference rule ordinary interactive actions use, see [[plutonium-resource]] › Actions.
|
|
84
|
+
|
|
85
|
+
### What's recorded, and why
|
|
86
|
+
|
|
87
|
+
Nothing is passed explicitly. Dispatch reads it off the interaction/controller it's already running in:
|
|
88
|
+
|
|
89
|
+
- **Targets** as ids (`target_ids`), re-resolved through the policy scope at perform time. Never serialized records, which would be stale and unauthorized the moment anything changed.
|
|
90
|
+
- **Initiator + tenant** (`current_user` / `current_scoped_entity`), the pair every Plutonium policy authorizes on.
|
|
91
|
+
- **`policy_class_name`**, the policy dispatch actually resolved, not an inferred `"#{Model}Policy"` (wrong under a namespaced portal or an STI target).
|
|
92
|
+
- **`policy_action`**, the predicate dispatch checked (e.g. `"archive?"`), re-asked per target at perform time.
|
|
93
|
+
- **`authorization_namespace`**, the portal's module name, so perform-time lookup finds the same policy dispatch did.
|
|
94
|
+
|
|
95
|
+
An opaque run records none of the target/policy columns. Nothing to re-verify without a subject.
|
|
96
|
+
|
|
97
|
+
## Attributes and files
|
|
98
|
+
|
|
99
|
+
Validated attributes reach the run through `options`, a JSON column written via `ActiveJob::Arguments` — primitives verbatim, `Date`/`BigDecimal`/`Time` round-tripped with their types. An attribute that can't be carried is refused at dispatch.
|
|
100
|
+
|
|
101
|
+
Files can't ride a JSON column, and the request's tempfile is gone by the time the job runs, so an uploaded file is staged to its backend's cache and carried as a token. Read it back with `attachment`:
|
|
102
|
+
|
|
103
|
+
```ruby
|
|
104
|
+
attribute :import_file
|
|
105
|
+
|
|
106
|
+
async do
|
|
107
|
+
def perform
|
|
108
|
+
attachment(:import_file).open { |f| CSV.foreach(f, headers: true) { |row| ... } }
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
`attachment(:key)` / `attachments(:key)` give `filename`, `content_type`, `url`, `open`, `download`.
|
|
114
|
+
|
|
115
|
+
`backend:` and `uploader:` come off the attribute's `input` declaration, exactly as in a wizard step — `input :import_file, as: :uppy, uploader: Catalog::ImportUploader`. The uploader's `Attacher.validate` rules run when the interaction validates, so a bad file **fails the form** rather than surfacing as a run failure the submitter never sees. Where no `backend:` is declared: `config.async_interactions.attachment_backend` → `config.attachment_backend` → auto-detect.
|
|
116
|
+
|
|
117
|
+
## Registering the Run resource
|
|
118
|
+
|
|
119
|
+
Use the generator, per portal. Never hand-write the route/controller:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
rails g pu:async_interactions:install --dest=admin_portal
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
```ruby
|
|
126
|
+
# packages/admin_portal/config/routes.rb
|
|
127
|
+
register_resource ::Plutonium::Interaction::Async::Run
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
```ruby
|
|
131
|
+
# packages/admin_portal/app/controllers/admin_portal/async_runs_controller.rb
|
|
132
|
+
class AdminPortal::AsyncRunsController < AdminPortal::ResourceController
|
|
133
|
+
controller_for ::Plutonium::Interaction::Async::Run
|
|
134
|
+
|
|
135
|
+
include AdminPortal::Concerns::Controller
|
|
136
|
+
end
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
`controller_for` is required — the controller's name doesn't match `Run`'s real, namespaced class, so inference can't find it on its own. No policy/definition files are generated: `Plutonium::Interaction::Async::RunPolicy`/`Async::RunDefinition` already resolve automatically (exact class-name match for the definition, ActionPolicy's own lookup for the policy).
|
|
140
|
+
|
|
141
|
+
If Solid Queue is in the bundle, this also schedules `Async::ReapJob` in `config/recurring.yml` — see [Scheduling ReapJob](#scheduling-reapjob-stalled-runs). `--schedule` overrides the default `every 15 minutes`. Idempotent, so running it against a second portal doesn't duplicate the entry.
|
|
142
|
+
|
|
143
|
+
Registering gets you, for free:
|
|
144
|
+
|
|
145
|
+
- **Progress page.** The show page IS the progress page, self-polling while `pending`/`running`, and stops carrying the poll once settled (a finished run is a static page, not an eternal background request per viewer).
|
|
146
|
+
- **Self-refreshing index.** The runs index polls on the same terms, one frame around the whole collection, armed only while some run is still working. One request per interval whatever the page size, and it re-fetches the current URL so filters, sort and page survive. No action needed on your part.
|
|
147
|
+
- **Running banner.** Any OTHER registered resource's index lists in-progress runs targeting it, above the collection, scoped through the same `authorized_resource_scope` every cross-resource read uses (a run in another tenant can't surface). If a `resource_class` is registered in a portal that never registered `Run`, the banner is skipped there instead of raising while building a link to a nonexistent route. No action needed on your part.
|
|
148
|
+
- **`target_label`.** The show page/table read `run.target_label` (`model_name.human`, e.g. `"Post"`) rather than the raw `target_type` string (`"Blogging::Post"`).
|
|
149
|
+
- **Tenant scoping via `associated_with`.** Filters on the tenant the run was dispatched in (recorded on the row), because the two tenant columns are polymorphic and the generic object-graph scope can't walk them.
|
|
150
|
+
|
|
151
|
+
## Scheduling ReapJob (stalled runs)
|
|
152
|
+
|
|
153
|
+
A worker crash mid-batch, or a job the queue silently drops, leaves a run `"running"`/`"pending"` forever. Nothing else revisits it. `ReapJob` finds runs with no activity (`last_activity_at`, or `created_at` if never picked up) past `config.async_interactions.stall_after` (default `1.hour`), and resumes them: resets to `"pending"`, re-enqueues.
|
|
154
|
+
|
|
155
|
+
This is safe, not a blind replay. The executor tracks `handled_target_ids` and only re-attempts what's left.
|
|
156
|
+
|
|
157
|
+
Safe *given a heartbeat*: `handled_target_ids` only exists for targeted work, so opaque work resumes by re-running `perform` whole. Long opaque work must call `heartbeat!` (see Critical, above) so it is never judged stalled in the first place.
|
|
158
|
+
|
|
159
|
+
`rails g pu:async_interactions:install` schedules it automatically when Solid Queue is in the bundle:
|
|
160
|
+
|
|
161
|
+
```yaml
|
|
162
|
+
# config/recurring.yml (Solid Queue)
|
|
163
|
+
production:
|
|
164
|
+
reap_stalled_async_runs:
|
|
165
|
+
class: Plutonium::Interaction::Async::ReapJob
|
|
166
|
+
schedule: every 15 minutes
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Without Solid Queue — or for another scheduler like `whenever` — add it yourself:
|
|
170
|
+
|
|
171
|
+
```ruby
|
|
172
|
+
# whenever gem
|
|
173
|
+
every 15.minutes do
|
|
174
|
+
runner "Plutonium::Interaction::Async::ReapJob.perform_later"
|
|
175
|
+
end
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
15 to 30 minutes is a reasonable cadence against the default 1-hour `stall_after`. This is a time heuristic, not a true lease: a merely-slow (not dead) run that crosses `stall_after` gets resumed too. `lock_version` bounds what that costs — the resumed row's version no longer matches the still-live worker's, so that worker stops at its next write instead of racing the new one. It does **not** interrupt an in-flight `perform_on` (one target can be applied twice, once by each side), and it does not roll back what the superseded worker already committed. Set `stall_after` well above the app's slowest legitimate run.
|
|
179
|
+
|
|
180
|
+
On Solid Queue (or any queue providing ActiveJob concurrency controls) this is tightened further, automatically and with no configuration: `Async::Job` declares a semaphore of 1 keyed on the run id, held for `stall_after`, and `ReapJob` one global sweep at a time. A second delivery of the same run then waits rather than racing, so the one target the fence cannot save from a double apply is not applied twice either. Nothing declares it when the queue does not support it.
|
|
181
|
+
|
|
182
|
+
## Full reference
|
|
183
|
+
|
|
184
|
+
`docs/reference/behavior/async-interactions.md` has the complete write-up: authorization re-derivation in detail, outcome-vs-state, everything above with more context.
|
|
185
|
+
|
|
186
|
+
## Related Skills
|
|
187
|
+
|
|
188
|
+
- [[plutonium-behavior]] — the interaction itself: inputs, validation, `succeed`/`failed`, policies.
|
|
189
|
+
- [[plutonium-resource]] — Actions (inferred bulk/record/resource shape), Definition (`field`/`display`/`column`).
|
|
190
|
+
- [[plutonium-tenancy]] — entity scoping, `associated_with`, portal tenant strategies.
|
|
191
|
+
- [[plutonium-wizard]] — the other long-lived, persisted flow primitive (multi-step, not async execution). `Wizard::SweepJob` is `ReapJob`'s sibling for abandoned wizard sessions.
|
|
@@ -222,6 +222,39 @@ For portal wiring (`AdminPortal::Concerns::Controller`), see [[plutonium-app]]
|
|
|
222
222
|
|
|
223
223
|
---
|
|
224
224
|
|
|
225
|
+
## Multiple portals in one browser
|
|
226
|
+
|
|
227
|
+
Each portal authenticates through its own Rodauth configuration, and a person can hold a session in several at once — sign into the admin portal and the customer portal in the same browser without either evicting the other.
|
|
228
|
+
|
|
229
|
+
Two settings make that work, and the generators emit both:
|
|
230
|
+
|
|
231
|
+
```ruby
|
|
232
|
+
# app/rodauth/rodauth_plugin.rb — the shared base
|
|
233
|
+
configure do
|
|
234
|
+
enable :session_isolation
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# app/rodauth/<name>_rodauth_plugin.rb — once per account type
|
|
238
|
+
configure do
|
|
239
|
+
session_key_prefix "admin_"
|
|
240
|
+
remember_cookie_key "_admin_remember"
|
|
241
|
+
end
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
🚨 **Both are required.** `session_key_prefix` namespaces every session key a configuration touches — the account id plus `authenticated_by`, `login_redirect`, `two_factor_auth_setup`, … `session_isolation` uses that prefix to decide ownership and stops a login from clearing the other configurations' keys.
|
|
245
|
+
|
|
246
|
+
🚨 **Never set `session_key` alongside it.** Explicit values bypass `convert_session_key` (`rodauth/features/base.rb:686`) so they are NOT prefixed — the account id then rotates separately from every other key. A session holding an account id with no `authenticated_by` makes Rodauth raise on every request (`logged_in_via_remember_key?` → `nil.include?`, `remember.rb:175`). A config with no prefix at all is simply not isolated: its keys are the unprefixed defaults, so nothing is carried for it.
|
|
247
|
+
|
|
248
|
+
**Why:** Rodauth resets the session on every login — including the `remember` feature's `load_memory` autologin — to defend against session fixation, and rodauth-rails implements that as a full `reset_session`. Without `session_isolation`, signing into one portal wipes every other portal's session; and because `RodauthApp#route` calls `load_memory` for *every* configuration on *every* request, the evicted configuration autologins from its remember cookie on the next request and evicts the new one right back. The last `load_memory` in the route block wins permanently, so the other portal can never hold a session at all.
|
|
249
|
+
|
|
250
|
+
`session_isolation` carries only the *other configurations'* session entries across the reset. The session id is still rotated and application session data is still cleared, so session fixation is still defeated.
|
|
251
|
+
|
|
252
|
+
**Reading a raw Rodauth session key?** Go through its accessor, never the literal — `session.delete(login_redirect_session_key)`, not `session.delete(:login_redirect)`. With a prefix set, the literal is the wrong key.
|
|
253
|
+
|
|
254
|
+
**Upgrading an app generated before this existed:** add `enable :session_isolation` once in the base plugin, add `session_key_prefix` to each account plugin, and **delete any existing `session_key "_x_session"` line**. Every key name changes together, so stale session cookies are simply ignored — the safe outcome. Keeping the old `session_key` to preserve logins is exactly what produces the crashing half-migrated session. Only *unremembered* sessions actually drop: `remember_cookie_key` is a cookie name and is not prefixed, so anyone holding a valid `_x_remember` cookie is restored by `load_memory` on their next request.
|
|
255
|
+
|
|
256
|
+
---
|
|
257
|
+
|
|
225
258
|
## Common customizations
|
|
226
259
|
|
|
227
260
|
All inside the Rodauth `configure do ... end` block in `app/rodauth/<name>_rodauth_plugin.rb`.
|
|
@@ -410,6 +443,9 @@ link_to("Profile", profile_url) if respond_to?(:profile_url)
|
|
|
410
443
|
- **`pu:saas:setup` runs four other generators** — don't re-run portal, profile, welcome, or invites separately.
|
|
411
444
|
- **Profile requires `pu:profile:conn`** — without it, no route, no `profile_url`, no menu link.
|
|
412
445
|
- **Users need a profile row.** Add an `after_create` callback (or `find_or_create_by`) — `current_user.profile` is otherwise nil.
|
|
446
|
+
- **Concurrent portal logins need `enable :session_isolation` + `session_key_prefix`.** Missing either and signing into one portal silently evicts the others — see Multiple portals in one browser.
|
|
447
|
+
- **Never hardcode a Rodauth session key.** Use the accessor (`login_redirect_session_key`), since `session_key_prefix` changes the literal.
|
|
448
|
+
- **"Remember me" is opt-in.** Configs use `after_login { remember_login if param_or_nil(remember_param) == remember_remember_param_value }` and the login form renders the checkbox. Compare against the value, not just presence — a bare truthiness check means `remember=disable` would remember you. Plutonium's form also passes `include_hidden: false` so an unticked box sends nothing — belt-and-braces next to the value comparison, not the thing holding it up.
|
|
413
449
|
|
|
414
450
|
---
|
|
415
451
|
|
|
@@ -17,6 +17,7 @@ For tenant-scoped `relation_scope` and entity scoping, load [[plutonium-tenancy]
|
|
|
17
17
|
- **`permitted_attributes_for_*` must be explicit in production.** Dev auto-detection works; production raises.
|
|
18
18
|
- **`ActiveRecord::RecordInvalid` is NOT rescued automatically in interactions.** Always rescue when using `create!` / `update!` / `save!`, return `failed(e.record.errors)`.
|
|
19
19
|
- **Return `succeed(...)` or `failed(...)`** from `execute` — the controller can't tell what happened otherwise.
|
|
20
|
+
- **An interaction is a presentation object** (it can only be built with a `view_context`). Logic may *start* in `execute`; the **second caller** — a job, an API controller, a rake task, the console — is the trigger to move it onto the **model**. Don't pre-extract, and don't invent a service layer. See Part 3 › Where the logic goes.
|
|
20
21
|
- **Redirect is automatic on success** — only use `with_redirect_response` for a *different* destination.
|
|
21
22
|
- **`relation_scope` must end up calling `default_relation_scope(relation)` somewhere in the chain.** Prefer calling it explicitly. `super` works when extending a parent policy (e.g., a package base) that itself calls it. See [[plutonium-tenancy]].
|
|
22
23
|
- **For `has_cents` fields, use the virtual name (`:price`), not `:price_cents`** in `permitted_attributes_for_*`.
|
|
@@ -32,7 +33,8 @@ For tenant-scoped `relation_scope` and entity scoping, load [[plutonium-tenancy]
|
|
|
32
33
|
| The requirement (in plain words) | Goes in | **NOT** in |
|
|
33
34
|
|---|---|---|
|
|
34
35
|
| "only \<role/owner\> may do X" — *who is allowed* | **Policy** `def x?` | a `condition:` proc — that only hides the button; the route stays live and callable |
|
|
35
|
-
| "
|
|
36
|
+
| "there's a button that does X" — *the trigger* | **Interaction** (+ action in the definition) | a hand-written controller action; an override of `create`/`update` |
|
|
37
|
+
| "doing X changes state / sends mail / charges a card" — *the work* | a named **model** method the interaction calls (`post.publish!`) — inline in `execute` is fine while the button is the only caller | a service-object layer; three chained interactions |
|
|
36
38
|
| "after create/update go to Y" · "munge a param" · "reshape the index query" | **Controller hook** (`redirect_url_after_submit`, `resource_params`, `filtered_resource_collection`) | overriding `create`/`update`/`index` |
|
|
37
39
|
| "which fields are visible / editable" | **Policy** `permitted_attributes_for_*` | the definition — that only controls *how* a field renders |
|
|
38
40
|
|
|
@@ -109,7 +111,8 @@ Plus interactive-action routes for every action declared in the definition.
|
|
|
109
111
|
|---|---|
|
|
110
112
|
| Field rendering (inputs, displays, columns) | Definition |
|
|
111
113
|
| Search, filters, scopes, sorting | Definition |
|
|
112
|
-
| Custom operations (publish, archive, import) | Interaction (+ action in definition) |
|
|
114
|
+
| Custom operations (publish, archive, import) — the *button* | Interaction (+ action in definition) |
|
|
115
|
+
| The operation itself, once a job/API/task also needs it | The **model** (`post.publish!`) — see Part 3 › Where the logic goes |
|
|
113
116
|
| Authorization rules | Policy |
|
|
114
117
|
| Form/show/page chrome | Definition (custom page classes) |
|
|
115
118
|
| **Custom redirect logic** | **Controller hook** |
|
|
@@ -158,6 +161,10 @@ def filtered_resource_collection
|
|
|
158
161
|
end
|
|
159
162
|
```
|
|
160
163
|
|
|
164
|
+
**Don't add eager loading unprompted.** Which associations a page renders is decided by the definition, so an `includes` list written now is a guess that goes stale when a column is added. Adding one is a performance change the user didn't ask for.
|
|
165
|
+
|
|
166
|
+
When a user actually reports a slow index or an N+1: suggest [goldiloader](https://github.com/salsify/goldiloader) first — it eager-loads on traversal, so it tracks whatever the definition renders and needs no list to maintain. Only hand-write `def filtered_resource_collection = super.includes(...)` if they decline the gem, and use the policy's `relation_scope` instead when the association is also read on show/export/typeahead. Full detail: [Guides › Performance](/guides/performance).
|
|
167
|
+
|
|
161
168
|
### Presentation hooks
|
|
162
169
|
|
|
163
170
|
Control whether parent / scoped-entity fields appear in forms and displays. Defaults are `false` (hidden, since they're inferred from the URL/portal).
|
|
@@ -171,13 +178,15 @@ def submit_scoped_entity? = true
|
|
|
171
178
|
|
|
172
179
|
## Custom actions
|
|
173
180
|
|
|
174
|
-
Prefer **interactive actions** (definition + interaction) for anything
|
|
181
|
+
Prefer **interactive actions** (definition + interaction) for anything a user triggers from a page. The only reason to hand-write a controller action is unusual flows (custom response shapes, external service callbacks, etc.).
|
|
182
|
+
|
|
183
|
+
Either way the *operation* is a named model method — the controller and the interaction are two front doors onto the same `post.publish!`.
|
|
175
184
|
|
|
176
185
|
```ruby
|
|
177
186
|
class PostsController < ::ResourceController
|
|
178
187
|
def publish
|
|
179
188
|
authorize_current!(resource_record!, to: :publish?)
|
|
180
|
-
resource_record!.
|
|
189
|
+
resource_record!.publish!
|
|
181
190
|
redirect_to resource_url_for(resource_record!), notice: "Published!"
|
|
182
191
|
end
|
|
183
192
|
end
|
|
@@ -279,10 +288,12 @@ end
|
|
|
279
288
|
| Method | Returns |
|
|
280
289
|
|---|---|
|
|
281
290
|
| `current_parent` | Parent record |
|
|
291
|
+
| `current_parent_class` | `User` |
|
|
282
292
|
| `current_nested_association` | `:posts` |
|
|
283
|
-
| `parent_route_param` | `:user_id` |
|
|
284
293
|
| `parent_input_param` | `:user` |
|
|
285
294
|
|
|
295
|
+
The parent class and association come from the **route** (each nested route carries its registration key), not from parsing the URL. There is no `parent_route_param`.
|
|
296
|
+
|
|
286
297
|
Parent fields are excluded from forms/displays by default — toggle with the presentation hooks above. For `has_one` associations, routes are singular (no `:id`); index redirects to show (or new if no record exists). See [[plutonium-tenancy]] for the full nested-routing story.
|
|
287
298
|
|
|
288
299
|
## Entity scoping (multi-tenancy)
|
|
@@ -423,6 +434,8 @@ end
|
|
|
423
434
|
| `search?` | `index?` | Search-specific rules |
|
|
424
435
|
| `typeahead?` | `index?` | Autocomplete-specific rules |
|
|
425
436
|
|
|
437
|
+
🚨 **`record` is the resource CLASS on collection routes** (`current_policy_subject = resource_record? || resource_class`). `read?` backs both `show?` (instance) and `index?` (class); `create?`/`new?`, `export_csv?`, `search?`, and resource-action gates (incl. kanban column actions) are class-backed too. `def read? = record.published?` raises `NoMethodError` on index — filter the list in `relation_scope`, gate individual records in `show?`. Record actions (`publish?` etc.) and bulk actions are always evaluated per record instance — no type guard needed.
|
|
438
|
+
|
|
426
439
|
`export_csv?` is the exception — it defaults to `false` (not derived) so CSV export is strictly opt-in. Override it to `true` (or `index?`) to enable the built-in export. The exported column set is `permitted_attributes_for_export` (defaults to `permitted_attributes_for_index`). See [[plutonium-resource]] → CSV Export.
|
|
427
440
|
|
|
428
441
|
### Custom actions
|
|
@@ -656,7 +669,41 @@ end
|
|
|
656
669
|
|
|
657
670
|
# Part 3 — Interactions
|
|
658
671
|
|
|
659
|
-
|
|
672
|
+
An interaction is the entry point from a Plutonium page into an operation: it declares the inputs, renders as a button and a form, is gated by a policy, and returns an outcome the controller turns into a flash + redirect. Registered as actions in definitions (see [[plutonium-resource]] › Actions) and executed by the controller.
|
|
673
|
+
|
|
674
|
+
For a bulk operation, or anything too slow to hold a request open, `async` declares a persisted, resumable run instead of executing inline. See [[plutonium-async-interactions]].
|
|
675
|
+
|
|
676
|
+
## Where the logic goes
|
|
677
|
+
|
|
678
|
+
An interaction is a **presentation object**. Its job is the button, the form, input-shape validation, and the user-facing outcome. It is not the home of a domain operation, because of one line in the base class:
|
|
679
|
+
|
|
680
|
+
```ruby
|
|
681
|
+
def initialize(view_context:, **attributes)
|
|
682
|
+
```
|
|
683
|
+
|
|
684
|
+
`view_context:` is required. So a caller that isn't a Plutonium page has two options: duplicate the logic, or manufacture a `view_context` it has no business owning. **`view_context` is the tell.**
|
|
685
|
+
|
|
686
|
+
| | |
|
|
687
|
+
|---|---|
|
|
688
|
+
| **Logic may start in `execute`** | A one-off with a single caller is fine inline. Don't pre-extract — Plutonium ships no service layer to put it in, and YAGNI. |
|
|
689
|
+
| **The trigger to extract is the second caller** | A background job, an API controller, a rake task, the console, another interaction. |
|
|
690
|
+
| **The destination is the model** | Fat models, per Rails convention. Name it in domain language — `publish!`, `archive!`, `register!` — not persistence (`update_published_at`). Never a `PublishPostService`. |
|
|
691
|
+
|
|
692
|
+
```ruby
|
|
693
|
+
# 🚫 Three interactions, three view_contexts. A signup API or a seeds script
|
|
694
|
+
# can supply none of them — the email and the audit row are stranded.
|
|
695
|
+
CreateUserInteraction.call(view_context:, **user_params)
|
|
696
|
+
.and_then { |user| SendWelcomeEmail.call(view_context:, user:) }
|
|
697
|
+
.and_then { |user| LogActivity.call(view_context:, user:) }
|
|
698
|
+
|
|
699
|
+
# ✅ One model method; the interaction presents it
|
|
700
|
+
def execute
|
|
701
|
+
user = User.register!(**attributes) # welcome email + audit row live in here
|
|
702
|
+
succeed(user).with_message("Welcome aboard!")
|
|
703
|
+
end
|
|
704
|
+
```
|
|
705
|
+
|
|
706
|
+
Chaining three interactions is usually one model method wearing three presentation costumes.
|
|
660
707
|
|
|
661
708
|
## Structure
|
|
662
709
|
|
|
@@ -665,7 +712,14 @@ Interactions encapsulate business logic into testable units. They're registered
|
|
|
665
712
|
class ResourceInteraction < Plutonium::Resource::Interaction
|
|
666
713
|
end
|
|
667
714
|
|
|
668
|
-
#
|
|
715
|
+
# app/models/post.rb — what publishing MEANS (a scheduler job can call this too)
|
|
716
|
+
class Post < ApplicationRecord
|
|
717
|
+
def publish!(on: Time.current)
|
|
718
|
+
update!(published: true, published_at: on)
|
|
719
|
+
end
|
|
720
|
+
end
|
|
721
|
+
|
|
722
|
+
# A real interaction — the button in front of it
|
|
669
723
|
class PublishPostInteraction < ResourceInteraction
|
|
670
724
|
presents label: "Publish",
|
|
671
725
|
icon: Phlex::TablerIcons::Send,
|
|
@@ -681,7 +735,7 @@ class PublishPostInteraction < ResourceInteraction
|
|
|
681
735
|
private
|
|
682
736
|
|
|
683
737
|
def execute
|
|
684
|
-
resource.
|
|
738
|
+
resource.publish!(on: publish_date)
|
|
685
739
|
succeed(resource).with_message("Post published!")
|
|
686
740
|
rescue ActiveRecord::RecordInvalid => e
|
|
687
741
|
failed(e.record.errors)
|
|
@@ -778,22 +832,32 @@ failed(email: "is invalid", name: "is required") # hash form
|
|
|
778
832
|
failed("Invalid value", :email) # string + attribute
|
|
779
833
|
```
|
|
780
834
|
|
|
781
|
-
### Chaining
|
|
835
|
+
### Chaining — `and_then`
|
|
836
|
+
|
|
837
|
+
On a `Success`, `and_then` yields **the value** (NOT the outcome — there is no `r.value` in the block) and returns whatever the block returns; on a `Failure` it short-circuits, returning the failure untouched.
|
|
838
|
+
|
|
839
|
+
Use it to compose outcomes **inside one `execute`**, e.g. a guard:
|
|
782
840
|
|
|
783
841
|
```ruby
|
|
784
842
|
def execute
|
|
785
|
-
|
|
786
|
-
.
|
|
787
|
-
.
|
|
788
|
-
|
|
843
|
+
unlocked_resource.and_then do |post|
|
|
844
|
+
post.publish!(on: publish_date)
|
|
845
|
+
succeed(post).with_message("Post published!")
|
|
846
|
+
end
|
|
847
|
+
end
|
|
848
|
+
|
|
849
|
+
private
|
|
850
|
+
|
|
851
|
+
def unlocked_resource
|
|
852
|
+
resource.locked? ? failed("This post is locked") : succeed(resource)
|
|
789
853
|
end
|
|
790
854
|
```
|
|
791
855
|
|
|
792
|
-
|
|
856
|
+
⚠️ **Don't chain interactions to sequence business operations** — see Where the logic goes above.
|
|
793
857
|
|
|
794
858
|
## Validations
|
|
795
859
|
|
|
796
|
-
Standard ActiveModel — run automatically before `execute
|
|
860
|
+
Standard ActiveModel — run automatically before `execute`; if they fail, `execute` never runs:
|
|
797
861
|
|
|
798
862
|
```ruby
|
|
799
863
|
validates :email, presence: true, format: {with: URI::MailTo::EMAIL_REGEXP}
|
|
@@ -808,6 +872,21 @@ def custom_check
|
|
|
808
872
|
end
|
|
809
873
|
```
|
|
810
874
|
|
|
875
|
+
### Which validation goes where
|
|
876
|
+
|
|
877
|
+
| | Interaction validation | Model validation |
|
|
878
|
+
|---|---|---|
|
|
879
|
+
| Asks | "Can I read this input?" — present, parses, right type, plausible format | "Is this record legal?" — invariants that hold no matter who calls |
|
|
880
|
+
| Exists to | render a form error next to the field | protect the data from every caller, including ones with no form |
|
|
881
|
+
| Runs | before `execute`, never touching the model | inside `save!`/`update!` — i.e. inside the model method |
|
|
882
|
+
|
|
883
|
+
They surface **differently**, and that should inform where a rule lives:
|
|
884
|
+
|
|
885
|
+
- An interaction validation attaches to a declared attribute → the re-rendered modal shows it inline against that input **and** in the summary.
|
|
886
|
+
- `failed(record.errors)` flattens `ActiveModel::Errors` to **full messages on `:base`** (`Array(errors)` → `errors.to_a` → `full_messages`) → error summary only, never against a field, phrased with the *model's* attribute names.
|
|
887
|
+
|
|
888
|
+
So it's fine — often right — to **duplicate** a cheap invariant as an interaction validation purely for the better error placement, while the model keeps the authoritative copy. What must not happen is the model-side copy going missing: when a job calls `post.publish!`, the interaction's validations aren't in the picture.
|
|
889
|
+
|
|
811
890
|
## Accessing context
|
|
812
891
|
|
|
813
892
|
```ruby
|
|
@@ -818,6 +897,8 @@ def execute
|
|
|
818
897
|
end
|
|
819
898
|
```
|
|
820
899
|
|
|
900
|
+
This write is **correctly inline**. "Who clicked the button" is context only the presentation layer holds — a job has no answer for it, so there's no second caller to extract for.
|
|
901
|
+
|
|
821
902
|
A shorter `current_user` helper is conventional:
|
|
822
903
|
|
|
823
904
|
```ruby
|
|
@@ -860,7 +941,25 @@ The same URL serves GET (form/confirmation) and POST (commit) — the HTTP verb
|
|
|
860
941
|
|
|
861
942
|
## Complete example
|
|
862
943
|
|
|
944
|
+
Inviting a user is a textbook second-caller case — a seats-provisioning job, an admin rake task, and a signup API all need to send the same invitation. So the operation lives on `Company`; the interaction is the button in front of it.
|
|
945
|
+
|
|
946
|
+
```ruby
|
|
947
|
+
# app/models/company.rb — what inviting MEANS: the row, the mail, the audit trail
|
|
948
|
+
class Company < ApplicationRecord
|
|
949
|
+
has_many :user_invites
|
|
950
|
+
|
|
951
|
+
def invite!(email:, role:, by:)
|
|
952
|
+
user_invites.create!(email: email, role: role, invited_by: by).tap do |invite|
|
|
953
|
+
UserInviteMailer.invitation(invite).deliver_later
|
|
954
|
+
end
|
|
955
|
+
end
|
|
956
|
+
|
|
957
|
+
def pending_invite_for?(email) = user_invites.exists?(email: email, state: :pending)
|
|
958
|
+
end
|
|
959
|
+
```
|
|
960
|
+
|
|
863
961
|
```ruby
|
|
962
|
+
# app/interactions/company/invite_user_interaction.rb
|
|
864
963
|
class Company::InviteUserInteraction < Plutonium::Resource::Interaction
|
|
865
964
|
presents label: "Invite User",
|
|
866
965
|
icon: Phlex::TablerIcons::UserPlus
|
|
@@ -872,6 +971,7 @@ class Company::InviteUserInteraction < Plutonium::Resource::Interaction
|
|
|
872
971
|
input :email
|
|
873
972
|
input :role, as: :select, choices: -> { UserInvite.roles.keys }
|
|
874
973
|
|
|
974
|
+
# Input shape only — readable email? a role that exists?
|
|
875
975
|
validates :email, presence: true, format: {with: URI::MailTo::EMAIL_REGEXP}
|
|
876
976
|
validates :role, presence: true, inclusion: {in: UserInvite.roles.keys}
|
|
877
977
|
validate :not_already_invited
|
|
@@ -879,21 +979,18 @@ class Company::InviteUserInteraction < Plutonium::Resource::Interaction
|
|
|
879
979
|
private
|
|
880
980
|
|
|
881
981
|
def execute
|
|
882
|
-
invite
|
|
883
|
-
company: resource, email: email, role: role,
|
|
884
|
-
invited_by: current_user
|
|
885
|
-
)
|
|
886
|
-
UserInviteMailer.invitation(invite).deliver_later
|
|
982
|
+
resource.invite!(email: email, role: role, by: current_user)
|
|
887
983
|
succeed(resource).with_message("Invitation sent to #{email}")
|
|
888
984
|
rescue ActiveRecord::RecordInvalid => e
|
|
889
985
|
failed(e.record.errors)
|
|
890
986
|
end
|
|
891
987
|
|
|
988
|
+
# Deliberately duplicated. UserInvite enforces uniqueness for real (a job calling
|
|
989
|
+
# company.invite! must hit it too); this copy exists only so the message lands on
|
|
990
|
+
# the :email field instead of in the base error summary.
|
|
892
991
|
def not_already_invited
|
|
893
|
-
return
|
|
894
|
-
|
|
895
|
-
errors.add(:email, "already has a pending invitation")
|
|
896
|
-
end
|
|
992
|
+
return if email.blank?
|
|
993
|
+
errors.add(:email, "already has a pending invitation") if resource.pending_invite_for?(email)
|
|
897
994
|
end
|
|
898
995
|
|
|
899
996
|
def current_user = view_context.controller.helpers.current_user
|