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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +133 -0
- data/README.md +6 -5
- data/exe/monk +45 -7
- data/lib/monk/jobs/adapters/pg.rb +272 -0
- data/lib/monk/jobs/args.rb +43 -0
- data/lib/monk/jobs/claim.rb +7 -0
- data/lib/monk/jobs/errors.rb +29 -0
- data/lib/monk/jobs/job.rb +117 -0
- data/lib/monk/jobs/runtime.rb +239 -0
- data/lib/monk/jobs/worker.rb +94 -0
- data/lib/monk/jobs.rb +229 -0
- data/lib/monk/live/client/monk_live.js +2 -2
- data/lib/monk/live/client/protocol.js +5 -0
- data/lib/monk/live.rb +10 -0
- data/lib/monk/mail/errors.rb +7 -0
- data/lib/monk/mail/later.rb +53 -0
- data/lib/monk/persistence/pg.rb +21 -1
- data/lib/monk/scaffold.rb +369 -70
- data/lib/monk/templates/auth/app/mailers/app_mailer.rb +22 -0
- data/lib/monk/templates/auth/config/auth.rb +7 -20
- data/lib/monk/templates/base/app/app.rb +9 -0
- data/lib/monk/templates/base/app/broadcasts/.keep +0 -0
- data/lib/monk/templates/base/app/helpers/.keep +0 -0
- data/lib/monk/templates/base/app/jobs/.keep +0 -0
- data/lib/monk/templates/base/app/mailers/.keep +0 -0
- data/lib/monk/templates/base/app/models/.keep +0 -0
- data/lib/monk/templates/base/app/presenters/.keep +0 -0
- data/lib/monk/templates/base/bin/websocket_server +6 -0
- data/lib/monk/templates/base/config/load.rb +18 -0
- data/lib/monk/templates/base/config.ru +3 -12
- data/lib/monk/templates/base/public/js/app.js +1 -1
- data/lib/monk/templates/jobs/app/jobs/hello_job.rb +9 -0
- data/lib/monk/templates/jobs/app/jobs/send_login_link.rb +28 -0
- data/lib/monk/templates/jobs/bin/jobs +18 -0
- data/lib/monk/templates/jobs/config/jobs.rb +15 -0
- data/lib/monk/templates/jobs/db/migrate/00000000000002_create_jobs_tables.down.sql +3 -0
- data/lib/monk/templates/jobs/db/migrate/00000000000002_create_jobs_tables.up.sql +48 -0
- data/lib/monk/templates/live/{config.ru → app/app.rb} +1 -6
- data/lib/monk/templates/live/{views → app/views}/index.erb +2 -2
- data/lib/monk/templates/live/bin/websocket_server +14 -2
- data/lib/monk/templates/live/config/live.rb +1 -1
- data/lib/monk/templates/live/config/live_pg.rb +1 -1
- data/lib/monk/templates/mail/config/mail.rb +2 -2
- data/lib/monk/templates/postgres/Dockerfile +3 -1
- data/lib/monk/templates/postgres/bin/console +4 -2
- data/lib/monk/version.rb +1 -1
- data/lib/monk/websocket/errors.rb +11 -0
- data/lib/monk/websocket/listeners.rb +23 -0
- data/lib/monk/websocket/pg_fanout.rb +55 -20
- data/lib/monk/websocket/redis_fanout.rb +57 -14
- data/lib/monk/websocket/registry.rb +6 -0
- data/lib/monk/websocket/server.rb +77 -17
- metadata +32 -7
- /data/lib/monk/templates/auth/{views → app/views}/mail/magic_link.erb +0 -0
- /data/lib/monk/templates/base/{views → app/views}/index.erb +0 -0
- /data/lib/monk/templates/base/{views → app/views}/layouts/app.erb +0 -0
- /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
|
|
49
|
-
#
|
|
50
|
-
|
|
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
|
|
62
|
-
# files, not one line of diff -- and add two. config.ru
|
|
63
|
-
#
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
-
@
|
|
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
|
-
|
|
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.
|
|
237
|
-
#
|
|
238
|
-
#
|
|
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
|
|
241
|
-
# combination) would duplicate the whole file for
|
|
242
|
-
def
|
|
243
|
-
path = File.join(@dir, "config.
|
|
244
|
-
settings_require = %(require_relative "
|
|
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 ? "
|
|
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.
|
|
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 \"
|
|
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.
|
|
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
|
-
#
|
|
293
|
-
#
|
|
294
|
-
#
|
|
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
|
-
- `
|
|
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
|
-
- `
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
530
|
-
-- `config.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
" `
|
|
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
|
|
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))
|