monkrb 0.20.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 (111) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +77 -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/bin.rb +107 -0
  11. data/lib/monk/cli.rb +277 -0
  12. data/lib/monk/context.rb +17 -0
  13. data/lib/monk/errors.rb +0 -6
  14. data/lib/monk/generator/definition.rb +153 -0
  15. data/lib/monk/generator/result.rb +279 -0
  16. data/lib/monk/generator/run.rb +449 -0
  17. data/lib/monk/generator.rb +48 -0
  18. data/lib/monk/generators/auth.rb +29 -0
  19. data/lib/monk/generators/base.rb +19 -0
  20. data/lib/monk/generators/jobs.rb +21 -0
  21. data/lib/monk/generators/live.rb +26 -0
  22. data/lib/monk/generators/mail.rb +24 -0
  23. data/lib/monk/generators/postgres.rb +23 -0
  24. data/lib/monk/generators/redis.rb +20 -0
  25. data/lib/monk/generators/websocket.rb +24 -0
  26. data/lib/monk/generators.rb +7 -0
  27. data/lib/monk/jobs.rb +7 -0
  28. data/lib/monk/live/helpers.rb +13 -0
  29. data/lib/monk/live.rb +12 -1
  30. data/lib/monk/log.rb +10 -1
  31. data/lib/monk/mail/deliver_later.rb +54 -0
  32. data/lib/monk/mail/later.rb +3 -51
  33. data/lib/monk/mail.rb +4 -0
  34. data/lib/monk/templates/auth/agents.md +29 -0
  35. data/lib/monk/templates/auth/app/mailers/{app_mailer.rb → auth_mailer.rb} +5 -5
  36. data/lib/monk/templates/auth/app/routes/auth.rb +48 -0
  37. data/lib/monk/templates/auth/app/views/auth/login.erb +25 -0
  38. data/lib/monk/templates/auth/config/auth.rb +8 -8
  39. data/lib/monk/templates/auth/setup.md +37 -0
  40. data/lib/monk/templates/auth/test/auth_test.rb +17 -0
  41. data/lib/monk/templates/base/CLAUDE.md +1 -0
  42. data/lib/monk/templates/base/Dockerfile +4 -3
  43. data/lib/monk/templates/base/Gemfile +7 -2
  44. data/lib/monk/templates/base/Rakefile +8 -0
  45. data/lib/monk/templates/base/agents.md +41 -0
  46. data/lib/monk/templates/base/app/app.rb +5 -0
  47. data/lib/monk/templates/base/app/routes/.keep +0 -0
  48. data/lib/monk/templates/base/app/views/index.erb +1 -1
  49. data/lib/monk/templates/base/app/views/layouts/app.erb +2 -0
  50. data/lib/monk/templates/base/config/load.rb +8 -0
  51. data/lib/monk/templates/base/config/settings.rb +4 -5
  52. data/lib/monk/templates/base/setup.md +22 -0
  53. data/lib/monk/templates/base/test/app_test.rb +12 -0
  54. data/lib/monk/templates/base/test/test_helper.rb +18 -0
  55. data/lib/monk/templates/jobs/agents.md +25 -0
  56. data/lib/monk/templates/jobs/app/routes/jobs.rb +9 -0
  57. data/lib/monk/templates/jobs/bin/jobs +19 -13
  58. data/lib/monk/templates/jobs/setup.md +30 -0
  59. data/lib/monk/templates/jobs/test/jobs_test.rb +16 -0
  60. data/lib/monk/templates/live/agents.md +25 -0
  61. data/lib/monk/templates/live/app/broadcasts/greeting.rb +13 -0
  62. data/lib/monk/templates/live/app/routes/demo_live.rb +21 -0
  63. data/lib/monk/templates/live/app/routes/live.rb +10 -0
  64. data/lib/monk/templates/live/app/views/demo/live.erb +18 -0
  65. data/lib/monk/templates/live/app/views/live/_greeting.erb +1 -0
  66. data/lib/monk/templates/live/config/live.rb +30 -32
  67. data/lib/monk/templates/live/config/live_without_demo.rb +38 -0
  68. data/lib/monk/templates/live/setup.md +39 -0
  69. data/lib/monk/templates/live/test/live_test.rb +22 -0
  70. data/lib/monk/templates/mail/agents.md +22 -0
  71. data/lib/monk/templates/mail/app/mailers/app_mailer.rb +14 -0
  72. data/lib/monk/templates/mail/app/routes/mail.rb +12 -0
  73. data/lib/monk/templates/mail/app/views/mail/welcome.erb +1 -0
  74. data/lib/monk/templates/mail/config/mail.rb +4 -4
  75. data/lib/monk/templates/mail/setup.md +32 -0
  76. data/lib/monk/templates/mail/test/mail_test.rb +13 -0
  77. data/lib/monk/templates/postgres/agents.md +25 -0
  78. data/lib/monk/templates/postgres/app/routes/postgres.rb +22 -0
  79. data/lib/monk/templates/postgres/bin/console +3 -1
  80. data/lib/monk/templates/postgres/bin/migrate +23 -17
  81. data/lib/monk/templates/postgres/bin/setup_db +11 -5
  82. data/lib/monk/templates/postgres/config/persistence.rb +1 -0
  83. data/lib/monk/templates/postgres/setup.md +38 -0
  84. data/lib/monk/templates/postgres/test/persistence_test.rb +11 -0
  85. data/lib/monk/templates/redis/agents.md +18 -0
  86. data/lib/monk/templates/redis/app/routes/redis.rb +15 -0
  87. data/lib/monk/templates/redis/config/redis.rb +13 -0
  88. data/lib/monk/templates/redis/setup.md +20 -0
  89. data/lib/monk/templates/redis/test/redis_test.rb +12 -0
  90. data/lib/monk/templates/websocket/agents.md +25 -0
  91. data/lib/monk/templates/websocket/app/sockets/chat.rb +18 -0
  92. data/lib/monk/templates/websocket/bin/websocket_server +58 -0
  93. data/lib/monk/templates/websocket/config/websocket_postgres.rb +24 -0
  94. data/lib/monk/templates/websocket/config/websocket_redis.rb +21 -0
  95. data/lib/monk/templates/websocket/setup.md +51 -0
  96. data/lib/monk/templates/websocket/test/websocket_test.rb +21 -0
  97. data/lib/monk/version.rb +1 -1
  98. data/lib/monk/websocket/server.rb +3 -1
  99. metadata +81 -14
  100. data/lib/monk/scaffold.rb +0 -1063
  101. data/lib/monk/templates/base/bin/websocket_server +0 -77
  102. data/lib/monk/templates/jobs/app/jobs/send_login_link.rb +0 -28
  103. data/lib/monk/templates/live/app/app.rb +0 -22
  104. data/lib/monk/templates/live/app/views/index.erb +0 -18
  105. data/lib/monk/templates/live/bin/websocket_server +0 -50
  106. data/lib/monk/templates/live/config/live_pg.rb +0 -60
  107. data/lib/monk/templates/mail/Gemfile.extra +0 -1
  108. data/lib/monk/templates/postgres/Dockerfile +0 -32
  109. data/lib/monk/templates/postgres/Gemfile.extra +0 -2
  110. data/lib/monk/templates/redis/Gemfile.extra +0 -1
  111. /data/lib/monk/templates/live/app/views/{live → demo}/_hits.erb +0 -0
@@ -0,0 +1,18 @@
1
+ ENV["MONK_ENV"] ||= "test"
2
+
3
+ # The test values the modules this app uses wrote (.env.test), loaded
4
+ # before config/settings.rb's .env, which doesn't override them. Only the
5
+ # database name and MONK_ENV differ from development, and they're two
6
+ # separate settings: MONK_ENV=test alone doesn't change DB_NAME.
7
+ env_test = File.expand_path("../.env.test", __dir__)
8
+ if File.exist?(env_test)
9
+ require "dotenv"
10
+ Dotenv.load(env_test)
11
+ end
12
+
13
+ require "minitest/autorun"
14
+ require_relative "../config/load"
15
+ require_relative "../app/app"
16
+
17
+ # Booted once, as config.ru does: request tests call APP.
18
+ APP = Monk.boot(App)
@@ -0,0 +1,25 @@
1
+ ## jobs
2
+
3
+ **Where:** `config/jobs.rb` (the queue lives in the `:primary` database),
4
+ job classes in `app/jobs/`, `bin/jobs` runs them.
5
+
6
+ **Main calls:** `class SendInvoice < Monk::Job` with
7
+ `def self.perform(id)`; `SendInvoice.enqueue(id)`, `wait: 300`, `at: time`,
8
+ or `conn: conn` to enqueue inside your own transaction. Class settings:
9
+ `queue "mailers"`, `max_attempts 5`, `never_retry SomeError`. With mail:
10
+ `Monk::Mail.deliver_later(...)`; with auth:
11
+ `Monk::Auth::SendLoginLink.enqueue(email)`.
12
+
13
+ **Test:** `test/jobs_test.rb`. In tests, `Monk::Jobs.drain!` runs every
14
+ enqueued job right away, each in a Ractor; `Monk::Jobs.clear!` in
15
+ `teardown` empties the queue.
16
+
17
+ **Pitfalls:**
18
+ - A job's arguments are stored as JSON: pass ids and plain values, never
19
+ records or objects.
20
+ - A job may run more than once (its process was killed mid-job): make it
21
+ safe to repeat.
22
+ - Jobs run in Ractors: the same shareability rules as routes.
23
+
24
+ **Examples:** `jobs-enqueue` in `app/routes/jobs.rb`; `app/jobs/hello_job.rb`
25
+ is a real, deletable demo job.
@@ -0,0 +1,9 @@
1
+ # Routes that hand work to bin/jobs (monk add jobs).
2
+ #
3
+ # monk:example jobs-enqueue -- a route enqueueing the demo job (app/jobs/hello_job.rb).
4
+ # # bin/jobs runs it, and its line lands in log/development.log:
5
+ # # curl -X POST "http://localhost:9292/jobs/hello?name=Ann"
6
+ # class App
7
+ # post("/jobs/hello") { json(enqueued: HelloJob.enqueue(params[:name] || "world")) }
8
+ # end
9
+ # monk:end
@@ -1,18 +1,24 @@
1
1
  #!/usr/bin/env ruby
2
2
  require "bundler/setup"
3
- # The configs and app/, as the web process loads them, so a job can use
4
- # the app's models and mailers, send mail, or push a Live update --
5
- # config/load.rb, never Monk.boot: this process serves no routes.
6
- require_relative "../config/load"
7
- require "monk/jobs/runtime"
3
+ # A service that's down, a missing database or setting ends this script
4
+ # with what to fix (SETUP.md's commands), not a backtrace.
5
+ require "monk/bin"
8
6
 
9
- # The app's templates, so a job can render one (a mail's HTML part, say):
10
- # there's no App class here to point Monk::Views at them.
11
- Monk::Views.root = File.expand_path("../app/views", __dir__)
7
+ Monk::Bin.run(__FILE__) do
8
+ # The configs and app/, as the web process loads them, so a job can use
9
+ # the app's models and mailers, send mail, or push a Live update --
10
+ # config/load.rb, never Monk.boot: this process serves no routes.
11
+ require_relative "../config/load"
12
+ require "monk/jobs/runtime"
12
13
 
13
- workers = Integer(ENV.fetch("JOBS_WORKERS", "5"))
14
- queues = ENV.fetch("JOBS_QUEUES", "default").split(",").map(&:strip)
15
- puts "Monk::Jobs: #{workers} worker(s) on #{queues.join(", ")} -- TERM or Ctrl-C finishes the jobs in hand and stops"
16
- $stdout.flush
14
+ # The app's templates, so a job can render one (a mail's HTML part, say):
15
+ # there's no App class here to point Monk::Views at them.
16
+ Monk::Views.root = File.expand_path("../app/views", __dir__)
17
17
 
18
- Monk::Jobs::Runtime.new(workers: workers, queues: queues).run
18
+ workers = Integer(ENV.fetch("JOBS_WORKERS", "5"))
19
+ queues = ENV.fetch("JOBS_QUEUES", "mailers,default").split(",").map(&:strip)
20
+ puts "Monk::Jobs: #{workers} worker(s) on #{queues.join(", ")} -- TERM or Ctrl-C finishes the jobs in hand and stops"
21
+ $stdout.flush
22
+
23
+ Monk::Jobs::Runtime.new(workers: workers, queues: queues).run
24
+ end
@@ -0,0 +1,30 @@
1
+ ## jobs
2
+
3
+ Background jobs on the app's own Postgres. A migration creates the queue's
4
+ tables: run `bin/setup_db` and `DB_NAME={{app}}_test bin/setup_db`
5
+ (postgres, above).
6
+
7
+ ### Run them
8
+
9
+ `bin/jobs` is the process that runs jobs, next to `bin/server`:
10
+
11
+ ```bash
12
+ bin/jobs
13
+ ```
14
+
15
+ `JOBS_WORKERS` (in `.env`) is how many jobs run at once, and `JOBS_QUEUES`
16
+ which queues it takes them from, in order: `mailers,default` serves mail
17
+ first. `TERM` or Ctrl-C lets the jobs in hand finish before it exits. In
18
+ production it's one more process from the same image, with `bin/jobs` as
19
+ its command.
20
+
21
+ ### Check it works
22
+
23
+ 1. `bundle exec rake test` runs `test/jobs_test.rb`, which enqueues the
24
+ demo job (`app/jobs/hello_job.rb`) and runs it.
25
+ 2. By hand, with `bin/jobs` running: in `bin/console`,
26
+ `HelloJob.enqueue("Ann")`, and `log/development.log` shows
27
+ `Hello, Ann, from a background job`.
28
+ 3. The example: uncomment the `jobs-enqueue` block in `app/routes/jobs.rb`,
29
+ restart `bin/server`, and
30
+ `curl -X POST "http://localhost:9292/jobs/hello?name=Bob"`.
@@ -0,0 +1,16 @@
1
+ require_relative "test_helper"
2
+
3
+ # A job enqueued in the app's database runs: Monk::Jobs.drain! runs every
4
+ # enqueued job right here, each in its own Ractor as bin/jobs would, so a
5
+ # job that only works outside one fails here too. SETUP.md, jobs.
6
+ class JobsTest < Minitest::Test
7
+ def teardown
8
+ Monk::Jobs.clear!
9
+ end
10
+
11
+ def test_the_demo_job_runs
12
+ HelloJob.enqueue("test")
13
+
14
+ assert_equal 1, Monk::Jobs.drain!
15
+ end
16
+ end
@@ -0,0 +1,25 @@
1
+ ## live
2
+
3
+ **Where:** `config/live.rb` (subscribe rules, `live_ws_url`), broadcasts in
4
+ `app/broadcasts/`, the partials they push in `app/views/` (e.g.
5
+ `app/views/live/`), the routes calling them in `app/routes/`.
6
+
7
+ **Main calls:** in a view, `<div <%= live_topic "topic" %>>`; from a route,
8
+ job or broadcast, `Monk::Live.patch(topic, to: "#id", partial: "live/_x",
9
+ **locals)`, also `append`, `prepend`, `remove(topic, to:)`, `batch`. Rules:
10
+ `Monk::Live.authorize("contacts:*") { |subject, topic| ... }` with a block
11
+ built in a module, `anonymous: true` to let visitors in.
12
+
13
+ **Test:** `test/live_test.rb` publishes and checks a subscribed socket gets
14
+ it. To test a page, render it and look for `data-live-topic`.
15
+
16
+ **Pitfalls:**
17
+ - Nothing can be subscribed to until a rule in `config/live.rb` allows it;
18
+ rules are checked in `bin/websocket_server`, which never loads `app/`.
19
+ - A pushed partial sees only its locals (`locals[:text]`), never `params`
20
+ or the session.
21
+ - Each topic name must match what pages subscribe to, exactly.
22
+ - The demo (`/demo/live`) is development only; delete it when done.
23
+
24
+ **Examples:** `live-rule` in `config/live.rb`; `live-broadcast` in
25
+ `app/broadcasts/greeting.rb` and `app/routes/live.rb`.
@@ -0,0 +1,13 @@
1
+ # What this app pushes to open pages (monk add live).
2
+ #
3
+ # monk:example live-broadcast -- a greeting that changes in every open page at once.
4
+ # # A page shows it in an element subscribed to the topic (and a rule in
5
+ # # config/live.rb lets pages subscribe to "greeting"):
6
+ # # <p <%= live_topic "greeting" %>><%= render "live/_greeting", text: "Hello", layout: false %></p>
7
+ # # The route that changes it is in app/routes/live.rb.
8
+ # module GreetingBroadcast
9
+ # def self.update(text)
10
+ # Monk::Live.patch("greeting", to: "#greeting", partial: "live/_greeting", text: text)
11
+ # end
12
+ # end
13
+ # monk:end
@@ -0,0 +1,21 @@
1
+ # monk:demo live -- Monk::Live's demo, development only: a counter whose
2
+ # open tabs update together. Open http://localhost:9292/demo/live in two
3
+ # tabs and press the button in one. To remove it, delete this file,
4
+ # app/views/demo/, and the `monk:demo live` lines in config/live.rb.
5
+ if Monk.env.development?
6
+ class App
7
+ # State shared across requests lives in a StateRactor; the update block
8
+ # is built here, where self is shareable, not inline in the route.
9
+ demo_hits = Monk::StateRactor.new(0)
10
+ demo_increment = Ractor.make_shareable(proc { |n| n + 1 })
11
+
12
+ get("/demo/live") { @title = "Monk::Live demo"; render "demo/live", hits: demo_hits.value }
13
+
14
+ # Change state, then tell every open page that shows it. Monk::Live.patch
15
+ # renders the partial once and pushes it to the topic's subscribers.
16
+ post("/demo/live/hit") do
17
+ Monk::Live.patch("demo:hits", to: "#hits", partial: "demo/_hits", hits: demo_hits.update(&demo_increment))
18
+ redirect "/demo/live"
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,10 @@
1
+ # Routes that push updates to open pages (monk add live).
2
+ #
3
+ # monk:example live-broadcast -- the route changing the greeting (app/broadcasts/greeting.rb).
4
+ # class App
5
+ # post("/greeting") do
6
+ # GreetingBroadcast.update(params[:text].to_s)
7
+ # json(updated: true)
8
+ # end
9
+ # end
10
+ # monk:end
@@ -0,0 +1,18 @@
1
+ <h1>Monk::Live demo</h1>
2
+ <p>
3
+ Open this page in two tabs and press the button in one: the counter in the
4
+ other updates by itself. The server pushes the new HTML over a WebSocket;
5
+ there is no polling and no page JavaScript of your own.
6
+ </p>
7
+
8
+ <%# live_topic makes this element's page subscribe to the "demo:hits" topic. %>
9
+ <p <%= live_topic "demo:hits" %>>Hits so far: <%= render "demo/_hits", hits: locals[:hits], layout: false %></p>
10
+
11
+ <form method="post" action="/demo/live/hit"><button>+1</button></form>
12
+
13
+ <p>
14
+ The pieces: <code>live_topic</code> in this view, the partial
15
+ <code>app/views/demo/_hits.erb</code>, <code>Monk::Live.patch</code> in
16
+ <code>POST /demo/live/hit</code> (<code>app/routes/demo_live.rb</code>), and
17
+ the subscribe rule in <code>config/live.rb</code>.
18
+ </p>
@@ -0,0 +1 @@
1
+ <span id="greeting"><%= locals[:text] %></span>
@@ -1,47 +1,45 @@
1
- # Monk::Live wiring, required by both processes: config/load.rb (this app
2
- # publishes updates, e.g. from a route) and bin/websocket_server (the browsers'
3
- # sockets terminate there and are handed the updates).
4
- #
5
- # They are separate processes, so Redis is what carries a publish from one to
6
- # the other -- REDIS_URL (set in .env by `monk new --live`) is required.
7
- require_relative "settings"
1
+ require "monk"
8
2
  require "monk/live"
9
- require "monk/websocket/redis_fanout"
3
+ require_relative "websocket"
4
+
5
+ # Monk::Live (monk add live): HTML pushed to open pages, over the app's
6
+ # WebSocket registry (config/websocket.rb), so it has no transport of its
7
+ # own. Loaded by config/load.rb (bin/server and bin/jobs publish) and by
8
+ # bin/websocket_server (it delivers).
10
9
 
11
- # Where the browser opens its WebSocket (read by the layout). In
12
- # development there's no reverse proxy in front, so this is the direct WS
13
- # port; outside development it defaults to a /ws path under public_url
14
- # (config/settings.rb) -- matching the path-based proxy routing
15
- # docs/guides/deploying.md's "Adding Monk::WebSocket" section sets up, so
16
- # setting PUBLIC_URL alone keeps this in sync instead of two env vars
17
- # drifting apart. Override with LIVE_WS_URL directly if your setup doesn't
18
- # fit that shape.
10
+ # Where the browser opens its WebSocket, for the layout's <%= monk_head %>:
11
+ # the direct port in development; elsewhere a /ws path under public_url
12
+ # (config/settings.rb), matching the proxy docs/guides/deploying.md sets
13
+ # up, so PUBLIC_URL alone keeps both in sync. LIVE_WS_URL overrides it.
19
14
  default_live_ws_url =
20
15
  if Monk.env.development?
21
16
  "ws://localhost:9293"
22
17
  else
23
- Monk::Settings[:public_url].sub(/\Ahttps:\/\//, "wss://").sub(/\Ahttp:\/\//, "ws://") + "/ws"
18
+ Monk::Settings[:public_url].sub(%r{\Ahttps://}, "wss://").sub(%r{\Ahttp://}, "ws://") + "/ws"
24
19
  end
25
20
 
26
21
  Monk::Settings.configure do
27
22
  optional :live_ws_url, default: default_live_ws_url
28
23
  end
29
24
 
30
- redis_url = ENV["REDIS_URL"] or raise "Monk::Live needs REDIS_URL: bin/server and bin/websocket_server " \
31
- "are separate processes, and Redis is how an update published in one reaches a socket in the other"
32
-
33
- Monk::Live.configure(
34
- registry: Monk::WebSocket::RedisFanout.new(Monk::WebSocket::Registry.new, redis_url: redis_url),
35
- )
25
+ Monk::Live.configure(registry: AppWebSocket::REGISTRY)
36
26
 
37
27
  # Who may subscribe to what. Nothing is allowed unless a rule says so, and an
38
- # anonymous connection is denied unless the rule opts in with `anonymous: true`.
39
- # The rule blocks live in a module because they have to be Ractor-shareable
40
- # (self at the top of this file is not).
41
- module AppLive
42
- # The demo counter is public. A real rule looks at who is asking:
43
- # proc { |subject, topic| topic == "contacts:#{subject}" }
44
- PUBLIC = proc { |_subject, _topic| true }
45
- end
28
+ # anonymous socket is denied unless its rule says `anonymous: true`. Rule
29
+ # blocks must be Ractor-shareable, so they're built in a module (self at the
30
+ # top of this file is not); Monk::Live::ALLOW_ALL lets everyone in.
31
+ #
32
+ # monk:example live-rule -- each person may subscribe to their own contacts topic.
33
+ # # subject is the logged-in user (with auth), nil for a visitor.
34
+ # module AppLive
35
+ # OWN_CONTACTS = proc { |subject, topic| topic == "contacts:#{subject}" }
36
+ # end
37
+ # Monk::Live.authorize("contacts:*", &AppLive::OWN_CONTACTS)
38
+ # monk:end
46
39
 
47
- Monk::Live.authorize("hits", anonymous: true, &AppLive::PUBLIC)
40
+ # monk:demo live -- the demo's topic (app/routes/demo_live.rb), development
41
+ # only. Remove these lines with it.
42
+ if Monk.env.development?
43
+ Monk::Live.authorize("demo:hits", anonymous: true, &Monk::Live::ALLOW_ALL)
44
+ end
45
+ # monk:end
@@ -0,0 +1,38 @@
1
+ require "monk"
2
+ require "monk/live"
3
+ require_relative "websocket"
4
+
5
+ # Monk::Live (monk add live): HTML pushed to open pages, over the app's
6
+ # WebSocket registry (config/websocket.rb), so it has no transport of its
7
+ # own. Loaded by config/load.rb (bin/server and bin/jobs publish) and by
8
+ # bin/websocket_server (it delivers).
9
+
10
+ # Where the browser opens its WebSocket, for the layout's <%= monk_head %>:
11
+ # the direct port in development; elsewhere a /ws path under public_url
12
+ # (config/settings.rb), matching the proxy docs/guides/deploying.md sets
13
+ # up, so PUBLIC_URL alone keeps both in sync. LIVE_WS_URL overrides it.
14
+ default_live_ws_url =
15
+ if Monk.env.development?
16
+ "ws://localhost:9293"
17
+ else
18
+ Monk::Settings[:public_url].sub(%r{\Ahttps://}, "wss://").sub(%r{\Ahttp://}, "ws://") + "/ws"
19
+ end
20
+
21
+ Monk::Settings.configure do
22
+ optional :live_ws_url, default: default_live_ws_url
23
+ end
24
+
25
+ Monk::Live.configure(registry: AppWebSocket::REGISTRY)
26
+
27
+ # Who may subscribe to what. Nothing is allowed unless a rule says so, and an
28
+ # anonymous socket is denied unless its rule says `anonymous: true`. Rule
29
+ # blocks must be Ractor-shareable, so they're built in a module (self at the
30
+ # top of this file is not); Monk::Live::ALLOW_ALL lets everyone in.
31
+ #
32
+ # monk:example live-rule -- each person may subscribe to their own contacts topic.
33
+ # # subject is the logged-in user (with auth), nil for a visitor.
34
+ # module AppLive
35
+ # OWN_CONTACTS = proc { |subject, topic| topic == "contacts:#{subject}" }
36
+ # end
37
+ # Monk::Live.authorize("contacts:*", &AppLive::OWN_CONTACTS)
38
+ # monk:end
@@ -0,0 +1,39 @@
1
+ ## live
2
+
3
+ HTML pushed to open pages over the WebSocket (websocket, above): a route
4
+ changes something and calls `Monk::Live.patch`; every page showing it
5
+ updates, with no page JavaScript of your own. The layout's
6
+ `<%= monk_head %>` loads the browser client, which Monk serves from the gem.
7
+
8
+ ### Try the demo
9
+
10
+ With `bin/server` and `bin/websocket_server` running, open
11
+ http://localhost:9292/demo/live in two tabs and press the button in one:
12
+ the other updates by itself. The demo exists in development only. To
13
+ remove it, delete `app/routes/demo_live.rb`, `app/views/demo/`, and the
14
+ `monk:demo live` lines in `config/live.rb`.
15
+
16
+ ### Before production
17
+
18
+ `LIVE_WS_URL` defaults to a `/ws` path under `PUBLIC_URL`, with `wss://`
19
+ for an `https://` origin: the proxy in front routes `/ws` to
20
+ `bin/websocket_server` (docs/guides/deploying.md in the monk repo).
21
+
22
+ ### Check it works
23
+
24
+ 1. `bundle exec rake test` runs `test/live_test.rb`: an update reaches a
25
+ socket subscribed to its topic.
26
+ 2. By hand: the demo, above.
27
+ 3. The example: uncomment `live-broadcast` (in
28
+ `app/broadcasts/greeting.rb` and `app/routes/live.rb`) and add a rule
29
+ for its topic in `config/live.rb`, e.g.
30
+ `Monk::Live.authorize("greeting", anonymous: true, &Monk::Live::ALLOW_ALL)`.
31
+ Put `<p <%= live_topic "greeting" %>><%= render "live/_greeting", text: "Hello", layout: false %></p>`
32
+ in a page, open it in two tabs, and change the greeting:
33
+
34
+ ```bash
35
+ curl -X POST http://localhost:9292/greeting -H "content-type: application/json" \
36
+ -d '{"text": "Hi all"}'
37
+ ```
38
+
39
+ Both tabs update.
@@ -0,0 +1,22 @@
1
+ require_relative "test_helper"
2
+ require "json"
3
+ require "timeout"
4
+
5
+ # Monk::Live publishes through the app's WebSocket registry
6
+ # (config/websocket.rb): an update reaches a socket subscribed to its topic.
7
+ # SETUP.md, live.
8
+ class LiveTest < Minitest::Test
9
+ def test_an_update_reaches_a_subscribed_socket
10
+ assert_same AppWebSocket::REGISTRY, Monk::Live.registry
11
+ AppWebSocket::REGISTRY.listen!
12
+ port = Ractor::Port.new
13
+ AppWebSocket::REGISTRY.register(:"live:check", port)
14
+
15
+ Monk::Live.remove("live:check", to: "#gone")
16
+
17
+ assert_equal "remove", JSON.parse(Timeout.timeout(5) { port.receive })["mode"]
18
+ ensure
19
+ AppWebSocket::REGISTRY.unregister(:"live:check", port) if port
20
+ port&.close
21
+ end
22
+ end
@@ -0,0 +1,22 @@
1
+ ## mail
2
+
3
+ **Where:** `config/mail.rb` (from `MAIL_URL`, `MAIL_FROM`), mailers in
4
+ `app/mailers/` (module methods), HTML parts in `app/views/mail/`.
5
+
6
+ **Main calls:** `Monk::Mail.deliver(to:, subject:, text:, html:)`;
7
+ `Monk::Mail.render("mail/<template>", **locals)` for the HTML part (the
8
+ template reads `locals[:name]`). With jobs:
9
+ `Monk::Mail.deliver_later(...)`, the same arguments.
10
+
11
+ **Test:** `test/mail_test.rb` delivers to `log/test.log` (`MAIL_URL=log://`
12
+ in `.env.test`); assert on that file's lines.
13
+
14
+ **Pitfalls:**
15
+ - `deliver` waits for the mail server. In a request that matters: use
16
+ `deliver_later` once jobs are installed.
17
+ - Render the HTML before `deliver_later`: the job only sends.
18
+ - `MAIL_URL` is required outside development; `MAIL_FROM` must be on a
19
+ verified domain, or mail lands in spam.
20
+
21
+ **Examples:** `mail-welcome` in `app/mailers/app_mailer.rb` and
22
+ `app/routes/mail.rb`.
@@ -0,0 +1,14 @@
1
+ # The emails this app sends, through Monk::Mail (config/mail.rb). Module
2
+ # methods, so a route or a job can call them from any Ractor.
3
+ module AppMailer
4
+ # monk:example mail-welcome -- an email, sent from a route (app/routes/mail.rb).
5
+ # def self.welcome(to:, name:)
6
+ # Monk::Mail.deliver(
7
+ # to: to,
8
+ # subject: "Welcome, #{name}",
9
+ # text: "Hello #{name}, thanks for signing up.",
10
+ # html: Monk::Mail.render("mail/welcome", name: name),
11
+ # )
12
+ # end
13
+ # monk:end
14
+ end
@@ -0,0 +1,12 @@
1
+ # Routes that send email (monk add mail).
2
+ #
3
+ # monk:example mail-welcome -- the route that sends AppMailer.welcome
4
+ # # (app/mailers/app_mailer.rb). Sending waits for the mail server; with
5
+ # # monk add jobs, send it from a job instead (AGENTS.md, mail).
6
+ # class App
7
+ # post("/welcome") do
8
+ # AppMailer.welcome(to: params[:email].to_s, name: params[:name].to_s)
9
+ # json(sent: true)
10
+ # end
11
+ # end
12
+ # monk:end
@@ -0,0 +1 @@
1
+ <p>Hello <%= locals[:name] %>, thanks for signing up.</p>
@@ -1,10 +1,10 @@
1
1
  require "monk"
2
2
  require "monk/mail"
3
3
 
4
- # Sends email with Monk::Mail.deliver(to:, subject:, text:, html:) from
5
- # any route -- and, with --auth, the magic links AppMailer::MAGIC_LINK
6
- # (app/mailers/app_mailer.rb) delivers. See docs/guides/mail.md. Required from config/load.rb (not from
7
- # config/auth.rb: bin/websocket_server loads that too, and never sends mail).
4
+ # Sends email with Monk::Mail.deliver(to:, subject:, text:, html:): the
5
+ # app's mailers (app/mailers/) and, with auth, its magic links. See
6
+ # docs/guides/mail.md in the monk repo. Loaded by config/load.rb, never by
7
+ # config/auth.rb: bin/websocket_server loads that, and never sends mail.
8
8
  Monk::Settings.configure do
9
9
  # Where mail goes. Unset in development means log:// (printed to the
10
10
  # console, nothing sent); anywhere else an unset one fails the boot.
@@ -0,0 +1,32 @@
1
+ ## mail
2
+
3
+ In development `MAIL_URL` is unset, so every message is printed to the
4
+ console (and `log/development.log`) instead of sent. Tests use
5
+ `MAIL_URL=log://` (`.env.test`): messages go to `log/test.log`.
6
+
7
+ ### Before production
8
+
9
+ Set `MAIL_URL` to your provider's SMTP relay, e.g.
10
+ `smtp://user:password@smtp.provider.com:587`, and `MAIL_FROM` to a sender on
11
+ a domain you've verified with that provider (SPF, DKIM). An unset `MAIL_URL`
12
+ fails the boot outside development. Provider examples:
13
+ `docs/guides/mail.md` in the monk repo.
14
+
15
+ ### Check it works
16
+
17
+ 1. `bundle exec rake test` runs `test/mail_test.rb`, which delivers a
18
+ message to `log/test.log`.
19
+ 2. By hand, in `bin/console`:
20
+ `Monk::Mail.deliver(to: "you@example.com", subject: "Hi", text: "Hello")`.
21
+ In development the message is printed right there (and in
22
+ `log/development.log`), not sent.
23
+ 3. The example: uncomment the `mail-welcome` blocks in
24
+ `app/mailers/app_mailer.rb` and `app/routes/mail.rb`, restart
25
+ `bin/server`, and send one:
26
+
27
+ ```bash
28
+ curl -X POST http://localhost:9292/welcome -H "content-type: application/json" \
29
+ -d '{"email": "ann@example.com", "name": "Ann"}'
30
+ ```
31
+
32
+ The welcome email is printed in `bin/server`'s console.
@@ -0,0 +1,13 @@
1
+ require_relative "test_helper"
2
+
3
+ # Monk::Mail is configured, and with .env.test's MAIL_URL=log:// a message
4
+ # lands in log/test.log instead of being sent: SETUP.md, mail.
5
+ class MailTest < Minitest::Test
6
+ def test_a_message_is_delivered_to_the_test_log
7
+ subject = "Mail check #{rand(1_000_000)}"
8
+
9
+ Monk::Mail.deliver(to: "ann@example.test", subject: subject, text: "Hello")
10
+
11
+ assert_includes File.read(File.expand_path("../log/test.log", __dir__)), subject.inspect
12
+ end
13
+ end
@@ -0,0 +1,25 @@
1
+ ## postgres
2
+
3
+ **Where:** `config/persistence.rb` registers the `:primary` connection from
4
+ `DB_*`. Migrations: `db/migrate/<timestamp>_<name>.up.sql` and `.down.sql`,
5
+ plain SQL. Models: `app/models/`.
6
+
7
+ **Main calls:** a model is `class Note < Monk::Persistence::Pg::Model` with
8
+ `self.db_name = :primary` and `self.table_name = "notes"`; then
9
+ `Note.create(...)`, `.find(id)`, `.where(...)`, `.update(id, ...)`,
10
+ `.delete(id)`. Rows are plain Symbol-keyed Hashes: no ORM, no associations.
11
+ Raw SQL: `Monk::Persistence::Pg.checkout(:primary) { |conn| conn.exec_params(sql, params) }`.
12
+ Commands: `bin/setup_db` (apply pending migrations), `bin/migrate
13
+ [migrate|rollback N|status]`, `bin/console`.
14
+
15
+ **Test:** `test/persistence_test.rb` connects to the test database. Tests use
16
+ `.env.test`'s `DB_NAME`: migrate it with `DB_NAME={{app}}_test bin/setup_db`.
17
+
18
+ **Pitfalls:**
19
+ - Models and connections must exist before `Monk.boot`: put models in
20
+ `app/models/`, never define one inside a route.
21
+ - Never interpolate params into SQL: use `exec_params` with `$1`, `$2`.
22
+ - A new migration gets a new, later timestamp. Never edit one that's
23
+ already applied.
24
+
25
+ **Examples:** `postgres-model` in `app/routes/postgres.rb`.
@@ -0,0 +1,22 @@
1
+ # Routes that read and write the app's own tables (monk add postgres).
2
+ #
3
+ # monk:example postgres-model -- a table, a model, and the routes using it.
4
+ # # Uncomment, then move each part where its comment says.
5
+ #
6
+ # # 1. The table, in a migration: db/migrate/<timestamp>_create_notes.up.sql
7
+ # # (and .down.sql with DROP TABLE notes;), then bin/setup_db.
8
+ # #
9
+ # # CREATE TABLE notes (id BIGSERIAL PRIMARY KEY, body TEXT NOT NULL);
10
+ #
11
+ # # 2. The model, in app/models/note.rb. Rows are plain Hashes, not objects.
12
+ # class Note < Monk::Persistence::Pg::Model
13
+ # self.db_name = :primary
14
+ # self.table_name = "notes"
15
+ # end
16
+ #
17
+ # # 3. The routes, here. params come from the query string or a JSON body.
18
+ # class App
19
+ # get("/notes") { json(notes: Note.where({}, order: :id, limit: 20)) }
20
+ # post("/notes") { json(note: Note.create(body: params[:body].to_s)) }
21
+ # end
22
+ # monk:end
@@ -3,7 +3,9 @@ require "bundler/setup"
3
3
  # The configs and app/ (models, jobs, mailers...), as the app sees them --
4
4
  # config/load.rb, never Monk.boot, so nothing is frozen and you can poke at
5
5
  # anything.
6
- require_relative "../config/load"
6
+ # A service that's down or a missing setting ends here with what to fix.
7
+ require "monk/bin"
8
+ Monk::Bin.run(__FILE__) { require_relative "../config/load" }
7
9
  require "irb"
8
10
 
9
11
  IRB.start(__FILE__)