monkrb 0.19.0 → 0.21.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 (127) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +166 -0
  3. data/README.md +7 -6
  4. data/exe/monk +3 -181
  5. data/lib/monk/assets.rb +39 -9
  6. data/lib/monk/auth/errors.rb +3 -0
  7. data/lib/monk/auth/helpers.rb +11 -0
  8. data/lib/monk/auth/send_login_link.rb +36 -0
  9. data/lib/monk/auth.rb +34 -5
  10. data/lib/monk/base.rb +20 -0
  11. data/lib/monk/bin.rb +107 -0
  12. data/lib/monk/cli.rb +277 -0
  13. data/lib/monk/context.rb +17 -0
  14. data/lib/monk/errors.rb +0 -6
  15. data/lib/monk/generator/definition.rb +153 -0
  16. data/lib/monk/generator/result.rb +279 -0
  17. data/lib/monk/generator/run.rb +449 -0
  18. data/lib/monk/generator.rb +48 -0
  19. data/lib/monk/generators/auth.rb +29 -0
  20. data/lib/monk/generators/base.rb +19 -0
  21. data/lib/monk/generators/jobs.rb +21 -0
  22. data/lib/monk/generators/live.rb +26 -0
  23. data/lib/monk/generators/mail.rb +24 -0
  24. data/lib/monk/generators/postgres.rb +23 -0
  25. data/lib/monk/generators/redis.rb +20 -0
  26. data/lib/monk/generators/websocket.rb +24 -0
  27. data/lib/monk/generators.rb +7 -0
  28. data/lib/monk/jobs/adapters/pg.rb +3 -1
  29. data/lib/monk/jobs.rb +7 -0
  30. data/lib/monk/live/helpers.rb +13 -0
  31. data/lib/monk/live/policy.rb +39 -4
  32. data/lib/monk/live/session.rb +7 -0
  33. data/lib/monk/live.rb +17 -4
  34. data/lib/monk/log.rb +10 -1
  35. data/lib/monk/mail/deliver_later.rb +54 -0
  36. data/lib/monk/mail/later.rb +3 -51
  37. data/lib/monk/mail.rb +4 -0
  38. data/lib/monk/persistence/errors.rb +32 -0
  39. data/lib/monk/persistence/pg.rb +92 -1
  40. data/lib/monk/persistence/pool.rb +513 -0
  41. data/lib/monk/persistence.rb +196 -7
  42. data/lib/monk/templates/auth/agents.md +29 -0
  43. data/lib/monk/templates/auth/app/mailers/{app_mailer.rb → auth_mailer.rb} +5 -5
  44. data/lib/monk/templates/auth/app/routes/auth.rb +48 -0
  45. data/lib/monk/templates/auth/app/views/auth/login.erb +25 -0
  46. data/lib/monk/templates/auth/config/auth.rb +14 -8
  47. data/lib/monk/templates/auth/setup.md +37 -0
  48. data/lib/monk/templates/auth/test/auth_test.rb +17 -0
  49. data/lib/monk/templates/base/CLAUDE.md +1 -0
  50. data/lib/monk/templates/base/Dockerfile +4 -3
  51. data/lib/monk/templates/base/Gemfile +7 -2
  52. data/lib/monk/templates/base/Rakefile +8 -0
  53. data/lib/monk/templates/base/agents.md +41 -0
  54. data/lib/monk/templates/base/app/app.rb +5 -0
  55. data/lib/monk/templates/base/app/routes/.keep +0 -0
  56. data/lib/monk/templates/base/app/views/index.erb +1 -1
  57. data/lib/monk/templates/base/app/views/layouts/app.erb +2 -0
  58. data/lib/monk/templates/base/config/load.rb +8 -0
  59. data/lib/monk/templates/base/config/settings.rb +4 -5
  60. data/lib/monk/templates/base/setup.md +22 -0
  61. data/lib/monk/templates/base/test/app_test.rb +12 -0
  62. data/lib/monk/templates/base/test/test_helper.rb +18 -0
  63. data/lib/monk/templates/jobs/agents.md +25 -0
  64. data/lib/monk/templates/jobs/app/routes/jobs.rb +9 -0
  65. data/lib/monk/templates/jobs/bin/jobs +19 -13
  66. data/lib/monk/templates/jobs/setup.md +30 -0
  67. data/lib/monk/templates/jobs/test/jobs_test.rb +16 -0
  68. data/lib/monk/templates/live/agents.md +25 -0
  69. data/lib/monk/templates/live/app/broadcasts/greeting.rb +13 -0
  70. data/lib/monk/templates/live/app/routes/demo_live.rb +21 -0
  71. data/lib/monk/templates/live/app/routes/live.rb +10 -0
  72. data/lib/monk/templates/live/app/views/demo/live.erb +18 -0
  73. data/lib/monk/templates/live/app/views/live/_greeting.erb +1 -0
  74. data/lib/monk/templates/live/config/live.rb +30 -32
  75. data/lib/monk/templates/live/config/live_without_demo.rb +38 -0
  76. data/lib/monk/templates/live/setup.md +39 -0
  77. data/lib/monk/templates/live/test/live_test.rb +22 -0
  78. data/lib/monk/templates/mail/agents.md +22 -0
  79. data/lib/monk/templates/mail/app/mailers/app_mailer.rb +14 -0
  80. data/lib/monk/templates/mail/app/routes/mail.rb +12 -0
  81. data/lib/monk/templates/mail/app/views/mail/welcome.erb +1 -0
  82. data/lib/monk/templates/mail/config/mail.rb +4 -4
  83. data/lib/monk/templates/mail/setup.md +32 -0
  84. data/lib/monk/templates/mail/test/mail_test.rb +13 -0
  85. data/lib/monk/templates/postgres/agents.md +25 -0
  86. data/lib/monk/templates/postgres/app/routes/postgres.rb +22 -0
  87. data/lib/monk/templates/postgres/bin/console +3 -1
  88. data/lib/monk/templates/postgres/bin/migrate +23 -17
  89. data/lib/monk/templates/postgres/bin/setup_db +11 -5
  90. data/lib/monk/templates/postgres/config/persistence.rb +1 -0
  91. data/lib/monk/templates/postgres/db/migrate/.keep +0 -0
  92. data/lib/monk/templates/postgres/setup.md +38 -0
  93. data/lib/monk/templates/postgres/test/persistence_test.rb +11 -0
  94. data/lib/monk/templates/redis/agents.md +18 -0
  95. data/lib/monk/templates/redis/app/routes/redis.rb +15 -0
  96. data/lib/monk/templates/redis/config/redis.rb +13 -0
  97. data/lib/monk/templates/redis/setup.md +20 -0
  98. data/lib/monk/templates/redis/test/redis_test.rb +12 -0
  99. data/lib/monk/templates/websocket/agents.md +25 -0
  100. data/lib/monk/templates/websocket/app/sockets/chat.rb +18 -0
  101. data/lib/monk/templates/websocket/bin/websocket_server +58 -0
  102. data/lib/monk/templates/websocket/config/websocket_postgres.rb +24 -0
  103. data/lib/monk/templates/websocket/config/websocket_redis.rb +21 -0
  104. data/lib/monk/templates/websocket/setup.md +51 -0
  105. data/lib/monk/templates/websocket/test/websocket_test.rb +21 -0
  106. data/lib/monk/version.rb +1 -1
  107. data/lib/monk/websocket/connection.rb +25 -6
  108. data/lib/monk/websocket/listeners.rb +24 -0
  109. data/lib/monk/websocket/pg_fanout.rb +143 -20
  110. data/lib/monk/websocket/redis_fanout.rb +78 -29
  111. data/lib/monk/websocket/registry.rb +24 -0
  112. data/lib/monk/websocket/server.rb +57 -16
  113. data/lib/monk/websocket.rb +2 -0
  114. data/lib/monk.rb +10 -0
  115. metadata +83 -14
  116. data/lib/monk/scaffold.rb +0 -1061
  117. data/lib/monk/templates/base/bin/websocket_server +0 -68
  118. data/lib/monk/templates/jobs/app/jobs/send_login_link.rb +0 -28
  119. data/lib/monk/templates/live/app/app.rb +0 -22
  120. data/lib/monk/templates/live/app/views/index.erb +0 -18
  121. data/lib/monk/templates/live/bin/websocket_server +0 -42
  122. data/lib/monk/templates/live/config/live_pg.rb +0 -60
  123. data/lib/monk/templates/mail/Gemfile.extra +0 -1
  124. data/lib/monk/templates/postgres/Dockerfile +0 -32
  125. data/lib/monk/templates/postgres/Gemfile.extra +0 -2
  126. data/lib/monk/templates/redis/Gemfile.extra +0 -1
  127. /data/lib/monk/templates/live/app/views/{live → demo}/_hits.erb +0 -0
data/lib/monk/scaffold.rb DELETED
@@ -1,1061 +0,0 @@
1
- require "fileutils"
2
-
3
- require_relative "errors"
4
-
5
- module Monk
6
- # Writes a new Monk project's skeleton to disk. Templates are static
7
- # files, copied verbatim -- nothing here needs the project's own name
8
- # substituted in, so there's no templating engine involved. See
9
- # docs/history/plan-init.md for the full design.
10
- class Scaffold
11
- TEMPLATES_DIR = File.expand_path("templates", __dir__)
12
-
13
- # The role directories under app/, in config/load.rb's load order.
14
- APP_ROLES = %w[models presenters helpers mailers broadcasts jobs].freeze
15
-
16
- BASE_FILES = {
17
- "Gemfile" => "base/Gemfile",
18
- "config.ru" => "base/config.ru",
19
- "config/settings.rb" => "base/config/settings.rb",
20
- "config/load.rb" => "base/config/load.rb",
21
- "app/app.rb" => "base/app/app.rb",
22
- ".ruby-version" => "base/.ruby-version",
23
- ".gitignore" => "base/.gitignore",
24
- ".dockerignore" => "base/.dockerignore",
25
- "Dockerfile" => "base/Dockerfile",
26
- "bin/server" => "base/bin/server",
27
- "bin/websocket_server" => "base/bin/websocket_server",
28
- "app/views/layouts/app.erb" => "base/app/views/layouts/app.erb",
29
- "app/views/index.erb" => "base/app/views/index.erb",
30
- # Every role directory, whatever the flags (docs/adr/0015): the tree
31
- # shows where each kind of code goes. Flags add files next to these.
32
- **APP_ROLES.to_h { |role| ["app/#{role}/.keep", "base/app/#{role}/.keep"] },
33
- "public/css/app.css" => "base/public/css/app.css",
34
- "public/js/app.js" => "base/public/js/app.js",
35
- }.freeze
36
-
37
- POSTGRES_FILES = {
38
- "config/persistence.rb" => "postgres/config/persistence.rb",
39
- "bin/console" => "postgres/bin/console",
40
- "bin/setup_db" => "postgres/bin/setup_db",
41
- "bin/migrate" => "postgres/bin/migrate",
42
- }.freeze
43
-
44
- # The pg gem's native extension needs libpq -- the base Dockerfile has
45
- # no system packages at all (kino is a precompiled platform gem), so
46
- # --postgres/--auth swap in a Dockerfile that adds libpq-dev/libpq5
47
- # instead of patching one image for every combination.
48
- POSTGRES_OVERRIDES = {
49
- "Dockerfile" => "postgres/Dockerfile",
50
- }.freeze
51
-
52
- AUTH_FILES = {
53
- "config/auth.rb" => "auth/config/auth.rb",
54
- "db/migrate/00000000000001_create_auth_tables.up.sql" => "auth/db/migrate/00000000000001_create_auth_tables.up.sql",
55
- "db/migrate/00000000000001_create_auth_tables.down.sql" => "auth/db/migrate/00000000000001_create_auth_tables.down.sql",
56
- # The magic link's sender, Monk::Auth's deliver: hook -- config/auth.rb
57
- # requires it -- and the HTML part it renders and sends through
58
- # Monk::Mail (MAIL_FILES, implied by --auth).
59
- "app/mailers/app_mailer.rb" => "auth/app/mailers/app_mailer.rb",
60
- "app/views/mail/magic_link.erb" => "auth/app/views/mail/magic_link.erb",
61
- }.freeze
62
-
63
- # --mail, or implied by --auth: Monk::Mail's config. Its Gemfile line
64
- # (net-smtp) and MAIL_* env vars are added by #write! / #write_env_files!.
65
- MAIL_FILES = {
66
- "config/mail.rb" => "mail/config/mail.rb",
67
- }.freeze
68
-
69
- # --jobs: Monk::Jobs. The migration is the canonical one under
70
- # templates/jobs/db/migrate -- the same file Monk's own tests apply --
71
- # numbered after auth's so the two sort in order when both are present.
72
- JOBS_FILES = {
73
- "config/jobs.rb" => "jobs/config/jobs.rb",
74
- "app/jobs/hello_job.rb" => "jobs/app/jobs/hello_job.rb",
75
- "bin/jobs" => "jobs/bin/jobs",
76
- "db/migrate/00000000000002_create_jobs_tables.up.sql" => "jobs/db/migrate/00000000000002_create_jobs_tables.up.sql",
77
- "db/migrate/00000000000002_create_jobs_tables.down.sql" =>
78
- "jobs/db/migrate/00000000000002_create_jobs_tables.down.sql",
79
- }.freeze
80
-
81
- # --auth --jobs: the magic link is created and sent inside a job, so the
82
- # raw token is never stored (docs/adr/0014).
83
- JOBS_AUTH_FILES = {
84
- "app/jobs/send_login_link.rb" => "jobs/app/jobs/send_login_link.rb",
85
- }.freeze
86
-
87
- # --mail --jobs (or --auth --jobs): Monk::Mail.deliver_later, loaded in
88
- # config/jobs.rb right after Monk::Jobs itself.
89
- JOBS_REQUIRE_ANCHOR = %(require "monk/jobs"\n).freeze
90
- JOBS_MAIL_REQUIRE = %(require "monk/mail/later"\n).freeze
91
-
92
- # --jobs's demo route, added right after this line in app/app.rb (the
93
- # base and the --live one both end their routes with it), so the
94
- # round trip -- enqueue from a request, run in bin/jobs -- works out of
95
- # the box.
96
- JOBS_ROUTE_ANCHOR = %( get("/api/hello") { json(message: "hello from monk") }\n).freeze
97
- JOBS_ROUTE = <<~RUBY.gsub(/^(?!$)/, " ").freeze
98
-
99
- # Enqueues the demo job (app/jobs/hello_job.rb) for bin/jobs to run:
100
- # curl -X POST "http://localhost:9292/jobs/hello?name=Ann"
101
- post("/jobs/hello") { json(enqueued: HelloJob.enqueue(params[:name] || "world")) }
102
- RUBY
103
-
104
- # --live: Monk::Live's demo (a counter whose open tabs update when
105
- # another request changes it). These replace three base files outright
106
- # rather than patching them -- a live app and index are different
107
- # files, not one line of diff -- and add two. config.ru stays the base
108
- # one: config/load.rb is where --live's config gets wired in.
109
- LIVE_OVERRIDES = {
110
- "app/app.rb" => "live/app/app.rb",
111
- "bin/websocket_server" => "live/bin/websocket_server",
112
- "app/views/index.erb" => "live/app/views/index.erb",
113
- }.freeze
114
-
115
- # config/live.rb itself isn't here -- #write_live! picks between
116
- # LIVE_CONFIG_TEMPLATES[@live_transport] instead, since which one gets
117
- # written depends on --redis vs --postgres, not a fixed mapping.
118
- LIVE_FILES = {
119
- "app/views/live/_hits.erb" => "live/app/views/live/_hits.erb",
120
- }.freeze
121
-
122
- LIVE_CONFIG_TEMPLATES = {
123
- redis: "live/config/live.rb",
124
- postgres: "live/config/live_pg.rb",
125
- }.freeze
126
-
127
- # Where the client runtime lands in the app's public root (an app serves
128
- # it like any other static file; its files import each other by relative
129
- # path, so they stay together in one directory).
130
- LIVE_CLIENT_DIR = "public/js/monk_live".freeze
131
-
132
- LIVE_HEAD = <<~HTML.gsub(/^/, " ").freeze
133
- <meta name="monk-live-url" content="<%= settings[:live_ws_url] %>">
134
- <script type="module" src="<%= asset_path "/js/monk_live/monk_live.js" %>"></script>
135
- HTML
136
-
137
- EXECUTABLE_FILES = %w[bin/server bin/websocket_server bin/console bin/setup_db bin/migrate bin/jobs].freeze
138
-
139
- # auth: implies postgres -- Monk::Auth is Postgres-only (lib/monk/auth.rb
140
- # subclasses Monk::Persistence::Pg::Model), so there's no combination
141
- # where an app gets Auth without also getting the persistence scaffold.
142
- #
143
- # redis: is unrelated to both -- it doesn't imply, and isn't implied by,
144
- # postgres or auth. Unlike them it adds no files of its own, just a
145
- # Gemfile line: bin/websocket_server (always present, see BASE_FILES
146
- # above -- WebSocket needs no external service, so unlike Postgres/Redis
147
- # it isn't gated behind a flag at all) checks ENV["REDIS_URL"] at boot
148
- # and only then requires "redis", so the gem has to already be in the
149
- # bundle for that to work.
150
- #
151
- # live: bin/server and bin/websocket_server are always separate
152
- # processes, so *something* has to carry a publish between them --
153
- # --redis (Monk::WebSocket::RedisFanout) or --postgres
154
- # (Monk::WebSocket::PgFanout, reusing --postgres's own DB_* settings,
155
- # no Redis to run). --live no longer silently defaults to one: with
156
- # neither flag, there's no way to tell whether this app has Postgres
157
- # available at all, so guessing would be as likely to hand back a
158
- # template that can't connect as one that can. --live --redis wins
159
- # over --postgres if both are passed (an explicit ask beats an
160
- # implied default); --live --postgres alone (or via --auth, which
161
- # implies --postgres) picks PgFanout; --live with neither raises.
162
- # See docs/design/live-pg-fanout.md / docs/history/plan-live-pg-fanout.md
163
- # for the design this decision comes from.
164
- #
165
- # mail: Monk::Mail on its own, no Postgres needed. auth: implies it, the
166
- # same way it implies postgres: a magic link has to reach someone.
167
- #
168
- # jobs: implies postgres, since the queue lives there, and nothing else.
169
- def initialize(dir, postgres: false, auth: false, redis: false, live: false, mail: false, jobs: false)
170
- @dir = dir
171
- # As passed, before anything is implied -- #summary reports the difference.
172
- @requested = { postgres: postgres, auth: auth, mail: mail, jobs: jobs, redis: redis, live: live }
173
- @auth = auth
174
- @jobs = jobs
175
- @postgres = postgres || auth || jobs
176
- @mail = mail || auth
177
- @live = live
178
-
179
- if @live
180
- @redis = redis || false
181
- @live_transport = if redis
182
- :redis
183
- elsif @postgres
184
- :postgres
185
- else
186
- raise Monk::AmbiguousLiveTransportError,
187
- "monk new --live needs --redis or --postgres to pick Monk::Live's cross-process " \
188
- "transport (bin/server and bin/websocket_server are always separate processes) -- " \
189
- "pass one explicitly, e.g. `--live --postgres` (docs/guides/live.md's " \
190
- "\"Without Redis\" section has the tradeoffs)"
191
- end
192
- else
193
- @redis = redis
194
- end
195
- end
196
-
197
- # The flags as resolved, for `monk new` to print: which flags were
198
- # given, which ones they implied and why, and which combinations change
199
- # what gets generated. The rule behind it, stated in `monk --help`: a
200
- # flag is implied when there's only one right answer (--auth can only
201
- # use Postgres), and required when there's a real choice (--live's
202
- # transport).
203
- def summary
204
- given = FLAG_ORDER.select { |flag| @requested[flag] }
205
- return ["Flags: none (the base skeleton)."] if given.empty?
206
-
207
- implied = implied_flags
208
- flags = given.map { |flag| "--#{flag}" }.join(" ")
209
- lines = implied.empty? ? ["Flags: #{flags}."] : ["Flags: #{flags}, which also turned on:"]
210
- implied.each do |flag, sources|
211
- lines << " #{"--#{flag}".ljust(12)}(needed by #{sources.map { |source| "--#{source}" }.join(", ")})"
212
- end
213
-
214
- together = combinations
215
- lines << "Together they also generate:" unless together.empty?
216
- width = together.map { |pair, _| pair.length }.max
217
- lines.concat(together.map { |pair, what| " #{pair.ljust(width)} #{what}" })
218
- end
219
-
220
- def write!
221
- raise Monk::ScaffoldExistsError, "#{@dir} already exists" if File.exist?(@dir)
222
-
223
- FileUtils.mkdir_p(@dir)
224
- base_files = BASE_FILES
225
- base_files = base_files.merge(LIVE_OVERRIDES) if @live
226
- base_files = base_files.merge(POSTGRES_OVERRIDES) if @postgres
227
- base_files.each { |relative, template| write_file(relative, template, executable: EXECUTABLE_FILES.include?(relative)) }
228
- write_live! if @live
229
-
230
- if @postgres
231
- POSTGRES_FILES.each { |relative, template| write_file(relative, template, executable: EXECUTABLE_FILES.include?(relative)) }
232
- FileUtils.mkdir_p(File.join(@dir, "db/migrate"))
233
- append_gemfile_extra("postgres/Gemfile.extra")
234
- end
235
-
236
- if @mail
237
- MAIL_FILES.each { |relative, template| write_file(relative, template) }
238
- append_gemfile_extra("mail/Gemfile.extra")
239
- end
240
-
241
- if @jobs
242
- JOBS_FILES.each { |relative, template| write_file(relative, template, executable: EXECUTABLE_FILES.include?(relative)) }
243
- JOBS_AUTH_FILES.each { |relative, template| write_file(relative, template) } if @auth
244
- add_jobs_route!
245
- add_deliver_later! if @mail
246
- end
247
-
248
- wire_load! if @postgres || @mail || @live
249
- append_gemfile_extra("redis/Gemfile.extra") if @redis
250
-
251
- # --postgres, --redis and --mail are the flags with anything worth
252
- # putting in an env file (a database, a REDIS_URL, MAIL_*) -- the
253
- # base skeleton alone has nothing to configure this way.
254
- if @postgres || @redis || @mail
255
- write_env_files!
256
- uncomment_dotenv!
257
- end
258
-
259
- AUTH_FILES.each { |relative, template| write_file(relative, template) } if @auth
260
-
261
- # Every combination of flags gets a SETUP.md, not just --postgres --
262
- # even the base skeleton has a dev step (bin/server) and an
263
- # unscaffolded test framework to set up.
264
- write_setup_md!
265
- end
266
-
267
- FLAG_ORDER = %i[postgres auth mail jobs redis live].freeze
268
-
269
- # Only these two imply anything, and only one flag each can need.
270
- IMPLIED_BY = { postgres: %i[auth jobs], mail: %i[auth] }.freeze
271
-
272
- private
273
-
274
- def implied_flags
275
- resolved = { postgres: @postgres, mail: @mail }
276
- IMPLIED_BY.filter_map do |flag, sources|
277
- next if @requested[flag] || !resolved[flag]
278
-
279
- [flag, sources.select { |source| @requested[source] }]
280
- end
281
- end
282
-
283
- def combinations
284
- pairs = []
285
- pairs << ["--auth + --jobs", "app/jobs/send_login_link.rb: login links are sent from a job"] if @auth && @jobs
286
- if @mail && @jobs
287
- pairs << ["--mail + --jobs",
288
- "config/jobs.rb loads Monk::Mail.deliver_later; JOBS_QUEUES serves mailers first",]
289
- end
290
- pairs << live_combination if @live
291
- pairs
292
- end
293
-
294
- def live_combination
295
- if @live_transport == :redis
296
- both = @postgres ? "; --redis wins over --postgres" : ""
297
- ["--live + --redis", "config/live.rb fans out over Redis (Monk::WebSocket::RedisFanout#{both})"]
298
- else
299
- ["--live + --postgres", "config/live.rb fans out over Postgres (Monk::WebSocket::PgFanout)"]
300
- end
301
- end
302
-
303
- def write_live!
304
- write_file("config/live.rb", LIVE_CONFIG_TEMPLATES.fetch(@live_transport))
305
- LIVE_FILES.each { |relative, template| write_file(relative, template) }
306
- copy_live_client!
307
- add_live_to_layout!
308
- end
309
-
310
- # The runtime ships inside the gem (Monk::Live.client_dir); copied, not
311
- # duplicated under templates/, so there is one source of truth.
312
- def copy_live_client!
313
- source = File.join(__dir__, "live/client")
314
- target = File.join(@dir, LIVE_CLIENT_DIR)
315
- FileUtils.mkdir_p(target)
316
- FileUtils.cp(Dir.children(source).map { |name| File.join(source, name) }, target)
317
- end
318
-
319
- def add_live_to_layout!
320
- path = File.join(@dir, "app/views/layouts/app.erb")
321
- content = File.read(path)
322
- raise "layout wiring failed: </head> not found" unless content.include?(" </head>\n")
323
-
324
- File.write(path, content.sub(" </head>\n", "#{LIVE_HEAD} </head>\n"))
325
- end
326
-
327
- def append_gemfile_extra(template_path)
328
- extra = File.read(File.join(TEMPLATES_DIR, template_path))
329
- File.write(File.join(@dir, "Gemfile"), "\n#{extra}", mode: "a")
330
- end
331
-
332
- # --postgres/--redis write a real .env (see write_env_files!) -- if the
333
- # dotenv gem stays commented out, as it does in the base skeleton,
334
- # config/settings.rb's `require "dotenv/load"` never runs, so nothing
335
- # ever actually loads them: bin/setup_db (or bin/websocket_server's
336
- # REDIS_URL check) silently falls back to config/persistence.rb's own
337
- # ENV.fetch defaults, or no REDIS_URL at all, instead of the
338
- # app-specific values .env was written to provide.
339
- DOTENV_COMMENTED_LINE = %(# gem "dotenv" # uncomment to load a local .env file (config/settings.rb)\n).freeze
340
- DOTENV_LINE = %(gem "dotenv" # loads .env/.env.test -- see config/settings.rb\n).freeze
341
-
342
- def uncomment_dotenv!
343
- path = File.join(@dir, "Gemfile")
344
- content = File.read(path)
345
- raise "Gemfile wiring failed: #{DOTENV_COMMENTED_LINE.inspect} not found" unless content.include?(DOTENV_COMMENTED_LINE)
346
-
347
- File.write(path, content.sub(DOTENV_COMMENTED_LINE, DOTENV_LINE))
348
- end
349
-
350
- # config/load.rb ships in BASE_FILES unconditionally (config.ru and the
351
- # tests require it), so the flags can't gate whether it exists, only
352
- # which module configs it requires. Unlike BASE_FILES/POSTGRES_FILES/
353
- # AUTH_FILES, this is a post-write edit rather than a verbatim copy --
354
- # the alternative (a second, fuller load.rb template per flag
355
- # combination) would duplicate the whole file for a few lines of diff.
356
- def wire_load!
357
- path = File.join(@dir, "config/load.rb")
358
- settings_require = %(require_relative "settings"\n)
359
- requires = +""
360
- if @postgres
361
- target = @auth ? "auth" : "persistence" # config/auth.rb itself require_relative "persistence"
362
- requires << "require_relative \"#{target}\"\n"
363
- end
364
- # config/mail.rb from config/load.rb only, never from config/auth.rb:
365
- # bin/websocket_server loads config/auth.rb too and never sends mail,
366
- # so it shouldn't need MAIL_URL to boot.
367
- requires << "require_relative \"mail\"\n" if @mail
368
- requires << "require_relative \"jobs\"\n" if @jobs
369
- requires << "require_relative \"live\"\n" if @live
370
-
371
- content = File.read(path)
372
- raise "config/load.rb wiring failed: #{settings_require.inspect} not found" unless content.include?(settings_require)
373
-
374
- File.write(path, content.sub(settings_require, "#{settings_require}#{requires}"))
375
- end
376
-
377
- def add_jobs_route!
378
- path = File.join(@dir, "app/app.rb")
379
- content = File.read(path)
380
- raise "app/app.rb wiring failed: #{JOBS_ROUTE_ANCHOR.inspect} not found" unless content.include?(JOBS_ROUTE_ANCHOR)
381
-
382
- File.write(path, content.sub(JOBS_ROUTE_ANCHOR, "#{JOBS_ROUTE_ANCHOR}#{JOBS_ROUTE}"))
383
- end
384
-
385
- def add_deliver_later!
386
- path = File.join(@dir, "config/jobs.rb")
387
- content = File.read(path)
388
- raise "config/jobs.rb wiring failed: #{JOBS_REQUIRE_ANCHOR.inspect} not found" unless content.include?(JOBS_REQUIRE_ANCHOR)
389
-
390
- File.write(path, content.sub(JOBS_REQUIRE_ANCHOR, "#{JOBS_REQUIRE_ANCHOR}#{JOBS_MAIL_REQUIRE}"))
391
- end
392
-
393
- # .env/.env.test/.env.example are the other deliberate exception to
394
- # "templates are static files, copied verbatim" (see the class comment
395
- # above): DB_NAME needs this app's own directory name in it -- the one
396
- # piece of scaffold-wide content that's actually per-project -- so
397
- # these are composed here instead of read from disk. .env/.env.test are
398
- # gitignored (see base/.gitignore); .env.example is the tracked
399
- # placeholder that file explicitly carves out.
400
- def write_env_files!
401
- app_name = File.basename(@dir)
402
-
403
- dev = @postgres ? pg_env_lines("#{app_name}_development") : []
404
- test = @postgres ? pg_env_lines("#{app_name}_test") : []
405
- example = @postgres ? pg_env_lines("#{app_name}_development") : []
406
-
407
- if @auth
408
- dev << "AUTH_SECRET=change-me-dev-secret"
409
- test << "AUTH_SECRET=change-me-test-secret"
410
- example << "AUTH_SECRET=change-me"
411
- end
412
-
413
- if @mail
414
- # MAIL_URL is left out of .env on purpose: unset in development
415
- # means log://, the message printed to the console. Tests boot
416
- # outside development, where an unset one raises, hence log:// there.
417
- dev << %(MAIL_FROM="#{app_name} <no-reply@localhost>")
418
- test << "MAIL_URL=log://"
419
- test << %(MAIL_FROM="#{app_name} <no-reply@localhost>")
420
- example << "MAIL_URL=smtp://user:password@smtp.example.com:587"
421
- example << %(MAIL_FROM="#{app_name} <no-reply@example.com>")
422
- end
423
-
424
- # bin/jobs's settings. Not in .env.test: tests run jobs with
425
- # Monk::Jobs.drain!, never a job process.
426
- # With mail, the mailers queue is served first, so a backlog of other
427
- # work never delays a login email (docs/adr/0014).
428
- if @jobs
429
- queues = @mail ? "mailers,default" : "default"
430
- [dev, example].each { |lines| lines.push("JOBS_WORKERS=2", "JOBS_QUEUES=#{queues}") }
431
- end
432
-
433
- # REDIS_URL is absent from .env.test for --redis alone -- only a test
434
- # that actually exercises RedisFanout needs it. --live --redis is
435
- # different: config/live.rb raises without it, and the test helper
436
- # loads config/live.rb through config/load.rb. Building the fanout
437
- # connects to nothing, so tests still need no Redis running unless
438
- # one publishes.
439
- if @redis
440
- dev << "REDIS_URL=redis://localhost:6379/0"
441
- example << "REDIS_URL=redis://localhost:6379/0"
442
- test << "REDIS_URL=redis://localhost:6379/0" if live_over_redis?
443
- end
444
-
445
- write_lines(".env", dev)
446
- write_lines(".env.example", example)
447
- # --redis alone (no --postgres) has nothing to put in .env.test --
448
- # skip it rather than write an empty, pointless file.
449
- write_lines(".env.test", test) unless test.empty?
450
- end
451
-
452
- def pg_env_lines(dbname)
453
- [
454
- "DB_HOST=127.0.0.1",
455
- "DB_PORT=5432",
456
- "DB_USER=postgres",
457
- "DB_PASSWORD=postgres",
458
- "DB_NAME=#{dbname}",
459
- ]
460
- end
461
-
462
- def write_lines(relative_path, lines)
463
- File.write(File.join(@dir, relative_path), "#{lines.join("\n")}\n")
464
- end
465
-
466
- def write_setup_md!
467
- File.write(File.join(@dir, "SETUP.md"), setup_md_content)
468
- File.write(File.join(@dir, "SETUP.md"), live_setup_md_content, mode: "a") if @live
469
- end
470
-
471
- def live_setup_md_content
472
- @live_transport == :postgres ? live_setup_md_content_postgres : live_setup_md_content_redis
473
- end
474
-
475
- def live_setup_md_content_redis
476
- <<~MARKDOWN
477
-
478
- ## Live updates (Monk::Live)
479
-
480
- `monk new --live --redis` wired a demo: a counter whose open tabs
481
- update by themselves. It needs three things running, each in its own
482
- terminal:
483
-
484
- ```bash
485
- docker run --rm -d -p 6379:6379 --name #{File.basename(@dir)}_redis redis:7 # skip if one is already up
486
- bin/server # the HTTP app on :9292, which publishes updates
487
- bin/websocket_server # the sockets on :9293, which deliver them
488
- ```
489
-
490
- Open http://localhost:9292 in two tabs and press the button in one.
491
-
492
- `bin/server` and `bin/websocket_server` are separate processes, so Redis
493
- (`REDIS_URL`, already in `.env`) is what carries an update from one to
494
- the other. Where things are:
495
-
496
- - `config/live.rb` -- the Redis wiring (`Monk::WebSocket::RedisFanout`)
497
- and the subscribe rules (nothing is allowed unless a rule says so).
498
- - `app/app.rb` -- `POST /hit` changes state and calls `Monk::Live.patch`.
499
- - `app/views/index.erb` -- `live_topic "hits"` marks what to subscribe to.
500
- - `app/views/live/_hits.erb` -- the fragment that gets pushed. Partials
501
- used this way see only their locals (`locals[:hits]`), never
502
- `params` or the session.
503
- - `bin/websocket_server` -- calls `Monk::Live.listen!` at boot: the
504
- only process that listens for updates. `bin/server`#{@jobs ? " and `bin/jobs`" : ""} only
505
- publish.
506
- - `public/js/monk_live/` -- the browser runtime, copied from the gem.
507
- The layout points at it and at `LIVE_WS_URL` (default
508
- `ws://localhost:9293`; use `wss://` in production).
509
-
510
- `WS_ALLOWED_ORIGINS` (default `http://localhost:9292`) must list the
511
- origin your pages are served from, or the browser's socket is refused.
512
- #{live_auth_setup_paragraph}
513
- MARKDOWN
514
- end
515
-
516
- def live_setup_md_content_postgres
517
- <<~MARKDOWN
518
-
519
- ## Live updates (Monk::Live)
520
-
521
- `monk new --live --postgres` wired a demo: a counter whose open tabs
522
- update by themselves. It needs two things running, each in its own
523
- terminal -- no Redis, since Postgres `LISTEN`/`NOTIFY` carries the
524
- update instead:
525
-
526
- ```bash
527
- bin/server # the HTTP app on :9292, which publishes updates
528
- bin/websocket_server # the sockets on :9293, which deliver them
529
- ```
530
-
531
- Open http://localhost:9292 in two tabs and press the button in one.
532
-
533
- `bin/server` and `bin/websocket_server` are separate processes, so
534
- Postgres `LISTEN`/`NOTIFY` (the same `DB_*` settings already in `.env`)
535
- is what carries an update from one to the other. Where things are:
536
-
537
- - `config/live.rb` -- the Postgres wiring (`Monk::WebSocket::PgFanout`)
538
- and the subscribe rules (nothing is allowed unless a rule says so).
539
- - `app/app.rb` -- `POST /hit` changes state and calls `Monk::Live.patch`.
540
- - `app/views/index.erb` -- `live_topic "hits"` marks what to subscribe to.
541
- - `app/views/live/_hits.erb` -- the fragment that gets pushed. Partials
542
- used this way see only their locals (`locals[:hits]`), never
543
- `params` or the session.
544
- - `bin/websocket_server` -- calls `Monk::Live.listen!` at boot: the
545
- only process that listens for updates. `bin/server`#{@jobs ? " and `bin/jobs`" : ""} only
546
- publish.
547
- - `public/js/monk_live/` -- the browser runtime, copied from the gem.
548
- The layout points at it and at `LIVE_WS_URL` (default
549
- `ws://localhost:9293`; use `wss://` in production).
550
-
551
- `WS_ALLOWED_ORIGINS` (default `http://localhost:9292`) must list the
552
- origin your pages are served from, or the browser's socket is refused.
553
- #{live_auth_setup_paragraph}
554
- A single broadcast is capped at just under 8000 bytes
555
- (`Monk::WebSocket::PgFanout::MAX_NOTIFY_PAYLOAD_BYTES`, Postgres's own
556
- `NOTIFY` limit) -- far more than this demo ever sends, but worth
557
- knowing before pushing much larger partials this way. Regenerate with
558
- `--redis` instead of `--postgres` if that becomes a real constraint.
559
- MARKDOWN
560
- end
561
-
562
- def setup_md_content
563
- @postgres ? postgres_setup_md_content : base_setup_md_content
564
- end
565
-
566
- # No Postgres, so no database/containers/migrations -- but still a
567
- # real dev-then-test walkthrough: bin/server needs nothing external,
568
- # and "no test framework scaffolded" is just as true here as it is
569
- # with --postgres.
570
- def base_setup_md_content
571
- app_name = File.basename(@dir)
572
-
573
- <<~MARKDOWN
574
- # Setting up #{app_name} (dev, then test)
575
-
576
- #{base_setup_md_intro(app_name)}
577
-
578
- ## Dev environment, first run
579
-
580
- ```bash
581
- bundle install
582
- bin/server # HTTP app on :9292 -> http://localhost:9292/hello
583
- bin/websocket_server # WS chat process on :9293, in another terminal
584
- ```
585
- #{redis_only_note}#{mail_setup_note}
586
- ## Test environment
587
-
588
- `monk new` scaffolds no test framework at all -- this is the minimum to
589
- get `bundle exec rake test` working, using Minitest (matches `monk`'s
590
- own suite):
591
-
592
- **Gemfile** -- add:
593
-
594
- ```ruby
595
- group :test do
596
- gem "minitest"
597
- gem "rake"
598
- end
599
- ```
600
-
601
- **test/test_helper.rb**:
602
-
603
- ```ruby
604
- $LOAD_PATH.unshift(File.expand_path("..", __dir__))
605
-
606
- ENV["MONK_ENV"] ||= "test"
607
- #{base_test_dotenv_lines}
608
- #{test_helper_app_lines}
609
- ```
610
-
611
- **Rakefile**:
612
-
613
- ```ruby
614
- require "rake/testtask"
615
-
616
- Rake::TestTask.new do |t|
617
- t.libs << "test"
618
- t.pattern = "test/**/*_test.rb"
619
- end
620
-
621
- task default: :test
622
- ```
623
-
624
- #{app_test_sample}
625
-
626
- Then:
627
-
628
- ```bash
629
- bundle install
630
- bundle exec rake test
631
- ```
632
- MARKDOWN
633
- end
634
-
635
- def base_setup_md_intro(app_name)
636
- unless @redis
637
- return "No external services are scaffolded here (`monk new #{app_name}`, no " \
638
- "`--postgres`/`--auth`/`--redis`) -- `bin/server`/`bin/websocket_server` need " \
639
- "nothing external to run."
640
- end
641
-
642
- "No database is scaffolded here (`monk new #{app_name} --redis`, no `--postgres`/" \
643
- "`--auth`) -- `bin/server` needs nothing external, but `bin/websocket_server`'s " \
644
- "cross-process fan-out needs a reachable Redis once `REDIS_URL` (already set in " \
645
- "the generated `.env`) is visible to it."
646
- end
647
-
648
- def redis_only_note
649
- return "" unless @redis
650
-
651
- app_name = File.basename(@dir)
652
- "\n`.env` already sets `REDIS_URL=redis://localhost:6379/0`, turning on " \
653
- "`bin/websocket_server`'s cross-process fan-out -- unset (or missing entirely), it " \
654
- "runs in-process only. Check `docker ps` first in case a Redis container is already " \
655
- "running from another project; otherwise start one: `docker run --rm -d -p 6379:6379 " \
656
- "--name #{app_name}_redis redis:7`.\n"
657
- end
658
-
659
- def postgres_setup_md_content
660
- app_name = File.basename(@dir)
661
-
662
- <<~MARKDOWN
663
- # Setting up #{app_name} (dev, then test)
664
-
665
- `config/load.rb`, `.env`, and `.env.test` are already wired up by `monk new`
666
- -- `config/load.rb` requires `config/#{@auth ? "auth" : "persistence"}` (and every other config
667
- you enabled), then loads `app/`, and
668
- `.env`/`.env.test` are pre-filled with a database name derived from
669
- this project's directory (`#{app_name}_development` / `#{app_name}_test`), not the
670
- generic `app_development` fallback baked into `config/persistence.rb`
671
- itself. Adjust `DB_HOST`/`DB_USER`/`DB_PASSWORD` in both files if your
672
- local Postgres doesn't use the `postgres`/`postgres` defaults.
673
-
674
- `MONK_ENV` and the database name are two separate, unlinked knobs --
675
- setting `MONK_ENV=test` does not by itself change which database you
676
- connect to. Only `DB_NAME` (here, via `.env`/`.env.test`) does that.
677
-
678
- ## Dev environment, first run
679
-
680
- ```bash
681
- bundle install
682
- ```
683
-
684
- ### 1. Start Postgres#{@redis ? " and Redis" : ""}
685
-
686
- Check first whether a Postgres#{@redis ? "/Redis" : ""} container is already running from another
687
- project -- starting a second one bound to the same host port fails with
688
- `port is already allocated`:
689
-
690
- ```bash
691
- docker ps
692
- ```
693
-
694
- If something's already listening on 5432#{@redis ? "/6379" : ""}, reuse it instead of starting a new
695
- one -- point `.env`/`.env.test`'s `DB_HOST`/`DB_PORT`#{@redis ? "/`REDIS_URL`" : ""} at it, and use its
696
- actual `DB_USER`/`DB_PASSWORD` rather than the generated defaults.
697
- Otherwise, start #{@redis ? "fresh containers" : "a fresh container"}:
698
-
699
- ```bash
700
- docker run --rm -d -p 5432:5432 -e POSTGRES_PASSWORD=postgres --name #{app_name}_pg postgres:16
701
- #{"docker run --rm -d -p 6379:6379 --name #{app_name}_redis redis:7\n" if @redis}```
702
-
703
- ### 2. Create the dev database
704
-
705
- Plain `createdb` connects over the local Unix socket by default, which a
706
- Docker container never provides -- use `-h`/`-p` to force a TCP
707
- connection, or run `createdb` from inside the container instead to avoid
708
- passing a password on the command line:
709
-
710
- ```bash
711
- # via TCP (adjust -h/-p/-U to match whatever's actually running):
712
- PGPASSWORD=postgres createdb -h 127.0.0.1 -p 5432 -U postgres #{app_name}_development
713
-
714
- # or, from inside an existing Postgres container, as the postgres user:
715
- docker exec -u postgres <container_name> createdb #{app_name}_development
716
- ```
717
-
718
- ### 3. Run migrations
719
-
720
- ```bash
721
- bin/setup_db
722
- ```
723
-
724
- ### 4. Run it
725
-
726
- ```bash
727
- bin/server # HTTP app on :9292
728
- bin/websocket_server # WS chat process on :9293, in another terminal
729
- #{"bin/jobs # background jobs (app/jobs/), in a third terminal\n" if @jobs}```
730
- #{auth_or_redis_confirmation_note}#{mail_setup_note}#{jobs_setup_note}
731
- ## Test environment
732
-
733
- ### 1. Create the test database
734
-
735
- ```bash
736
- PGPASSWORD=postgres createdb -h 127.0.0.1 -p 5432 -U postgres #{app_name}_test
737
-
738
- # or, from inside an existing Postgres container, as the postgres user:
739
- docker exec -u postgres <container_name> createdb #{app_name}_test
740
- ```
741
-
742
- ### 2. Migrate the test database
743
-
744
- ```bash
745
- DB_NAME=#{app_name}_test bin/setup_db
746
- ```
747
-
748
- ### 3. Add a test framework and run it
749
-
750
- `monk new` scaffolds no test framework at all -- this is the minimum to
751
- get `bundle exec rake test` working, using Minitest (matches `monk`'s
752
- own suite):
753
-
754
- **Gemfile** -- add:
755
-
756
- ```ruby
757
- group :test do
758
- gem "minitest"
759
- gem "rake"
760
- end
761
- ```
762
-
763
- **test/test_helper.rb** -- loads `.env.test` explicitly (not the default
764
- dotenv-in-`config/settings.rb` path, which only loads plain `.env`), then
765
- loads and boots the app the same way `config.ru` does:
766
-
767
- ```ruby
768
- $LOAD_PATH.unshift(File.expand_path("..", __dir__))
769
-
770
- ENV["MONK_ENV"] ||= "test"
771
-
772
- require "dotenv"
773
- Dotenv.load(File.expand_path(".env.test", __dir__ + "/.."))
774
-
775
- #{test_helper_app_lines}
776
- ```
777
-
778
- **Rakefile**:
779
-
780
- ```ruby
781
- require "rake/testtask"
782
-
783
- Rake::TestTask.new do |t|
784
- t.libs << "test"
785
- t.pattern = "test/**/*_test.rb"
786
- end
787
-
788
- task default: :test
789
- ```
790
-
791
- **test/persistence_test.rb** -- a real smoke test, not just a
792
- connectivity check:
793
-
794
- ```ruby
795
- require_relative "test_helper"
796
-
797
- class PersistenceTest < Minitest::Test
798
- #{sample_test_body}
799
- end
800
- ```
801
-
802
- #{app_test_sample}
803
- #{jobs_test_sample}
804
- Then:
805
-
806
- ```bash
807
- bundle install
808
- bundle exec rake test
809
- ```
810
-
811
- `test/test_helper.rb` sets `MONK_ENV=test` itself (so `Monk.env.test?`
812
- reads correctly if the app ever branches on it) and loads `.env.test` for
813
- the actual connection details -- but per the note at the top, those are
814
- two separate knobs: `MONK_ENV` doesn't affect `DB_NAME` on its own,
815
- `.env.test`'s `DB_NAME=#{app_name}_test` is what actually points tests at
816
- the right database.
817
- MARKDOWN
818
- end
819
-
820
- def auth_or_redis_confirmation_note
821
- return live_auth_confirmation_note if @live
822
- return "" unless @auth || @redis
823
-
824
- flags = [("authenticate: true" if @auth), ("redis fan-out: on" if @redis)].compact.join(", ")
825
- visible = if @auth && @redis
826
- "both `AUTH_SECRET` and `REDIS_URL` are"
827
- else
828
- @auth ? "`AUTH_SECRET` is" : "`REDIS_URL` is"
829
- end
830
-
831
- "\n`bin/websocket_server`'s startup line should print `#{flags}` once " \
832
- "#{visible} visible to it -- that confirms everything's actually wired up.\n"
833
- end
834
-
835
- # Who gets which topics once there are users. Interpolated into a
836
- # squiggly heredoc, so no indentation of its own.
837
- def live_auth_setup_paragraph
838
- return "" unless @auth
839
-
840
- <<~MARKDOWN
841
-
842
- **Visitors and logged-in users.** A logged-in user's socket carries
843
- their identity (the session cookie reaches `:9293` too); a visitor's
844
- comes in anonymous. Each topic's rule in `config/live.rb` decides:
845
- `anonymous: true` opens it to visitors (the demo's `hits`), and a
846
- rule without it is for logged-in users only, e.g. one topic per user.
847
- `monk new` writes no login routes: see `docs/guides/auth.md` in the
848
- monk repo, and `docs/guides/live.md`, "Who may subscribe".
849
- MARKDOWN
850
- end
851
-
852
- # The --live bin/websocket_server prints only `authenticate:`, and with
853
- # --auth it's :optional (a visitor's socket is anonymous, not refused).
854
- def live_auth_confirmation_note
855
- return "" unless @auth
856
-
857
- "\n`bin/websocket_server`'s startup line should print `authenticate: optional` once " \
858
- "`AUTH_SECRET` is visible to it: a logged-in user's socket carries their identity, and a " \
859
- "visitor's comes in anonymous, so it gets only the topics `config/live.rb` opens with " \
860
- "`anonymous: true`.\n"
861
- end
862
-
863
- # Interpolated into a squiggly heredoc, so no leading indentation of
864
- # its own (interpolated text isn't dedented).
865
- def mail_setup_note
866
- return "" unless @mail
867
-
868
- <<~MARKDOWN
869
-
870
- ### Email
871
-
872
- `config/mail.rb` configures `Monk::Mail`; send with
873
- `Monk::Mail.deliver(to:, subject:, text:, html:)`.#{auth_mail_sentence} In
874
- development `MAIL_URL` is unset, so messages are printed to the
875
- console instead of sent. Outside
876
- development set `MAIL_URL` (e.g. `smtp://user:pass@smtp.provider.com:587`)
877
- and `MAIL_FROM` to a sender on a domain you've verified with your
878
- provider -- an unset `MAIL_URL` fails the boot there. Provider
879
- examples: `docs/guides/mail.md` in the monk repo.
880
- MARKDOWN
881
- end
882
-
883
- # Interpolated into a squiggly heredoc, so no indentation of its own.
884
- def jobs_setup_note
885
- return "" unless @jobs
886
-
887
- <<~MARKDOWN
888
-
889
- ### Background jobs
890
-
891
- `config/jobs.rb` configures `Monk::Jobs` on the app's own database --
892
- its tables come from `db/migrate/00000000000002_create_jobs_tables`,
893
- which `bin/setup_db` already applied. The job classes live in
894
- `app/jobs/`, which `config/load.rb` loads. With `bin/server` and
895
- `bin/jobs` both running, enqueue the demo job and watch it run:
896
-
897
- ```bash
898
- curl -X POST "http://localhost:9292/jobs/hello?name=Ann"
899
- tail -f log/development.log # ... INFO Hello, Ann, from a background job
900
- ```
901
-
902
- `JOBS_WORKERS` and `JOBS_QUEUES` (in `.env`) set how many worker Ractors
903
- `bin/jobs` runs and which queues they take jobs from, in order. A job
904
- may run more than once (its process was killed mid-job, say), so make
905
- each one safe to repeat. `TERM` or Ctrl-C lets the jobs in hand finish
906
- before `bin/jobs` exits.#{jobs_mail_note}#{jobs_auth_note}
907
- MARKDOWN
908
- end
909
-
910
- # Interpolated into a squiggly heredoc, so no indentation of its own.
911
- def jobs_mail_note
912
- return "" unless @mail
913
-
914
- <<~MARKDOWN.chomp
915
-
916
- To send an email from a job instead of the request, use
917
- `Monk::Mail.deliver_later` -- the same arguments as `deliver` (render
918
- HTML first with `Monk::Mail.render`), plus `wait:`/`at:`, or `conn:`
919
- to enqueue inside your own transaction. It goes on the `mailers`
920
- queue, which `JOBS_QUEUES` serves first. Temporary failures are
921
- retried; a refusal the mail server made final is not.
922
- MARKDOWN
923
- end
924
-
925
- # Interpolated into a squiggly heredoc, so no indentation of its own.
926
- def jobs_auth_note
927
- return "" unless @auth
928
-
929
- <<~MARKDOWN.chomp
930
-
931
- Magic links go through a job too, `app/jobs/send_login_link.rb`: it
932
- creates the token and sends the link inside the job, so the raw
933
- token is never stored, not even in the queue. Your login route,
934
- after its own per-email rate limit, just enqueues it:
935
-
936
- ```ruby
937
- post("/auth/request") do
938
- SendLoginLink.enqueue(params[:email])
939
- json(sent: true)
940
- end
941
- ```
942
-
943
- Don't point `config/auth.rb`'s `deliver:` at `deliver_later`: that
944
- would store the link, token included, in the queue until it's sent.
945
- MARKDOWN
946
- end
947
-
948
- # Interpolated into a squiggly heredoc, so no indentation of its own.
949
- def jobs_test_sample
950
- return "" unless @jobs
951
-
952
- <<~MARKDOWN
953
-
954
- **test/jobs_test.rb** -- `Monk::Jobs.drain!` runs every enqueued job
955
- right in the test (each in its own Ractor, as `bin/jobs` would, so a
956
- job that only works outside one fails here too), and
957
- `Monk::Jobs.clear!` empties the queue between tests:
958
-
959
- ```ruby
960
- require_relative "test_helper"
961
-
962
- class JobsTest < Minitest::Test
963
- def teardown
964
- Monk::Jobs.clear!
965
- end
966
-
967
- def test_the_demo_job_runs
968
- HelloJob.enqueue("test")
969
-
970
- assert_equal 1, Monk::Jobs.drain!
971
- end
972
- end
973
- ```
974
- MARKDOWN
975
- end
976
-
977
- # The no-Postgres test helper normally has nothing to load from
978
- # .env.test -- but with --mail it has MAIL_URL=log://, which tests need
979
- # (they boot outside development, where an unset MAIL_URL raises), and
980
- # with --live --redis it has REDIS_URL, which config/live.rb needs.
981
- # Interpolated into a squiggly heredoc, so no indentation of its own.
982
- def base_test_dotenv_lines
983
- return "" unless @mail || live_over_redis?
984
-
985
- %(\nrequire "dotenv"\nDotenv.load(File.expand_path(".env.test", __dir__ + "/.."))\n)
986
- end
987
-
988
- def auth_mail_sentence
989
- return "" unless @auth
990
-
991
- " `AppMailer::MAGIC_LINK` (`app/mailers/app_mailer.rb`) sends each login link through it " \
992
- "(HTML part: `app/views/mail/magic_link.erb`)."
993
- end
994
-
995
- def sample_test_body
996
- if @auth
997
- <<~RUBY.chomp.gsub(/^/, " ")
998
- def test_connects_to_the_test_database_and_sees_the_auth_tables
999
- Monk::Persistence::Pg.checkout(:primary) do |conn|
1000
- result = conn.exec("SELECT to_regclass('login_tokens') IS NOT NULL AS present")
1001
- assert_equal true, result[0]["present"]
1002
- end
1003
- end
1004
- RUBY
1005
- else
1006
- <<~RUBY.chomp.gsub(/^/, " ")
1007
- def test_connects_to_the_test_database
1008
- Monk::Persistence::Pg.checkout(:primary) do |conn|
1009
- assert_equal 1, conn.exec("SELECT 1").getvalue(0, 0)
1010
- end
1011
- end
1012
- RUBY
1013
- end
1014
- end
1015
-
1016
- def live_over_redis? = @live && @live_transport == :redis
1017
-
1018
- # The end of every SETUP.md test helper: the app loaded the way
1019
- # config.ru loads it (config/load.rb, then app/app.rb), booted once.
1020
- # Interpolated into a squiggly heredoc, so no indentation of its own.
1021
- def test_helper_app_lines
1022
- <<~RUBY.chomp
1023
- require "minitest/autorun"
1024
- require_relative "../config/load"
1025
- require_relative "../app/app"
1026
-
1027
- # Booted once, as config.ru does: request tests call APP.
1028
- APP = Monk.boot(App)
1029
- RUBY
1030
- end
1031
-
1032
- # A request through the booted app. Every generated app, --live's
1033
- # included, has GET /hello.
1034
- def app_test_sample
1035
- <<~MARKDOWN.chomp
1036
- **test/app_test.rb** -- a request through the booted app:
1037
-
1038
- ```ruby
1039
- require_relative "test_helper"
1040
- require "rack/mock_request"
1041
-
1042
- class AppTest < Minitest::Test
1043
- def test_hello
1044
- status, _headers, body = APP.call(Rack::MockRequest.env_for("/hello"))
1045
-
1046
- assert_equal 200, status
1047
- assert_equal "hello from monk", body.join
1048
- end
1049
- end
1050
- ```
1051
- MARKDOWN
1052
- end
1053
-
1054
- def write_file(relative_path, template_path, executable: false)
1055
- destination = File.join(@dir, relative_path)
1056
- FileUtils.mkdir_p(File.dirname(destination))
1057
- FileUtils.cp(File.join(TEMPLATES_DIR, template_path), destination)
1058
- File.chmod(0o755, destination) if executable
1059
- end
1060
- end
1061
- end