tsykvas_rails_template 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +200 -0
  3. data/CODE_OF_CONDUCT.md +10 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +589 -0
  6. data/Rakefile +17 -0
  7. data/lib/generators/tsykvas_rails_template/companions/companions_generator.rb +273 -0
  8. data/lib/generators/tsykvas_rails_template/concept/concept_generator.rb +145 -0
  9. data/lib/generators/tsykvas_rails_template/concept/templates/component/edit.html.slim.tt +5 -0
  10. data/lib/generators/tsykvas_rails_template/concept/templates/component/edit.rb.tt +11 -0
  11. data/lib/generators/tsykvas_rails_template/concept/templates/component/index.html.slim.tt +5 -0
  12. data/lib/generators/tsykvas_rails_template/concept/templates/component/index.rb.tt +11 -0
  13. data/lib/generators/tsykvas_rails_template/concept/templates/component/new.html.slim.tt +5 -0
  14. data/lib/generators/tsykvas_rails_template/concept/templates/component/new.rb.tt +11 -0
  15. data/lib/generators/tsykvas_rails_template/concept/templates/component/show.html.slim.tt +4 -0
  16. data/lib/generators/tsykvas_rails_template/concept/templates/component/show.rb.tt +11 -0
  17. data/lib/generators/tsykvas_rails_template/concept/templates/controller.rb.tt +45 -0
  18. data/lib/generators/tsykvas_rails_template/concept/templates/operation/create.rb.tt +31 -0
  19. data/lib/generators/tsykvas_rails_template/concept/templates/operation/destroy.rb.tt +13 -0
  20. data/lib/generators/tsykvas_rails_template/concept/templates/operation/edit.rb.tt +10 -0
  21. data/lib/generators/tsykvas_rails_template/concept/templates/operation/index.rb.tt +9 -0
  22. data/lib/generators/tsykvas_rails_template/concept/templates/operation/new.rb.tt +10 -0
  23. data/lib/generators/tsykvas_rails_template/concept/templates/operation/show.rb.tt +10 -0
  24. data/lib/generators/tsykvas_rails_template/concept/templates/operation/update.rb.tt +31 -0
  25. data/lib/generators/tsykvas_rails_template/install/bootstrap_installer.rb +225 -0
  26. data/lib/generators/tsykvas_rails_template/install/install_generator.rb +298 -0
  27. data/lib/generators/tsykvas_rails_template/install/templates/.claude/agents/buddy.md +157 -0
  28. data/lib/generators/tsykvas_rails_template/install/templates/.claude/agents/code-reviewer.md +117 -0
  29. data/lib/generators/tsykvas_rails_template/install/templates/.claude/agents/security-reviewer.md +113 -0
  30. data/lib/generators/tsykvas_rails_template/install/templates/.claude/agents/tech-lead.md +150 -0
  31. data/lib/generators/tsykvas_rails_template/install/templates/.claude/commands/check.md +51 -0
  32. data/lib/generators/tsykvas_rails_template/install/templates/.claude/commands/code-review.md +60 -0
  33. data/lib/generators/tsykvas_rails_template/install/templates/.claude/commands/docs-create.md +102 -0
  34. data/lib/generators/tsykvas_rails_template/install/templates/.claude/commands/pr-review.md +81 -0
  35. data/lib/generators/tsykvas_rails_template/install/templates/.claude/commands/pushit.md +160 -0
  36. data/lib/generators/tsykvas_rails_template/install/templates/.claude/commands/refactor.md +132 -0
  37. data/lib/generators/tsykvas_rails_template/install/templates/.claude/commands/task-sum.md +47 -0
  38. data/lib/generators/tsykvas_rails_template/install/templates/.claude/commands/tests.md +67 -0
  39. data/lib/generators/tsykvas_rails_template/install/templates/.claude/commands/tsykvas-claude.md +262 -0
  40. data/lib/generators/tsykvas_rails_template/install/templates/.claude/commands/update-docs.md +78 -0
  41. data/lib/generators/tsykvas_rails_template/install/templates/.claude/commands/update-rules.md +102 -0
  42. data/lib/generators/tsykvas_rails_template/install/templates/.claude/commands/update-tests.md +135 -0
  43. data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/architecture.md +315 -0
  44. data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/authentication.md +96 -0
  45. data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/background-jobs.md +135 -0
  46. data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/code-style.md +101 -0
  47. data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/commands.md +34 -0
  48. data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/companions.md +128 -0
  49. data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/concepts-refactoring.md +194 -0
  50. data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/database.md +135 -0
  51. data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/deployment.md +138 -0
  52. data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/design-system.md +322 -0
  53. data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/documentation.md +89 -0
  54. data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/forms.md +174 -0
  55. data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/i18n.md +165 -0
  56. data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/routing-and-namespaces.md +114 -0
  57. data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/security.md +122 -0
  58. data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/stimulus-controllers.md +166 -0
  59. data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/testing-examples.md +180 -0
  60. data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/testing.md +117 -0
  61. data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/tsykvas_rails_template.md +280 -0
  62. data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/ui-components.md +196 -0
  63. data/lib/generators/tsykvas_rails_template/install/templates/CLAUDE.md.tt +81 -0
  64. data/lib/generators/tsykvas_rails_template/install/templates/app/concepts/base/component/base.rb +6 -0
  65. data/lib/generators/tsykvas_rails_template/install/templates/app/concepts/base/operation/base.rb +124 -0
  66. data/lib/generators/tsykvas_rails_template/install/templates/app/concepts/base/operation/result.rb +56 -0
  67. data/lib/generators/tsykvas_rails_template/install/templates/app/concepts/home/component/index.html.slim +49 -0
  68. data/lib/generators/tsykvas_rails_template/install/templates/app/concepts/home/component/index.rb +11 -0
  69. data/lib/generators/tsykvas_rails_template/install/templates/app/concepts/home/operation/index.rb +17 -0
  70. data/lib/generators/tsykvas_rails_template/install/templates/app/controllers/concerns/operations_methods.rb +148 -0
  71. data/lib/generators/tsykvas_rails_template/install/templates/app/controllers/home_controller.rb +10 -0
  72. data/lib/generators/tsykvas_rails_template/install/templates/app/policies/application_policy.rb +33 -0
  73. data/lib/generators/tsykvas_rails_template/install/templates/app/policies/home_policy.rb +8 -0
  74. data/lib/tasks/tsykvas.rake +11 -0
  75. data/lib/tsykvas_rails_template/probe.rb +236 -0
  76. data/lib/tsykvas_rails_template/railtie.rb +13 -0
  77. data/lib/tsykvas_rails_template/version.rb +5 -0
  78. data/lib/tsykvas_rails_template.rb +18 -0
  79. metadata +183 -0
@@ -0,0 +1,128 @@
1
+ # Recommended companions
2
+
3
+ `bin/rails g tsykvas_rails_template:companions` adds the gems used across
4
+ every project the gem author maintains, plus runs their `:install`
5
+ sub-generators and injects standard configuration. Run **after**
6
+ `tsykvas_rails_template:install`.
7
+
8
+ This doc is gem-shipped reference; `/tsykvas-claude` keeps the body
9
+ verbatim and only swaps host-specific examples.
10
+
11
+ ## What you get
12
+
13
+ | Gem | Group | Why | Post-install action |
14
+ |---|---|---|---|
15
+ | `devise` | top-level | Authentication (`current_user`, `before_action :authenticate_user!`). The gem's `OperationsMethods` and `Base::Operation::Base#authorize!` need a `current_user` source — Devise is the universal choice across reference projects. | `rails g devise:install` (initializer + locale). **No User model is generated** — run `rails g devise User` (or your resource name) when ready. |
16
+ | `omniauth-rails_csrf_protection` | top-level | CSRF guard for OAuth callbacks. Required if Devise + OmniAuth are used together. | none |
17
+ | `simple_form` | top-level | Form builder with cleaner DSL than `form_with`. Components and operation scaffolds work fine without it, but you'll likely use it once you have non-trivial forms. | `rails g simple_form:install` (with `--bootstrap` if Probe sees Bootstrap). |
18
+ | `mini_magick` | top-level | Image processing for ActiveStorage variants and `image_processing` gem. | none — but **ImageMagick must be installed system-wide** (`brew install imagemagick` / `apt install imagemagick`). The generator doesn't manage OS packages. |
19
+ | `mission_control-jobs` | top-level (gated on `:solid_queue`) | Web UI for SolidQueue at `/jobs`. | Injects an admin-only mount into `config/routes.rb`. See "MissionControl::Jobs constraint" below. |
20
+ | `rspec-rails` | `:development, :test` | The gem assumes RSpec for testing; operation-spec patterns documented in `testing.md`. | `rails g rspec:install` (creates `.rspec`, `spec/spec_helper.rb`, `spec/rails_helper.rb`). |
21
+ | `factory_bot_rails` | `:development, :test` | Test data factories. | none |
22
+ | `faker` | `:development, :test` | Realistic fake data inside factories. | none |
23
+ | `shoulda-matchers` | `:test` | RSpec matchers for AR / Rails (`have_many`, `validate_presence_of`, etc.). The project explicitly avoids association/validation specs as a default — but matchers are still useful where appropriate. | Appends a `Shoulda::Matchers.configure` block to `spec/rails_helper.rb`. |
24
+ | `webmock` | `:test` | Stub external HTTP calls in tests. | Appends `WebMock.disable_net_connect!(allow_localhost: true)` to `spec/rails_helper.rb`. |
25
+ | `dotenv-rails` | `:development, :test` | Load env vars from `.env` files in dev/test. | Appends `.env` / `.env.*` / `!.env.example` rules to `.gitignore`. |
26
+
27
+ ## Opt-out matrix
28
+
29
+ | Flag | Skips |
30
+ |---|---|
31
+ | `--skip-auth` | `devise` + `omniauth-rails_csrf_protection` + `devise:install` |
32
+ | `--skip-forms` | `simple_form` + `simple_form:install` |
33
+ | `--skip-images` | `mini_magick` |
34
+ | `--skip-jobs-ui` | `mission_control-jobs` + the routes mount |
35
+ | `--skip-test` | `rspec-rails` + `factory_bot_rails` + `faker` + `shoulda-matchers` + `webmock` + `rspec:install` + config injections |
36
+ | `--skip-dev` | `dotenv-rails` + `.gitignore` append |
37
+ | `--skip-bundle` | `bundle install` after Gemfile edits |
38
+ | `--skip-post-install` | All `:install` sub-generators and config injections (Gemfile additions still happen) |
39
+
40
+ Combine flags freely: `bin/rails g tsykvas_rails_template:companions --skip-auth --skip-forms` adds only the test/dev/images/jobs-ui groups.
41
+
42
+ ## Idempotency
43
+
44
+ Re-running `:companions` is safe:
45
+
46
+ - **Gemfile additions** check existing contents via regex; skip if present.
47
+ - **`:install` sub-generators** skip if their canonical config file exists
48
+ (`config/initializers/devise.rb`, `config/initializers/simple_form.rb`,
49
+ `spec/rails_helper.rb`).
50
+ - **Config injections** check for marker strings (`Shoulda::Matchers`,
51
+ `WebMock.disable_net_connect`, `MissionControl::Jobs::Engine`, `.env`)
52
+ and bail if found.
53
+
54
+ You can re-run after edits without losing your work, and CI runs the
55
+ generator twice in the smoke job to verify this.
56
+
57
+ ## MissionControl::Jobs constraint
58
+
59
+ The mount injected into `config/routes.rb`:
60
+
61
+ ```ruby
62
+ mount MissionControl::Jobs::Engine,
63
+ at: "/jobs",
64
+ constraints: ->(req) {
65
+ user = req.env["warden"]&.user
66
+ user.respond_to?(:admin?) && user.admin?
67
+ }
68
+ ```
69
+
70
+ Why a lambda constraint and not `authenticated :user`:
71
+
72
+ - The lambda runs **per request**, so a missing `User` model at boot
73
+ doesn't crash. You can install `:companions` before generating Devise's
74
+ User; `/jobs` will return 404 on every request until `User` exists with
75
+ an `admin?` method, which is exactly what you want (lock-by-default).
76
+ - Works with **any Warden-based auth** (Devise, custom Warden mounts).
77
+ Doesn't require Devise's `devise_for :users` to have run yet.
78
+ - `respond_to?(:admin?)` keeps the lambda from raising `NoMethodError`
79
+ if your User model doesn't define `admin?` yet — the route just 404s.
80
+
81
+ If your auth stack isn't Warden-based, swap the constraint:
82
+ ```ruby
83
+ constraints: ->(req) {
84
+ current_user = YourAuthHelper.current_user_from(req)
85
+ current_user&.admin?
86
+ }
87
+ ```
88
+
89
+ ## Common follow-ups
90
+
91
+ After `bin/rails g tsykvas_rails_template:companions`:
92
+
93
+ 1. **Devise User model.** When your domain is ready: `rails g devise User`.
94
+ Add `admin:boolean` if you want the `/jobs` mount to actually show its
95
+ UI: `rails g devise User admin:boolean`. Then `rails db:migrate`.
96
+ Don't forget to update the Devise routes line: `devise_for :users`.
97
+ 2. **simple_form configuration.** If you skipped `--bootstrap` and want it
98
+ later, re-run `rails g simple_form:install --bootstrap` (it'll prompt
99
+ to overwrite the initializer; say yes).
100
+ 3. **shoulda-matchers + RSpec.** The appended config block targets RSpec.
101
+ If you migrated to a different test framework after running
102
+ `:companions`, remove the block manually.
103
+ 4. **WebMock allow-list.** If your tests need to hit a real internal
104
+ service (e.g. a containerized backend), wrap the call:
105
+ ```ruby
106
+ WebMock.disable! { real_call } # or WebMock.allow_net_connect!
107
+ ```
108
+
109
+ ## Why these gems specifically
110
+
111
+ These are the gems that appear across the gem author's reference projects AND aren't part of the default `rails new` Gemfile. The gem deliberately doesn't ship gems that:
112
+
113
+ - Already come with Rails (Puma, Importmap, Turbo, Stimulus, Bootsnap,
114
+ SolidQueue / SolidCache / SolidCable, Brakeman, Rubocop-Rails-Omakase, …).
115
+ - Vary across projects (Tailwind vs custom CSS as alternatives to Bootstrap, MySQL vs
116
+ Postgres, Sidekiq vs SolidQueue when both are valid).
117
+ - Are essential to one project but not universal (Swagger generators, alternate
118
+ pagination libraries, search DSLs).
119
+
120
+ The `:companions` set is the **least common denominator across the gem
121
+ author's projects**, not "every gem you might want".
122
+
123
+ ## Skipping `:companions` entirely
124
+
125
+ If your stack is incompatible (CanCanCan instead of Pundit, Tailwind
126
+ instead of Bootstrap with simple_form, Minitest instead of RSpec), don't
127
+ run `:companions`. The gem's core (`:install` + `:concept`) is stack-
128
+ agnostic and works fine without any of these.
@@ -0,0 +1,194 @@
1
+ # Concepts Pattern — Guide
2
+
3
+ ## What a controller looks like
4
+
5
+ ```ruby
6
+ # frozen_string_literal: true
7
+
8
+ class Admin::UsersController < Admin::BaseController
9
+ def index
10
+ endpoint Admin::User::Operation::Index, Admin::User::Component::Index
11
+ end
12
+
13
+ def show
14
+ endpoint Admin::User::Operation::Show, Admin::User::Component::Show
15
+ end
16
+ end
17
+ ```
18
+
19
+ Every action is a one-liner: `endpoint OperationClass, ComponentClass`. For redirect-only actions the component can be omitted (the operation must set `self.redirect_path`). No AR queries, no `if`/`else`, no flash juggling in controllers.
20
+
21
+ ---
22
+
23
+ ## File structure
24
+
25
+ ```
26
+ app/concepts/<namespace>/<feature>/
27
+ operation/
28
+ index.rb # business logic for index
29
+ show.rb
30
+ create.rb
31
+ update.rb
32
+ destroy.rb
33
+ component/
34
+ index.rb # ViewComponent class
35
+ index.html.slim # template (note .html.slim — required by ViewComponent 4.8+)
36
+ show.rb
37
+ show.html.slim
38
+ form.rb # shared form for new/edit
39
+ form.html.slim
40
+ new.rb # subclass of Form (breadcrumbs/title)
41
+ edit.rb
42
+ ```
43
+
44
+ Mirror the same paths under `spec/concepts/...`.
45
+
46
+ ---
47
+
48
+ ## How `endpoint` works
49
+
50
+ `endpoint` is defined in `app/controllers/concerns/operations_methods.rb`. It:
51
+
52
+ 1. Calls `operation.call(params:, current_user:)`
53
+ 2. Verifies authorization was invoked (or skipped) via `check_authorization_is_called(result)`
54
+ 3. Yields to the optional block (used by `Users::RegistrationsController` for Devise sign-in glue)
55
+ 4. Dispatches by `action_name` and response format
56
+
57
+ | `action_name` | Success behavior | Failure behavior |
58
+ |---|---|---|
59
+ | `index`, `show`, `edit`, `new` | Render `component.new(**kwargs)` | Same, with flash alert |
60
+ | `create`, `update`, anything containing `destroy` | Redirect to `result.redirect_path` (or `<controller_name>_path`) with flash notice | Re-render component with 422 |
61
+ | `format.js` (for any action) | Redirect via `window.location.href = '<path>'` | Render component into `#modals` and toggle Bootstrap modal |
62
+ | `format.json` | `result.model.map(&:select2_search_result)` with pagination JSON | (same) |
63
+
64
+ Component kwargs come from `result.model`:
65
+
66
+ - `OpenStruct` → `component.new(**result.model.to_h)` (recommended for multi-value results)
67
+ - Single AR object → `component.new(<concept>: model)` where `<concept>` is the operation's top-level namespace, singularized for show/edit/new and pluralized for index/create/update/destroy
68
+
69
+ ---
70
+
71
+ ## Writing an operation
72
+
73
+ ```ruby
74
+ # frozen_string_literal: true
75
+
76
+ class Feature::Operation::Action < Base::Operation::Base
77
+ def perform!(params:, current_user:)
78
+ # 1. Authorization (REQUIRED — pick one)
79
+ authorize! record, :action?
80
+ # or
81
+ self.model = ::OpenStruct.new(items: policy_scope(Item))
82
+ # or
83
+ skip_authorize # only when there's no AR record to authorize
84
+
85
+ # 2. Business logic / data loading
86
+
87
+ # 3. Set the result model
88
+ self.model = ::OpenStruct.new(key: value, ...)
89
+
90
+ # 4. (For create/update/destroy) trigger redirect
91
+ self.redirect_path = some_path
92
+ notice(I18n.t('notices.created'))
93
+ end
94
+ end
95
+ ```
96
+
97
+ ### Authorization rules
98
+
99
+ - Internal AR models → `authorize! record, :action?` (or `policy_scope(...)` for collections, or `authorize_and_save!`)
100
+ - Anything truly auth-free → `skip_authorize` and (if iterating a collection) `skip_policy_scope`
101
+ - Per-domain base policies (`Admin::BasePolicy`, `Crm::BasePolicy`, `Screener::BasePolicy`) drive the redirect destination on `Pundit::NotAuthorizedError` — keep your `<Domain>Policy < <Domain>::BasePolicy` hierarchy intact.
102
+
103
+ ### Triggering a redirect from an operation
104
+
105
+ Set `self.redirect_path` and `endpoint` will redirect:
106
+
107
+ ```ruby
108
+ self.redirect_path = Rails.application.routes.url_helpers.crm_root_path
109
+ notice(I18n.t('notices.updated'))
110
+ ```
111
+
112
+ For "resource not found" in a `show`-style action:
113
+
114
+ ```ruby
115
+ def resource_not_found?(record)
116
+ return false unless record.nil?
117
+
118
+ add_error :base, I18n.t('alerts.resource_not_found')
119
+ invalid!
120
+ self.redirect_path = Rails.application.routes.url_helpers.admin_users_path
121
+ true
122
+ end
123
+ ```
124
+
125
+ ### Flash messages
126
+
127
+ ```ruby
128
+ notice(I18n.t('notices.created')) # success → result.message
129
+ notice(I18n.t('alerts.warning'), level: :alert) # warning → result.message_level == :alert
130
+ add_error :base, I18n.t('alerts.failed'); invalid! # failure → result.error_message
131
+ ```
132
+
133
+ ### `OpenStruct` model
134
+
135
+ Pass multiple values via `::OpenStruct`. `endpoint` spreads it as kwargs:
136
+
137
+ ```ruby
138
+ self.model = ::OpenStruct.new(
139
+ users: paginated_users,
140
+ request_params: params # only if the slim template needs request params
141
+ )
142
+ ```
143
+
144
+ ```ruby
145
+ component.new(**result.model.to_h) # Component.new(users: ..., request_params: ...)
146
+ ```
147
+
148
+ ### Sub-operations
149
+
150
+ ```ruby
151
+ run_operation(OtherOp, params: params, current_user: current_user)
152
+ # Failures bubble up automatically. Pass manually_handle_errors: true to handle them yourself.
153
+ ```
154
+
155
+ ---
156
+
157
+ ## Writing a component
158
+
159
+ ```ruby
160
+ # frozen_string_literal: true
161
+
162
+ class Feature::Component::Index < Base::Component::Base
163
+ def initialize(users:, request_params: nil)
164
+ @users = users
165
+ @request_params = request_params
166
+ end
167
+ end
168
+ ```
169
+
170
+ Rules:
171
+
172
+ - Always `# frozen_string_literal: true`.
173
+ - Pure presentation: data only via `initialize`. No AR queries.
174
+ - Use `I18n.t('full.key')` — never `t('.relative_key')`.
175
+ - Template at `app/concepts/<ns>/<feature>/component/<action>.html.slim` next to the `.rb`.
176
+ - For request params, prefer `@request_params` (passed through OpenStruct) over `helpers.params`.
177
+
178
+ ---
179
+
180
+ ## Non-standard action names
181
+
182
+ `endpoint` triggers a redirect when `result.redirect_path` is set, regardless of action name. So custom actions like `archive_property` work without special handling — just make sure the operation sets `self.redirect_path`.
183
+
184
+ ---
185
+
186
+ ## Refactor checklist
187
+
188
+ 1. Read the controller — understand each action's intent.
189
+ 2. Move logic into operations under `app/concepts/<ns>/<feature>/operation/`.
190
+ 3. Create or verify components under `app/concepts/<ns>/<feature>/component/`.
191
+ 4. Replace controller actions with `endpoint Op, Component`.
192
+ 5. Update every locale file under `config/locales/` to mirror the new keys.
193
+ 6. Add specs: operation spec (happy path + Pundit failure path), component spec for non-trivial render logic.
194
+ 7. Validate: `bin/rails zeitwerk:check`, `bin/rubocop`, `bundle exec rspec`.
@@ -0,0 +1,135 @@
1
+ # Database & Migrations
2
+
3
+ PostgreSQL 16, Rails 8.1, multi-database setup.
4
+
5
+ ## Multi-database layout
6
+
7
+ Rails 8.1 ships with three solid_* gems, each with its own database:
8
+
9
+ | Database | Purpose | Schema file |
10
+ |---|---|---|
11
+ | `<app_name>_<env>` (primary) | Application data — `users`, `properties`, ... | `db/schema.rb` |
12
+ | `cache` | `Rails.cache` backing (SolidCache) | `db/cache_schema.rb` |
13
+ | `queue` | Active Job backing (SolidQueue) | `db/queue_schema.rb` |
14
+ | `cable` | Action Cable backing (SolidCable) | `db/cable_schema.rb` |
15
+
16
+ `config/database.yml` only defines the primary; `cache`, `queue`, `cable` are configured per-env via the corresponding YAML files (`config/cache.yml`, `config/queue.yml`, `config/cable.yml`). In **development** and **test**, cache/queue/cable run in the primary database for simplicity. In **production** they're separate Postgres databases on the same instance.
17
+
18
+ Practical impact: `bin/rails db:prepare` handles them all. You generally don't write migrations against the cache/queue/cable schemas — those are managed by their respective gems.
19
+
20
+ ## Migration conventions
21
+
22
+ - File name: `db/migrate/YYYYMMDDHHMMSS_describe_change.rb`
23
+ - Class name in PascalCase matching the file.
24
+ - Schema version is in `db/schema.rb` — keep it in version control.
25
+ - Always include `null: false` for required columns.
26
+ - Always add an index for foreign keys: `t.references :property, foreign_key: true, null: false` (creates the index automatically).
27
+ - Use `add_foreign_key` if you create the column without `t.references`.
28
+ - Default values: only at the DB layer for safety-critical defaults (NOT NULL with sensible default). Application-level defaults live in the model.
29
+ - For long-running migrations on large tables, use `disable_ddl_transaction!` and `add_index ..., algorithm: :concurrently`.
30
+
31
+ ```ruby
32
+ class CreateReports < ActiveRecord::Migration[8.1]
33
+ def change
34
+ create_table :reports do |t|
35
+ t.references :property, null: false, foreign_key: true
36
+ t.string :title, null: false
37
+ t.text :body
38
+ t.integer :status, null: false, default: 0
39
+ t.timestamps
40
+ end
41
+
42
+ add_index :reports, [:property_id, :status]
43
+ end
44
+ end
45
+ ```
46
+
47
+ ## Enums
48
+
49
+ Use **integer enums** at the DB layer, declared in the model:
50
+
51
+ ```ruby
52
+ # db migration
53
+ t.integer :role, null: false, default: 1
54
+
55
+ # model
56
+ enum :role, { admin: 0, owner: 1, customer: 2 }
57
+ ```
58
+
59
+ Rules:
60
+ - Always pin integer values explicitly (don't rely on positional ordering).
61
+ - Adding a new value: append to the end with the next integer. Never reorder or reuse values — existing rows reference them.
62
+ - The default integer in the migration should map to the most common value (here `1` = `:customer`).
63
+
64
+ ## Current schema (snapshot)
65
+
66
+ ```
67
+ users
68
+ id (bigint, PK)
69
+ email (string, NOT NULL, default '', unique index)
70
+ encrypted_password (string, NOT NULL, default '')
71
+ name (string, NOT NULL)
72
+ role (integer, NOT NULL, default 1) # User#role enum
73
+ reset_password_token (string, unique index)
74
+ reset_password_sent_at (datetime)
75
+ remember_created_at (datetime)
76
+ created_at, updated_at
77
+
78
+ properties
79
+ id (bigint, PK)
80
+ name (string, NOT NULL)
81
+ owner_id (bigint, FK → users.id, NOT NULL, indexed)
82
+ created_at, updated_at
83
+ ```
84
+
85
+ `properties.owner_id` → `users.id` is the property/owner relationship. `User#owned_properties` returns properties where this user is the owner. Substitute your own associations to fit your domain.
86
+
87
+ ## Models
88
+
89
+ `app/models/` — keep them small:
90
+
91
+ - Validations + associations + enums + scopes only.
92
+ - No business logic — that lives in operations.
93
+ - No callbacks for cross-model side effects (use operations or jobs).
94
+ - Custom validators (e.g. `User#owner_can_have_only_one_property`) are fine when they enforce a true invariant.
95
+
96
+ `select2_search_result` is a model-level convention: any model used as a select2 source must implement it (called by `endpoint`'s `format.json` branch). Returns a hash like `{ id:, text: }`.
97
+
98
+ ## Seeds & dev data
99
+
100
+ `db/seeds.rb` is currently empty. When you add seeds:
101
+
102
+ - Make them **idempotent** (`find_or_create_by!`).
103
+ - Use `Rails.env.development?` guards if a seed shouldn't run in production.
104
+ - Document the dev login (admin email + password) in `README.md` so a fresh checkout works.
105
+
106
+ ```ruby
107
+ # db/seeds.rb
108
+ if Rails.env.development?
109
+ User.find_or_create_by!(email: 'admin@example.com') do |u|
110
+ u.name = 'Admin'
111
+ u.password = 'password'
112
+ u.role = :admin
113
+ end
114
+ end
115
+ ```
116
+
117
+ ## Common commands
118
+
119
+ ```bash
120
+ bin/rails db:prepare # create + migrate (or load schema) — run on bootstrap
121
+ bin/rails db:migrate # apply pending migrations
122
+ bin/rails db:rollback # roll back last migration
123
+ bin/rails db:seed # run db/seeds.rb
124
+ bin/rails db:reset # drop + create + load schema + seed (development only)
125
+ bin/rails db:schema:load # load schema.rb (faster than running all migrations)
126
+ ```
127
+
128
+ ## Anti-patterns
129
+
130
+ - ❌ `belongs_to :foo` without `foreign_key: true` constraint at the DB level.
131
+ - ❌ String-typed enums — use integer enums with explicit values.
132
+ - ❌ Renaming an enum value without a data migration.
133
+ - ❌ Reusing an integer for a different enum value (silently corrupts old rows).
134
+ - ❌ Long-running `add_column` on large tables without `algorithm: :concurrently` for the matching index.
135
+ - ❌ Business logic in `before_save` — the operation layer is the right place.
@@ -0,0 +1,138 @@
1
+ # Deployment (Kamal)
2
+
3
+ The app deploys via **Kamal** (`config/deploy.yml`). Single web server runs Puma + SolidQueue + the Thruster proxy in one container.
4
+
5
+ ## Where things live
6
+
7
+ ```
8
+ config/deploy.yml # Service config — servers, registry, env, volumes, builder
9
+ .kamal/secrets # Pulls secrets from local env / 1Password (NEVER raw creds)
10
+ .kamal/hooks/ # Lifecycle hooks (pre-build, post-deploy, ...) — all .sample by default
11
+ Dockerfile # Production image
12
+ config/master.key # NEVER committed — used to decrypt config/credentials.yml.enc
13
+ ```
14
+
15
+ ## Service overview
16
+
17
+ From `config/deploy.yml`:
18
+
19
+ - **Service name**: `<your-app>` (set in `config/deploy.yml` — match the host app's name)
20
+ - **Image**: `your-user/<your-app>` (replace `your-user` with your container registry user before first deploy)
21
+ - **Servers**: web servers under `servers.web`
22
+ - **SSL**: Let's Encrypt via Kamal proxy (`proxy.ssl: true`)
23
+ - **Volumes**: `app_storage:/rails/storage` for Active Storage (rename to match your service)
24
+ - **Builder arch**: `amd64`
25
+ - **SolidQueue runs in Puma**: `SOLID_QUEUE_IN_PUMA=true` (single-server setup). Split it onto `servers.job` once you scale.
26
+
27
+ ## Secrets
28
+
29
+ ```bash
30
+ .kamal/secrets # Resolves at deploy time, never holds raw secrets
31
+ ```
32
+
33
+ Current setup pulls:
34
+ - `KAMAL_REGISTRY_PASSWORD` from local env
35
+ - `RAILS_MASTER_KEY` from `config/master.key`
36
+
37
+ To add a new secret:
38
+
39
+ 1. Add the variable name to `.kamal/secrets` and resolve it (env, 1Password, file).
40
+ 2. Reference it under `env.secret:` in `config/deploy.yml`.
41
+ 3. The container will receive it as an env var at runtime.
42
+
43
+ **Never** put raw credentials in `config/deploy.yml` or `.kamal/secrets` — both are committed.
44
+
45
+ ## Common commands
46
+
47
+ ```bash
48
+ bin/kamal setup # First-time setup (one-shot per server)
49
+ bin/kamal deploy # Build + push image + reload containers
50
+ bin/kamal redeploy # Redeploy without rebuilding
51
+ bin/kamal rollback # Rollback to the previous version
52
+ bin/kamal logs -f # Tail logs (alias defined in deploy.yml)
53
+ bin/kamal console # Open a Rails console on the server
54
+ bin/kamal shell # Bash shell on the server
55
+ bin/kamal dbc # Open a dbconsole
56
+
57
+ bin/kamal app exec ... # Run an ad-hoc command in the app container
58
+ bin/kamal proxy ... # Manage the Kamal proxy container
59
+ ```
60
+
61
+ `config/deploy.yml.aliases` defines `console`, `shell`, `logs`, `dbc` as shortcuts.
62
+
63
+ ## Build & release flow
64
+
65
+ 1. `bin/kamal deploy` builds the image locally (or via remote builder if configured).
66
+ 2. Pushes to the registry.
67
+ 3. Triggers `pre-deploy` hooks (none by default — `.sample` files).
68
+ 4. Runs `db:prepare` inside the app container (handled by Rails on boot).
69
+ 5. Replaces running containers.
70
+ 6. Triggers `post-deploy` hooks.
71
+
72
+ Bridging in-flight requests across asset versions: `asset_path: /rails/public/assets` (Kamal copies the new and old asset directories so neither generation 404s during the swap).
73
+
74
+ ## Lifecycle hooks
75
+
76
+ `.kamal/hooks/` ships with samples:
77
+
78
+ - `pre-build` — before the image is built (e.g. compile assets externally)
79
+ - `pre-connect` — before Kamal connects to servers
80
+ - `pre-deploy`, `post-deploy` — around the deploy
81
+ - `pre-app-boot`, `post-app-boot` — around the app container start
82
+ - `pre-proxy-reboot`, `post-proxy-reboot` — around the Kamal proxy
83
+
84
+ Drop the `.sample` extension to enable a hook. They are shell scripts run from the deploy machine.
85
+
86
+ ## Rollback strategy
87
+
88
+ ```bash
89
+ bin/kamal rollback # Reverts to previous image version
90
+ ```
91
+
92
+ Rollbacks are fast (just swap container images), but **migrations are not auto-reverted** — if you rolled forward a destructive migration, you need to manually fix the schema before rolling back.
93
+
94
+ For destructive migrations, prefer the two-step pattern:
95
+ 1. Deploy a release that *adds* the new column / table without removing the old one.
96
+ 2. Backfill data + switch reads/writes.
97
+ 3. Deploy a follow-up release that drops the old column.
98
+
99
+ ## CI
100
+
101
+ GitHub Actions (`.github/workflows/ci.yml`) runs on every PR:
102
+
103
+ - `bin/brakeman --no-pager`
104
+ - `bin/importmap audit`
105
+ - `bin/rubocop`
106
+ - `bundle exec rspec` (against PostgreSQL 16)
107
+
108
+ All four must pass before merge. CI does NOT auto-deploy — production deploys are manual via `bin/kamal deploy`.
109
+
110
+ ## Environment variables
111
+
112
+ Set in `config/deploy.yml` under `env.clear` (visible) or `env.secret` (resolved from `.kamal/secrets`):
113
+
114
+ - `RAILS_MASTER_KEY` (secret) — decrypts credentials
115
+ - `SOLID_QUEUE_IN_PUMA=true` — runs queue inline with web
116
+ - `JOB_CONCURRENCY` — number of SolidQueue processes (default 1)
117
+ - `WEB_CONCURRENCY` — number of Puma workers (default 1)
118
+ - `DB_HOST` — when using an external Postgres
119
+ - `RAILS_LOG_LEVEL` — defaults to `info`
120
+
121
+ In development, use `.env` (loaded by `dotenv-rails`).
122
+
123
+ ## Adding a new server
124
+
125
+ 1. Add the IP under `servers.web` in `config/deploy.yml`.
126
+ 2. Run `bin/kamal setup` against that server (one-time).
127
+ 3. Run `bin/kamal deploy`.
128
+
129
+ Once you have multiple web servers, **move SolidQueue to a dedicated `servers.job` host** (set `SOLID_QUEUE_IN_PUMA=false` and use `cmd: bin/jobs`). Otherwise jobs run on every web box and double-execute.
130
+
131
+ ## Anti-patterns
132
+
133
+ - ❌ Committing `config/master.key`.
134
+ - ❌ Hardcoding secrets in `config/deploy.yml` or `.kamal/secrets`.
135
+ - ❌ `kamal redeploy` after a schema change without re-running migrations (use `deploy`).
136
+ - ❌ Running `bin/kamal deploy` from a branch with uncommitted changes.
137
+ - ❌ Force-pushing to `main` (CI gates production-bound code).
138
+ - ❌ Multi-host deploys with `SOLID_QUEUE_IN_PUMA=true` (jobs double-execute).