monkrb 0.17.0 → 0.19.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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +133 -0
  3. data/README.md +6 -5
  4. data/exe/monk +45 -7
  5. data/lib/monk/jobs/adapters/pg.rb +272 -0
  6. data/lib/monk/jobs/args.rb +43 -0
  7. data/lib/monk/jobs/claim.rb +7 -0
  8. data/lib/monk/jobs/errors.rb +29 -0
  9. data/lib/monk/jobs/job.rb +117 -0
  10. data/lib/monk/jobs/runtime.rb +239 -0
  11. data/lib/monk/jobs/worker.rb +94 -0
  12. data/lib/monk/jobs.rb +229 -0
  13. data/lib/monk/live/client/monk_live.js +2 -2
  14. data/lib/monk/live/client/protocol.js +5 -0
  15. data/lib/monk/live.rb +10 -0
  16. data/lib/monk/mail/errors.rb +7 -0
  17. data/lib/monk/mail/later.rb +53 -0
  18. data/lib/monk/persistence/pg.rb +21 -1
  19. data/lib/monk/scaffold.rb +369 -70
  20. data/lib/monk/templates/auth/app/mailers/app_mailer.rb +22 -0
  21. data/lib/monk/templates/auth/config/auth.rb +7 -20
  22. data/lib/monk/templates/base/app/app.rb +9 -0
  23. data/lib/monk/templates/base/app/broadcasts/.keep +0 -0
  24. data/lib/monk/templates/base/app/helpers/.keep +0 -0
  25. data/lib/monk/templates/base/app/jobs/.keep +0 -0
  26. data/lib/monk/templates/base/app/mailers/.keep +0 -0
  27. data/lib/monk/templates/base/app/models/.keep +0 -0
  28. data/lib/monk/templates/base/app/presenters/.keep +0 -0
  29. data/lib/monk/templates/base/bin/websocket_server +6 -0
  30. data/lib/monk/templates/base/config/load.rb +18 -0
  31. data/lib/monk/templates/base/config.ru +3 -12
  32. data/lib/monk/templates/base/public/js/app.js +1 -1
  33. data/lib/monk/templates/jobs/app/jobs/hello_job.rb +9 -0
  34. data/lib/monk/templates/jobs/app/jobs/send_login_link.rb +28 -0
  35. data/lib/monk/templates/jobs/bin/jobs +18 -0
  36. data/lib/monk/templates/jobs/config/jobs.rb +15 -0
  37. data/lib/monk/templates/jobs/db/migrate/00000000000002_create_jobs_tables.down.sql +3 -0
  38. data/lib/monk/templates/jobs/db/migrate/00000000000002_create_jobs_tables.up.sql +48 -0
  39. data/lib/monk/templates/live/{config.ru → app/app.rb} +1 -6
  40. data/lib/monk/templates/live/{views → app/views}/index.erb +2 -2
  41. data/lib/monk/templates/live/bin/websocket_server +14 -2
  42. data/lib/monk/templates/live/config/live.rb +1 -1
  43. data/lib/monk/templates/live/config/live_pg.rb +1 -1
  44. data/lib/monk/templates/mail/config/mail.rb +2 -2
  45. data/lib/monk/templates/postgres/Dockerfile +3 -1
  46. data/lib/monk/templates/postgres/bin/console +4 -2
  47. data/lib/monk/version.rb +1 -1
  48. data/lib/monk/websocket/errors.rb +11 -0
  49. data/lib/monk/websocket/listeners.rb +23 -0
  50. data/lib/monk/websocket/pg_fanout.rb +55 -20
  51. data/lib/monk/websocket/redis_fanout.rb +57 -14
  52. data/lib/monk/websocket/registry.rb +6 -0
  53. data/lib/monk/websocket/server.rb +77 -17
  54. metadata +32 -7
  55. /data/lib/monk/templates/auth/{views → app/views}/mail/magic_link.erb +0 -0
  56. /data/lib/monk/templates/base/{views → app/views}/index.erb +0 -0
  57. /data/lib/monk/templates/base/{views → app/views}/layouts/app.erb +0 -0
  58. /data/lib/monk/templates/live/{views → app/views}/live/_hits.erb +0 -0
data/lib/monk/scaffold.rb CHANGED
@@ -10,18 +10,26 @@ module Monk
10
10
  class Scaffold
11
11
  TEMPLATES_DIR = File.expand_path("templates", __dir__)
12
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
+
13
16
  BASE_FILES = {
14
17
  "Gemfile" => "base/Gemfile",
15
18
  "config.ru" => "base/config.ru",
16
19
  "config/settings.rb" => "base/config/settings.rb",
20
+ "config/load.rb" => "base/config/load.rb",
21
+ "app/app.rb" => "base/app/app.rb",
17
22
  ".ruby-version" => "base/.ruby-version",
18
23
  ".gitignore" => "base/.gitignore",
19
24
  ".dockerignore" => "base/.dockerignore",
20
25
  "Dockerfile" => "base/Dockerfile",
21
26
  "bin/server" => "base/bin/server",
22
27
  "bin/websocket_server" => "base/bin/websocket_server",
23
- "views/layouts/app.erb" => "base/views/layouts/app.erb",
24
- "views/index.erb" => "base/views/index.erb",
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"] },
25
33
  "public/css/app.css" => "base/public/css/app.css",
26
34
  "public/js/app.js" => "base/public/js/app.js",
27
35
  }.freeze
@@ -45,9 +53,11 @@ module Monk
45
53
  "config/auth.rb" => "auth/config/auth.rb",
46
54
  "db/migrate/00000000000001_create_auth_tables.up.sql" => "auth/db/migrate/00000000000001_create_auth_tables.up.sql",
47
55
  "db/migrate/00000000000001_create_auth_tables.down.sql" => "auth/db/migrate/00000000000001_create_auth_tables.down.sql",
48
- # The magic link's HTML part -- config/auth.rb's AppMailer::DELIVER
49
- # renders it and sends through Monk::Mail (MAIL_FILES, implied by --auth).
50
- "views/mail/magic_link.erb" => "auth/views/mail/magic_link.erb",
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",
51
61
  }.freeze
52
62
 
53
63
  # --mail, or implied by --auth: Monk::Mail's config. Its Gemfile line
@@ -56,22 +66,57 @@ module Monk
56
66
  "config/mail.rb" => "mail/config/mail.rb",
57
67
  }.freeze
58
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
+
59
104
  # --live: Monk::Live's demo (a counter whose open tabs update when
60
105
  # another request changes it). These replace three base files outright
61
- # rather than patching them -- a live config.ru and index are different
62
- # files, not one line of diff -- and add two. config.ru's first line stays
63
- # the settings require, so --postgres/--auth wiring still finds it.
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.
64
109
  LIVE_OVERRIDES = {
65
- "config.ru" => "live/config.ru",
110
+ "app/app.rb" => "live/app/app.rb",
66
111
  "bin/websocket_server" => "live/bin/websocket_server",
67
- "views/index.erb" => "live/views/index.erb",
112
+ "app/views/index.erb" => "live/app/views/index.erb",
68
113
  }.freeze
69
114
 
70
115
  # config/live.rb itself isn't here -- #write_live! picks between
71
116
  # LIVE_CONFIG_TEMPLATES[@live_transport] instead, since which one gets
72
117
  # written depends on --redis vs --postgres, not a fixed mapping.
73
118
  LIVE_FILES = {
74
- "views/live/_hits.erb" => "live/views/live/_hits.erb",
119
+ "app/views/live/_hits.erb" => "live/app/views/live/_hits.erb",
75
120
  }.freeze
76
121
 
77
122
  LIVE_CONFIG_TEMPLATES = {
@@ -89,7 +134,7 @@ module Monk
89
134
  <script type="module" src="<%= asset_path "/js/monk_live/monk_live.js" %>"></script>
90
135
  HTML
91
136
 
92
- EXECUTABLE_FILES = %w[bin/server bin/websocket_server bin/console bin/setup_db bin/migrate].freeze
137
+ EXECUTABLE_FILES = %w[bin/server bin/websocket_server bin/console bin/setup_db bin/migrate bin/jobs].freeze
93
138
 
94
139
  # auth: implies postgres -- Monk::Auth is Postgres-only (lib/monk/auth.rb
95
140
  # subclasses Monk::Persistence::Pg::Model), so there's no combination
@@ -119,10 +164,15 @@ module Monk
119
164
  #
120
165
  # mail: Monk::Mail on its own, no Postgres needed. auth: implies it, the
121
166
  # same way it implies postgres: a magic link has to reach someone.
122
- def initialize(dir, postgres: false, auth: false, redis: false, live: false, mail: false)
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)
123
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 }
124
173
  @auth = auth
125
- @postgres = postgres || auth
174
+ @jobs = jobs
175
+ @postgres = postgres || auth || jobs
126
176
  @mail = mail || auth
127
177
  @live = live
128
178
 
@@ -144,6 +194,29 @@ module Monk
144
194
  end
145
195
  end
146
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
+
147
220
  def write!
148
221
  raise Monk::ScaffoldExistsError, "#{@dir} already exists" if File.exist?(@dir)
149
222
 
@@ -165,7 +238,14 @@ module Monk
165
238
  append_gemfile_extra("mail/Gemfile.extra")
166
239
  end
167
240
 
168
- wire_config_ru! if @postgres || @mail
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
169
249
  append_gemfile_extra("redis/Gemfile.extra") if @redis
170
250
 
171
251
  # --postgres, --redis and --mail are the flags with anything worth
@@ -184,8 +264,42 @@ module Monk
184
264
  write_setup_md!
185
265
  end
186
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
+
187
272
  private
188
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
+
189
303
  def write_live!
190
304
  write_file("config/live.rb", LIVE_CONFIG_TEMPLATES.fetch(@live_transport))
191
305
  LIVE_FILES.each { |relative, template| write_file(relative, template) }
@@ -203,7 +317,7 @@ module Monk
203
317
  end
204
318
 
205
319
  def add_live_to_layout!
206
- path = File.join(@dir, "views/layouts/app.erb")
320
+ path = File.join(@dir, "app/views/layouts/app.erb")
207
321
  content = File.read(path)
208
322
  raise "layout wiring failed: </head> not found" unless content.include?(" </head>\n")
209
323
 
@@ -233,31 +347,49 @@ module Monk
233
347
  File.write(path, content.sub(DOTENV_COMMENTED_LINE, DOTENV_LINE))
234
348
  end
235
349
 
236
- # config.ru ships in BASE_FILES unconditionally (it has to -- it's the
237
- # only rackup entrypoint), so --postgres/--auth can't gate whether it
238
- # exists, only what it requires. Unlike BASE_FILES/POSTGRES_FILES/
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/
239
353
  # AUTH_FILES, this is a post-write edit rather than a verbatim copy --
240
- # the alternative (a second, fuller config.ru template per flag
241
- # combination) would duplicate the whole file for one line of diff.
242
- def wire_config_ru!
243
- path = File.join(@dir, "config.ru")
244
- settings_require = %(require_relative "config/settings"\n)
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)
245
359
  requires = +""
246
360
  if @postgres
247
- target = @auth ? "config/auth" : "config/persistence" # config/auth.rb itself require_relative "persistence"
361
+ target = @auth ? "auth" : "persistence" # config/auth.rb itself require_relative "persistence"
248
362
  requires << "require_relative \"#{target}\"\n"
249
363
  end
250
- # config/mail.rb from config.ru only, never from config/auth.rb:
364
+ # config/mail.rb from config/load.rb only, never from config/auth.rb:
251
365
  # bin/websocket_server loads config/auth.rb too and never sends mail,
252
366
  # so it shouldn't need MAIL_URL to boot.
253
- requires << "require_relative \"config/mail\"\n" if @mail
367
+ requires << "require_relative \"mail\"\n" if @mail
368
+ requires << "require_relative \"jobs\"\n" if @jobs
369
+ requires << "require_relative \"live\"\n" if @live
254
370
 
255
371
  content = File.read(path)
256
- raise "config.ru wiring failed: #{settings_require.inspect} not found" unless content.include?(settings_require)
372
+ raise "config/load.rb wiring failed: #{settings_require.inspect} not found" unless content.include?(settings_require)
257
373
 
258
374
  File.write(path, content.sub(settings_require, "#{settings_require}#{requires}"))
259
375
  end
260
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
+
261
393
  # .env/.env.test/.env.example are the other deliberate exception to
262
394
  # "templates are static files, copied verbatim" (see the class comment
263
395
  # above): DB_NAME needs this app's own directory name in it -- the one
@@ -289,12 +421,25 @@ module Monk
289
421
  example << %(MAIL_FROM="#{app_name} <no-reply@example.com>")
290
422
  end
291
423
 
292
- # REDIS_URL is deliberately absent from .env.test -- only a test that
293
- # actually exercises RedisFanout needs it, unlike DB_NAME/AUTH_SECRET
294
- # which every test touching persistence/auth needs.
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.
295
439
  if @redis
296
440
  dev << "REDIS_URL=redis://localhost:6379/0"
297
441
  example << "REDIS_URL=redis://localhost:6379/0"
442
+ test << "REDIS_URL=redis://localhost:6379/0" if live_over_redis?
298
443
  end
299
444
 
300
445
  write_lines(".env", dev)
@@ -350,17 +495,21 @@ module Monk
350
495
 
351
496
  - `config/live.rb` -- the Redis wiring (`Monk::WebSocket::RedisFanout`)
352
497
  and the subscribe rules (nothing is allowed unless a rule says so).
353
- - `config.ru` -- `POST /hit` changes state and calls `Monk::Live.patch`.
354
- - `views/index.erb` -- `live_topic "hits"` marks what to subscribe to.
355
- - `views/live/_hits.erb` -- the fragment that gets pushed. Partials
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
356
501
  used this way see only their locals (`locals[:hits]`), never
357
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.
358
506
  - `public/js/monk_live/` -- the browser runtime, copied from the gem.
359
507
  The layout points at it and at `LIVE_WS_URL` (default
360
508
  `ws://localhost:9293`; use `wss://` in production).
361
509
 
362
510
  `WS_ALLOWED_ORIGINS` (default `http://localhost:9292`) must list the
363
511
  origin your pages are served from, or the browser's socket is refused.
512
+ #{live_auth_setup_paragraph}
364
513
  MARKDOWN
365
514
  end
366
515
 
@@ -387,18 +536,21 @@ module Monk
387
536
 
388
537
  - `config/live.rb` -- the Postgres wiring (`Monk::WebSocket::PgFanout`)
389
538
  and the subscribe rules (nothing is allowed unless a rule says so).
390
- - `config.ru` -- `POST /hit` changes state and calls `Monk::Live.patch`.
391
- - `views/index.erb` -- `live_topic "hits"` marks what to subscribe to.
392
- - `views/live/_hits.erb` -- the fragment that gets pushed. Partials
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
393
542
  used this way see only their locals (`locals[:hits]`), never
394
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.
395
547
  - `public/js/monk_live/` -- the browser runtime, copied from the gem.
396
548
  The layout points at it and at `LIVE_WS_URL` (default
397
549
  `ws://localhost:9293`; use `wss://` in production).
398
550
 
399
551
  `WS_ALLOWED_ORIGINS` (default `http://localhost:9292`) must list the
400
552
  origin your pages are served from, or the browser's socket is refused.
401
-
553
+ #{live_auth_setup_paragraph}
402
554
  A single broadcast is capped at just under 8000 bytes
403
555
  (`Monk::WebSocket::PgFanout::MAX_NOTIFY_PAYLOAD_BYTES`, Postgres's own
404
556
  `NOTIFY` limit) -- far more than this demo ever sends, but worth
@@ -453,8 +605,7 @@ module Monk
453
605
 
454
606
  ENV["MONK_ENV"] ||= "test"
455
607
  #{base_test_dotenv_lines}
456
- require "minitest/autorun"
457
- require_relative "../config/settings"#{%(\nrequire_relative "../config/mail") if @mail}
608
+ #{test_helper_app_lines}
458
609
  ```
459
610
 
460
611
  **Rakefile**:
@@ -470,22 +621,7 @@ module Monk
470
621
  task default: :test
471
622
  ```
472
623
 
473
- **test/settings_test.rb** -- `config.ru`'s `class App` lives inline in a
474
- rackup file, not a plain `.rb` a test could `require_relative`, so this
475
- starts with what's actually requirable standalone. Extract `App` into
476
- its own file (`require_relative`d from both `config.ru` and
477
- `test/test_helper.rb`) once there's real app behavior worth testing
478
- against requests:
479
-
480
- ```ruby
481
- require_relative "test_helper"
482
-
483
- class SettingsTest < Minitest::Test
484
- def test_monk_env_reads_as_test
485
- assert_equal "test", Monk::Settings[:monk_env]
486
- end
487
- end
488
- ```
624
+ #{app_test_sample}
489
625
 
490
626
  Then:
491
627
 
@@ -526,8 +662,9 @@ module Monk
526
662
  <<~MARKDOWN
527
663
  # Setting up #{app_name} (dev, then test)
528
664
 
529
- `config.ru`, `.env`, and `.env.test` are already wired up by `monk new`
530
- -- `config.ru` requires `config/#{@auth ? "auth" : "persistence"}` before `class App`, and
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
531
668
  `.env`/`.env.test` are pre-filled with a database name derived from
532
669
  this project's directory (`#{app_name}_development` / `#{app_name}_test`), not the
533
670
  generic `app_development` fallback baked into `config/persistence.rb`
@@ -589,8 +726,8 @@ module Monk
589
726
  ```bash
590
727
  bin/server # HTTP app on :9292
591
728
  bin/websocket_server # WS chat process on :9293, in another terminal
592
- ```
593
- #{auth_or_redis_confirmation_note}#{mail_setup_note}
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}
594
731
  ## Test environment
595
732
 
596
733
  ### 1. Create the test database
@@ -625,7 +762,7 @@ module Monk
625
762
 
626
763
  **test/test_helper.rb** -- loads `.env.test` explicitly (not the default
627
764
  dotenv-in-`config/settings.rb` path, which only loads plain `.env`), then
628
- wires up the app config the same way `config.ru` does:
765
+ loads and boots the app the same way `config.ru` does:
629
766
 
630
767
  ```ruby
631
768
  $LOAD_PATH.unshift(File.expand_path("..", __dir__))
@@ -635,9 +772,7 @@ module Monk
635
772
  require "dotenv"
636
773
  Dotenv.load(File.expand_path(".env.test", __dir__ + "/.."))
637
774
 
638
- require "minitest/autorun"
639
- require_relative "../config/settings"
640
- require_relative "../config/#{@auth ? "auth" : "persistence"}"#{%(\nrequire_relative "../config/mail") if @mail}
775
+ #{test_helper_app_lines}
641
776
  ```
642
777
 
643
778
  **Rakefile**:
@@ -664,6 +799,8 @@ module Monk
664
799
  end
665
800
  ```
666
801
 
802
+ #{app_test_sample}
803
+ #{jobs_test_sample}
667
804
  Then:
668
805
 
669
806
  ```bash
@@ -681,6 +818,7 @@ module Monk
681
818
  end
682
819
 
683
820
  def auth_or_redis_confirmation_note
821
+ return live_auth_confirmation_note if @live
684
822
  return "" unless @auth || @redis
685
823
 
686
824
  flags = [("authenticate: true" if @auth), ("redis fan-out: on" if @redis)].compact.join(", ")
@@ -694,6 +832,34 @@ module Monk
694
832
  "#{visible} visible to it -- that confirms everything's actually wired up.\n"
695
833
  end
696
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
+
697
863
  # Interpolated into a squiggly heredoc, so no leading indentation of
698
864
  # its own (interpolated text isn't dedented).
699
865
  def mail_setup_note
@@ -714,12 +880,107 @@ module Monk
714
880
  MARKDOWN
715
881
  end
716
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
+
717
977
  # The no-Postgres test helper normally has nothing to load from
718
- # .env.test -- but with --mail it has MAIL_URL=log://, which tests need:
719
- # they boot outside development, where an unset MAIL_URL raises.
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.
720
981
  # Interpolated into a squiggly heredoc, so no indentation of its own.
721
982
  def base_test_dotenv_lines
722
- return "" unless @mail
983
+ return "" unless @mail || live_over_redis?
723
984
 
724
985
  %(\nrequire "dotenv"\nDotenv.load(File.expand_path(".env.test", __dir__ + "/.."))\n)
725
986
  end
@@ -727,8 +988,8 @@ module Monk
727
988
  def auth_mail_sentence
728
989
  return "" unless @auth
729
990
 
730
- " `config/auth.rb`'s `AppMailer::DELIVER` sends each login link through it " \
731
- "(HTML part: `views/mail/magic_link.erb`)."
991
+ " `AppMailer::MAGIC_LINK` (`app/mailers/app_mailer.rb`) sends each login link through it " \
992
+ "(HTML part: `app/views/mail/magic_link.erb`)."
732
993
  end
733
994
 
734
995
  def sample_test_body
@@ -745,13 +1006,51 @@ module Monk
745
1006
  <<~RUBY.chomp.gsub(/^/, " ")
746
1007
  def test_connects_to_the_test_database
747
1008
  Monk::Persistence::Pg.checkout(:primary) do |conn|
748
- assert_equal "1", conn.exec("SELECT 1").getvalue(0, 0)
1009
+ assert_equal 1, conn.exec("SELECT 1").getvalue(0, 0)
749
1010
  end
750
1011
  end
751
1012
  RUBY
752
1013
  end
753
1014
  end
754
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
+
755
1054
  def write_file(relative_path, template_path, executable: false)
756
1055
  destination = File.join(@dir, relative_path)
757
1056
  FileUtils.mkdir_p(File.dirname(destination))