monkrb 0.15.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 (80) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +581 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +88 -0
  5. data/exe/monk +116 -0
  6. data/lib/monk/assets.rb +197 -0
  7. data/lib/monk/auth/errors.rb +13 -0
  8. data/lib/monk/auth/helpers.rb +76 -0
  9. data/lib/monk/auth/login_token.rb +11 -0
  10. data/lib/monk/auth/rate_limiter.rb +46 -0
  11. data/lib/monk/auth/session.rb +11 -0
  12. data/lib/monk/auth.rb +301 -0
  13. data/lib/monk/base.rb +323 -0
  14. data/lib/monk/context.rb +78 -0
  15. data/lib/monk/environment.rb +50 -0
  16. data/lib/monk/errors.rb +37 -0
  17. data/lib/monk/freeze_hooks.rb +23 -0
  18. data/lib/monk/live/client/idiomorph.LICENSE +13 -0
  19. data/lib/monk/live/client/idiomorph.js +4 -0
  20. data/lib/monk/live/client/monk_live.js +204 -0
  21. data/lib/monk/live/client/protocol.js +87 -0
  22. data/lib/monk/live/envelope.rb +51 -0
  23. data/lib/monk/live/errors.rb +9 -0
  24. data/lib/monk/live/helpers.rb +22 -0
  25. data/lib/monk/live/policy.rb +58 -0
  26. data/lib/monk/live/publisher.rb +91 -0
  27. data/lib/monk/live/renderer.rb +47 -0
  28. data/lib/monk/live/session.rb +121 -0
  29. data/lib/monk/live.rb +96 -0
  30. data/lib/monk/log.rb +130 -0
  31. data/lib/monk/persistence/errors.rb +7 -0
  32. data/lib/monk/persistence/model.rb +41 -0
  33. data/lib/monk/persistence/pg/errors.rb +4 -0
  34. data/lib/monk/persistence/pg/migrator.rb +165 -0
  35. data/lib/monk/persistence/pg/model.rb +233 -0
  36. data/lib/monk/persistence/pg.rb +34 -0
  37. data/lib/monk/persistence.rb +113 -0
  38. data/lib/monk/scaffold.rb +606 -0
  39. data/lib/monk/settings.rb +151 -0
  40. data/lib/monk/state_ractor.rb +45 -0
  41. data/lib/monk/templates/auth/config/auth.rb +28 -0
  42. data/lib/monk/templates/auth/db/migrate/00000000000001_create_auth_tables.down.sql +2 -0
  43. data/lib/monk/templates/auth/db/migrate/00000000000001_create_auth_tables.up.sql +18 -0
  44. data/lib/monk/templates/base/.dockerignore +5 -0
  45. data/lib/monk/templates/base/.gitignore +4 -0
  46. data/lib/monk/templates/base/.ruby-version +1 -0
  47. data/lib/monk/templates/base/Dockerfile +28 -0
  48. data/lib/monk/templates/base/Gemfile +7 -0
  49. data/lib/monk/templates/base/bin/server +5 -0
  50. data/lib/monk/templates/base/bin/websocket_server +62 -0
  51. data/lib/monk/templates/base/config/settings.rb +30 -0
  52. data/lib/monk/templates/base/config.ru +13 -0
  53. data/lib/monk/templates/base/public/css/app.css +17 -0
  54. data/lib/monk/templates/base/public/js/app.js +5 -0
  55. data/lib/monk/templates/base/views/index.erb +6 -0
  56. data/lib/monk/templates/base/views/layouts/app.erb +18 -0
  57. data/lib/monk/templates/live/bin/websocket_server +30 -0
  58. data/lib/monk/templates/live/config/live.rb +47 -0
  59. data/lib/monk/templates/live/config.ru +27 -0
  60. data/lib/monk/templates/live/views/index.erb +18 -0
  61. data/lib/monk/templates/live/views/live/_hits.erb +1 -0
  62. data/lib/monk/templates/postgres/Dockerfile +30 -0
  63. data/lib/monk/templates/postgres/Gemfile.extra +2 -0
  64. data/lib/monk/templates/postgres/bin/console +7 -0
  65. data/lib/monk/templates/postgres/bin/migrate +22 -0
  66. data/lib/monk/templates/postgres/bin/setup_db +9 -0
  67. data/lib/monk/templates/postgres/config/persistence.rb +10 -0
  68. data/lib/monk/templates/redis/Gemfile.extra +1 -0
  69. data/lib/monk/version.rb +9 -0
  70. data/lib/monk/views.rb +175 -0
  71. data/lib/monk/websocket/connection.rb +226 -0
  72. data/lib/monk/websocket/errors.rb +9 -0
  73. data/lib/monk/websocket/frame.rb +71 -0
  74. data/lib/monk/websocket/handshake.rb +77 -0
  75. data/lib/monk/websocket/redis_fanout.rb +103 -0
  76. data/lib/monk/websocket/registry.rb +92 -0
  77. data/lib/monk/websocket/server.rb +234 -0
  78. data/lib/monk/websocket.rb +19 -0
  79. data/lib/monk.rb +45 -0
  80. metadata +252 -0
@@ -0,0 +1,606 @@
1
+ require "fileutils"
2
+
3
+ require_relative "errors"
4
+
5
+ module Monk
6
+ # Writes a new Monk project's skeleton to disk. Templates are static
7
+ # files, copied verbatim -- nothing here needs the project's own name
8
+ # substituted in, so there's no templating engine involved. See
9
+ # docs/history/plan-init.md for the full design.
10
+ class Scaffold
11
+ TEMPLATES_DIR = File.expand_path("templates", __dir__)
12
+
13
+ BASE_FILES = {
14
+ "Gemfile" => "base/Gemfile",
15
+ "config.ru" => "base/config.ru",
16
+ "config/settings.rb" => "base/config/settings.rb",
17
+ ".ruby-version" => "base/.ruby-version",
18
+ ".gitignore" => "base/.gitignore",
19
+ ".dockerignore" => "base/.dockerignore",
20
+ "Dockerfile" => "base/Dockerfile",
21
+ "bin/server" => "base/bin/server",
22
+ "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",
25
+ "public/css/app.css" => "base/public/css/app.css",
26
+ "public/js/app.js" => "base/public/js/app.js",
27
+ }.freeze
28
+
29
+ POSTGRES_FILES = {
30
+ "config/persistence.rb" => "postgres/config/persistence.rb",
31
+ "bin/console" => "postgres/bin/console",
32
+ "bin/setup_db" => "postgres/bin/setup_db",
33
+ "bin/migrate" => "postgres/bin/migrate",
34
+ }.freeze
35
+
36
+ # The pg gem's native extension needs libpq -- the base Dockerfile has
37
+ # no system packages at all (kino is a precompiled platform gem), so
38
+ # --postgres/--auth swap in a Dockerfile that adds libpq-dev/libpq5
39
+ # instead of patching one image for every combination.
40
+ POSTGRES_OVERRIDES = {
41
+ "Dockerfile" => "postgres/Dockerfile",
42
+ }.freeze
43
+
44
+ AUTH_FILES = {
45
+ "config/auth.rb" => "auth/config/auth.rb",
46
+ "db/migrate/00000000000001_create_auth_tables.up.sql" => "auth/db/migrate/00000000000001_create_auth_tables.up.sql",
47
+ "db/migrate/00000000000001_create_auth_tables.down.sql" => "auth/db/migrate/00000000000001_create_auth_tables.down.sql",
48
+ }.freeze
49
+
50
+ # --live: Monk::Live's demo (a counter whose open tabs update when
51
+ # another request changes it). These replace three base files outright
52
+ # rather than patching them -- a live config.ru and index are different
53
+ # files, not one line of diff -- and add two. config.ru's first line stays
54
+ # the settings require, so --postgres/--auth wiring still finds it.
55
+ LIVE_OVERRIDES = {
56
+ "config.ru" => "live/config.ru",
57
+ "bin/websocket_server" => "live/bin/websocket_server",
58
+ "views/index.erb" => "live/views/index.erb",
59
+ }.freeze
60
+
61
+ LIVE_FILES = {
62
+ "config/live.rb" => "live/config/live.rb",
63
+ "views/live/_hits.erb" => "live/views/live/_hits.erb",
64
+ }.freeze
65
+
66
+ # Where the client runtime lands in the app's public root (an app serves
67
+ # it like any other static file; its files import each other by relative
68
+ # path, so they stay together in one directory).
69
+ LIVE_CLIENT_DIR = "public/js/monk_live".freeze
70
+
71
+ LIVE_HEAD = <<~HTML.gsub(/^/, " ").freeze
72
+ <meta name="monk-live-url" content="<%= settings[:live_ws_url] %>">
73
+ <script type="module" src="<%= asset_path "/js/monk_live/monk_live.js" %>"></script>
74
+ HTML
75
+
76
+ EXECUTABLE_FILES = %w[bin/server bin/websocket_server bin/console bin/setup_db bin/migrate].freeze
77
+
78
+ # auth: implies postgres -- Monk::Auth is Postgres-only (lib/monk/auth.rb
79
+ # subclasses Monk::Persistence::Pg::Model), so there's no combination
80
+ # where an app gets Auth without also getting the persistence scaffold.
81
+ #
82
+ # redis: is unrelated to both -- it doesn't imply, and isn't implied by,
83
+ # postgres or auth. Unlike them it adds no files of its own, just a
84
+ # Gemfile line: bin/websocket_server (always present, see BASE_FILES
85
+ # above -- WebSocket needs no external service, so unlike Postgres/Redis
86
+ # it isn't gated behind a flag at all) checks ENV["REDIS_URL"] at boot
87
+ # and only then requires "redis", so the gem has to already be in the
88
+ # bundle for that to work.
89
+ #
90
+ # live: implies redis: -- bin/server and bin/websocket_server are separate
91
+ # processes, so an update published by one only reaches a socket held by
92
+ # the other through Redis.
93
+ def initialize(dir, postgres: false, auth: false, redis: false, live: false)
94
+ @dir = dir
95
+ @auth = auth
96
+ @postgres = postgres || auth
97
+ @live = live
98
+ @redis = redis || live
99
+ end
100
+
101
+ def write!
102
+ raise Monk::ScaffoldExistsError, "#{@dir} already exists" if File.exist?(@dir)
103
+
104
+ FileUtils.mkdir_p(@dir)
105
+ base_files = BASE_FILES
106
+ base_files = base_files.merge(LIVE_OVERRIDES) if @live
107
+ base_files = base_files.merge(POSTGRES_OVERRIDES) if @postgres
108
+ base_files.each { |relative, template| write_file(relative, template, executable: EXECUTABLE_FILES.include?(relative)) }
109
+ write_live! if @live
110
+
111
+ if @postgres
112
+ POSTGRES_FILES.each { |relative, template| write_file(relative, template, executable: EXECUTABLE_FILES.include?(relative)) }
113
+ FileUtils.mkdir_p(File.join(@dir, "db/migrate"))
114
+ append_gemfile_extra("postgres/Gemfile.extra")
115
+ wire_config_ru!
116
+ end
117
+
118
+ append_gemfile_extra("redis/Gemfile.extra") if @redis
119
+
120
+ # --postgres and --redis are the two flags with anything worth
121
+ # putting in an env file (a database, a REDIS_URL) -- the base
122
+ # skeleton alone has nothing to configure this way.
123
+ if @postgres || @redis
124
+ write_env_files!
125
+ uncomment_dotenv!
126
+ end
127
+
128
+ AUTH_FILES.each { |relative, template| write_file(relative, template) } if @auth
129
+
130
+ # Every combination of flags gets a SETUP.md, not just --postgres --
131
+ # even the base skeleton has a dev step (bin/server) and an
132
+ # unscaffolded test framework to set up.
133
+ write_setup_md!
134
+ end
135
+
136
+ private
137
+
138
+ def write_live!
139
+ LIVE_FILES.each { |relative, template| write_file(relative, template) }
140
+ copy_live_client!
141
+ add_live_to_layout!
142
+ end
143
+
144
+ # The runtime ships inside the gem (Monk::Live.client_dir); copied, not
145
+ # duplicated under templates/, so there is one source of truth.
146
+ def copy_live_client!
147
+ source = File.join(__dir__, "live/client")
148
+ target = File.join(@dir, LIVE_CLIENT_DIR)
149
+ FileUtils.mkdir_p(target)
150
+ FileUtils.cp(Dir.children(source).map { |name| File.join(source, name) }, target)
151
+ end
152
+
153
+ def add_live_to_layout!
154
+ path = File.join(@dir, "views/layouts/app.erb")
155
+ content = File.read(path)
156
+ raise "layout wiring failed: </head> not found" unless content.include?(" </head>\n")
157
+
158
+ File.write(path, content.sub(" </head>\n", "#{LIVE_HEAD} </head>\n"))
159
+ end
160
+
161
+ def append_gemfile_extra(template_path)
162
+ extra = File.read(File.join(TEMPLATES_DIR, template_path))
163
+ File.write(File.join(@dir, "Gemfile"), "\n#{extra}", mode: "a")
164
+ end
165
+
166
+ # --postgres/--redis write a real .env (see write_env_files!) -- if the
167
+ # dotenv gem stays commented out, as it does in the base skeleton,
168
+ # config/settings.rb's `require "dotenv/load"` never runs, so nothing
169
+ # ever actually loads them: bin/setup_db (or bin/websocket_server's
170
+ # REDIS_URL check) silently falls back to config/persistence.rb's own
171
+ # ENV.fetch defaults, or no REDIS_URL at all, instead of the
172
+ # app-specific values .env was written to provide.
173
+ DOTENV_COMMENTED_LINE = %(# gem "dotenv" # uncomment to load a local .env file (config/settings.rb)\n).freeze
174
+ DOTENV_LINE = %(gem "dotenv" # loads .env/.env.test -- see config/settings.rb\n).freeze
175
+
176
+ def uncomment_dotenv!
177
+ path = File.join(@dir, "Gemfile")
178
+ content = File.read(path)
179
+ raise "Gemfile wiring failed: #{DOTENV_COMMENTED_LINE.inspect} not found" unless content.include?(DOTENV_COMMENTED_LINE)
180
+
181
+ File.write(path, content.sub(DOTENV_COMMENTED_LINE, DOTENV_LINE))
182
+ end
183
+
184
+ # config.ru ships in BASE_FILES unconditionally (it has to -- it's the
185
+ # only rackup entrypoint), so --postgres/--auth can't gate whether it
186
+ # exists, only what it requires. Unlike BASE_FILES/POSTGRES_FILES/
187
+ # AUTH_FILES, this is a post-write edit rather than a verbatim copy --
188
+ # the alternative (a second, fuller config.ru template per flag
189
+ # combination) would duplicate the whole file for one line of diff.
190
+ def wire_config_ru!
191
+ path = File.join(@dir, "config.ru")
192
+ settings_require = %(require_relative "config/settings"\n)
193
+ target = @auth ? "config/auth" : "config/persistence" # config/auth.rb itself require_relative "persistence"
194
+
195
+ content = File.read(path)
196
+ raise "config.ru wiring failed: #{settings_require.inspect} not found" unless content.include?(settings_require)
197
+
198
+ File.write(path, content.sub(settings_require, "#{settings_require}require_relative \"#{target}\"\n"))
199
+ end
200
+
201
+ # .env/.env.test/.env.example are the other deliberate exception to
202
+ # "templates are static files, copied verbatim" (see the class comment
203
+ # above): DB_NAME needs this app's own directory name in it -- the one
204
+ # piece of scaffold-wide content that's actually per-project -- so
205
+ # these are composed here instead of read from disk. .env/.env.test are
206
+ # gitignored (see base/.gitignore); .env.example is the tracked
207
+ # placeholder that file explicitly carves out.
208
+ def write_env_files!
209
+ app_name = File.basename(@dir)
210
+
211
+ dev = @postgres ? pg_env_lines("#{app_name}_development") : []
212
+ test = @postgres ? pg_env_lines("#{app_name}_test") : []
213
+ example = @postgres ? pg_env_lines("#{app_name}_development") : []
214
+
215
+ if @auth
216
+ dev << "AUTH_SECRET=change-me-dev-secret"
217
+ test << "AUTH_SECRET=change-me-test-secret"
218
+ example << "AUTH_SECRET=change-me"
219
+ end
220
+
221
+ # REDIS_URL is deliberately absent from .env.test -- only a test that
222
+ # actually exercises RedisFanout needs it, unlike DB_NAME/AUTH_SECRET
223
+ # which every test touching persistence/auth needs.
224
+ if @redis
225
+ dev << "REDIS_URL=redis://localhost:6379/0"
226
+ example << "REDIS_URL=redis://localhost:6379/0"
227
+ end
228
+
229
+ write_lines(".env", dev)
230
+ write_lines(".env.example", example)
231
+ # --redis alone (no --postgres) has nothing to put in .env.test --
232
+ # skip it rather than write an empty, pointless file.
233
+ write_lines(".env.test", test) unless test.empty?
234
+ end
235
+
236
+ def pg_env_lines(dbname)
237
+ [
238
+ "DB_HOST=127.0.0.1",
239
+ "DB_PORT=5432",
240
+ "DB_USER=postgres",
241
+ "DB_PASSWORD=postgres",
242
+ "DB_NAME=#{dbname}",
243
+ ]
244
+ end
245
+
246
+ def write_lines(relative_path, lines)
247
+ File.write(File.join(@dir, relative_path), "#{lines.join("\n")}\n")
248
+ end
249
+
250
+ def write_setup_md!
251
+ File.write(File.join(@dir, "SETUP.md"), setup_md_content)
252
+ File.write(File.join(@dir, "SETUP.md"), live_setup_md_content, mode: "a") if @live
253
+ end
254
+
255
+ def live_setup_md_content
256
+ <<~MARKDOWN
257
+
258
+ ## Live updates (Monk::Live)
259
+
260
+ `monk new --live` wired a demo: a counter whose open tabs update by
261
+ themselves. It needs three things running, each in its own terminal:
262
+
263
+ ```bash
264
+ docker run --rm -d -p 6379:6379 --name #{File.basename(@dir)}_redis redis:7 # skip if one is already up
265
+ bin/server # the HTTP app on :9292, which publishes updates
266
+ bin/websocket_server # the sockets on :9293, which deliver them
267
+ ```
268
+
269
+ Open http://localhost:9292 in two tabs and press the button in one.
270
+
271
+ `bin/server` and `bin/websocket_server` are separate processes, so Redis
272
+ (`REDIS_URL`, already in `.env`) is what carries an update from one to
273
+ the other. Where things are:
274
+
275
+ - `config/live.rb` -- the Redis wiring and the subscribe rules (nothing
276
+ is allowed unless a rule says so).
277
+ - `config.ru` -- `POST /hit` changes state and calls `Monk::Live.patch`.
278
+ - `views/index.erb` -- `live_topic "hits"` marks what to subscribe to.
279
+ - `views/live/_hits.erb` -- the fragment that gets pushed. Partials
280
+ used this way see only their locals (`locals[:hits]`), never
281
+ `params` or the session.
282
+ - `public/js/monk_live/` -- the browser runtime, copied from the gem.
283
+ The layout points at it and at `LIVE_WS_URL` (default
284
+ `ws://localhost:9293`; use `wss://` in production).
285
+
286
+ `WS_ALLOWED_ORIGINS` (default `http://localhost:9292`) must list the
287
+ origin your pages are served from, or the browser's socket is refused.
288
+ MARKDOWN
289
+ end
290
+
291
+ def setup_md_content
292
+ @postgres ? postgres_setup_md_content : base_setup_md_content
293
+ end
294
+
295
+ # No Postgres, so no database/containers/migrations -- but still a
296
+ # real dev-then-test walkthrough: bin/server needs nothing external,
297
+ # and "no test framework scaffolded" is just as true here as it is
298
+ # with --postgres.
299
+ def base_setup_md_content
300
+ app_name = File.basename(@dir)
301
+
302
+ <<~MARKDOWN
303
+ # Setting up #{app_name} (dev, then test)
304
+
305
+ #{base_setup_md_intro(app_name)}
306
+
307
+ ## Dev environment, first run
308
+
309
+ ```bash
310
+ bundle install
311
+ bin/server # HTTP app on :9292 -> http://localhost:9292/hello
312
+ bin/websocket_server # WS chat process on :9293, in another terminal
313
+ ```
314
+ #{redis_only_note}
315
+ ## Test environment
316
+
317
+ `monk new` scaffolds no test framework at all -- this is the minimum to
318
+ get `bundle exec rake test` working, using Minitest (matches `monk`'s
319
+ own suite):
320
+
321
+ **Gemfile** -- add:
322
+
323
+ ```ruby
324
+ group :test do
325
+ gem "minitest"
326
+ gem "rake"
327
+ end
328
+ ```
329
+
330
+ **test/test_helper.rb**:
331
+
332
+ ```ruby
333
+ $LOAD_PATH.unshift(File.expand_path("..", __dir__))
334
+
335
+ ENV["MONK_ENV"] ||= "test"
336
+
337
+ require "minitest/autorun"
338
+ require_relative "../config/settings"
339
+ ```
340
+
341
+ **Rakefile**:
342
+
343
+ ```ruby
344
+ require "rake/testtask"
345
+
346
+ Rake::TestTask.new do |t|
347
+ t.libs << "test"
348
+ t.pattern = "test/**/*_test.rb"
349
+ end
350
+
351
+ task default: :test
352
+ ```
353
+
354
+ **test/settings_test.rb** -- `config.ru`'s `class App` lives inline in a
355
+ rackup file, not a plain `.rb` a test could `require_relative`, so this
356
+ starts with what's actually requirable standalone. Extract `App` into
357
+ its own file (`require_relative`d from both `config.ru` and
358
+ `test/test_helper.rb`) once there's real app behavior worth testing
359
+ against requests:
360
+
361
+ ```ruby
362
+ require_relative "test_helper"
363
+
364
+ class SettingsTest < Minitest::Test
365
+ def test_monk_env_reads_as_test
366
+ assert_equal "test", Monk::Settings[:monk_env]
367
+ end
368
+ end
369
+ ```
370
+
371
+ Then:
372
+
373
+ ```bash
374
+ bundle install
375
+ bundle exec rake test
376
+ ```
377
+ MARKDOWN
378
+ end
379
+
380
+ def base_setup_md_intro(app_name)
381
+ unless @redis
382
+ return "No external services are scaffolded here (`monk new #{app_name}`, no " \
383
+ "`--postgres`/`--auth`/`--redis`) -- `bin/server`/`bin/websocket_server` need " \
384
+ "nothing external to run."
385
+ end
386
+
387
+ "No database is scaffolded here (`monk new #{app_name} --redis`, no `--postgres`/" \
388
+ "`--auth`) -- `bin/server` needs nothing external, but `bin/websocket_server`'s " \
389
+ "cross-process fan-out needs a reachable Redis once `REDIS_URL` (already set in " \
390
+ "the generated `.env`) is visible to it."
391
+ end
392
+
393
+ def redis_only_note
394
+ return "" unless @redis
395
+
396
+ app_name = File.basename(@dir)
397
+ "\n`.env` already sets `REDIS_URL=redis://localhost:6379/0`, turning on " \
398
+ "`bin/websocket_server`'s cross-process fan-out -- unset (or missing entirely), it " \
399
+ "runs in-process only. Check `docker ps` first in case a Redis container is already " \
400
+ "running from another project; otherwise start one: `docker run --rm -d -p 6379:6379 " \
401
+ "--name #{app_name}_redis redis:7`.\n"
402
+ end
403
+
404
+ def postgres_setup_md_content
405
+ app_name = File.basename(@dir)
406
+
407
+ <<~MARKDOWN
408
+ # Setting up #{app_name} (dev, then test)
409
+
410
+ `config.ru`, `.env`, and `.env.test` are already wired up by `monk new`
411
+ -- `config.ru` requires `config/#{@auth ? "auth" : "persistence"}` before `class App`, and
412
+ `.env`/`.env.test` are pre-filled with a database name derived from
413
+ this project's directory (`#{app_name}_development` / `#{app_name}_test`), not the
414
+ generic `app_development` fallback baked into `config/persistence.rb`
415
+ itself. Adjust `DB_HOST`/`DB_USER`/`DB_PASSWORD` in both files if your
416
+ local Postgres doesn't use the `postgres`/`postgres` defaults.
417
+
418
+ `MONK_ENV` and the database name are two separate, unlinked knobs --
419
+ setting `MONK_ENV=test` does not by itself change which database you
420
+ connect to. Only `DB_NAME` (here, via `.env`/`.env.test`) does that.
421
+
422
+ ## Dev environment, first run
423
+
424
+ ```bash
425
+ bundle install
426
+ ```
427
+
428
+ ### 1. Start Postgres#{@redis ? " and Redis" : ""}
429
+
430
+ Check first whether a Postgres#{@redis ? "/Redis" : ""} container is already running from another
431
+ project -- starting a second one bound to the same host port fails with
432
+ `port is already allocated`:
433
+
434
+ ```bash
435
+ docker ps
436
+ ```
437
+
438
+ If something's already listening on 5432#{@redis ? "/6379" : ""}, reuse it instead of starting a new
439
+ one -- point `.env`/`.env.test`'s `DB_HOST`/`DB_PORT`#{@redis ? "/`REDIS_URL`" : ""} at it, and use its
440
+ actual `DB_USER`/`DB_PASSWORD` rather than the generated defaults.
441
+ Otherwise, start #{@redis ? "fresh containers" : "a fresh container"}:
442
+
443
+ ```bash
444
+ docker run --rm -d -p 5432:5432 -e POSTGRES_PASSWORD=postgres --name #{app_name}_pg postgres:16
445
+ #{"docker run --rm -d -p 6379:6379 --name #{app_name}_redis redis:7\n" if @redis}```
446
+
447
+ ### 2. Create the dev database
448
+
449
+ Plain `createdb` connects over the local Unix socket by default, which a
450
+ Docker container never provides -- use `-h`/`-p` to force a TCP
451
+ connection, or run `createdb` from inside the container instead to avoid
452
+ passing a password on the command line:
453
+
454
+ ```bash
455
+ # via TCP (adjust -h/-p/-U to match whatever's actually running):
456
+ PGPASSWORD=postgres createdb -h 127.0.0.1 -p 5432 -U postgres #{app_name}_development
457
+
458
+ # or, from inside an existing Postgres container, as the postgres user:
459
+ docker exec -u postgres <container_name> createdb #{app_name}_development
460
+ ```
461
+
462
+ ### 3. Run migrations
463
+
464
+ ```bash
465
+ bin/setup_db
466
+ ```
467
+
468
+ ### 4. Run it
469
+
470
+ ```bash
471
+ bin/server # HTTP app on :9292
472
+ bin/websocket_server # WS chat process on :9293, in another terminal
473
+ ```
474
+ #{auth_or_redis_confirmation_note}
475
+ ## Test environment
476
+
477
+ ### 1. Create the test database
478
+
479
+ ```bash
480
+ PGPASSWORD=postgres createdb -h 127.0.0.1 -p 5432 -U postgres #{app_name}_test
481
+
482
+ # or, from inside an existing Postgres container, as the postgres user:
483
+ docker exec -u postgres <container_name> createdb #{app_name}_test
484
+ ```
485
+
486
+ ### 2. Migrate the test database
487
+
488
+ ```bash
489
+ DB_NAME=#{app_name}_test bin/setup_db
490
+ ```
491
+
492
+ ### 3. Add a test framework and run it
493
+
494
+ `monk new` scaffolds no test framework at all -- this is the minimum to
495
+ get `bundle exec rake test` working, using Minitest (matches `monk`'s
496
+ own suite):
497
+
498
+ **Gemfile** -- add:
499
+
500
+ ```ruby
501
+ group :test do
502
+ gem "minitest"
503
+ gem "rake"
504
+ end
505
+ ```
506
+
507
+ **test/test_helper.rb** -- loads `.env.test` explicitly (not the default
508
+ dotenv-in-`config/settings.rb` path, which only loads plain `.env`), then
509
+ wires up the app config the same way `config.ru` does:
510
+
511
+ ```ruby
512
+ $LOAD_PATH.unshift(File.expand_path("..", __dir__))
513
+
514
+ ENV["MONK_ENV"] ||= "test"
515
+
516
+ require "dotenv"
517
+ Dotenv.load(File.expand_path(".env.test", __dir__ + "/.."))
518
+
519
+ require "minitest/autorun"
520
+ require_relative "../config/settings"
521
+ require_relative "../config/#{@auth ? "auth" : "persistence"}"
522
+ ```
523
+
524
+ **Rakefile**:
525
+
526
+ ```ruby
527
+ require "rake/testtask"
528
+
529
+ Rake::TestTask.new do |t|
530
+ t.libs << "test"
531
+ t.pattern = "test/**/*_test.rb"
532
+ end
533
+
534
+ task default: :test
535
+ ```
536
+
537
+ **test/persistence_test.rb** -- a real smoke test, not just a
538
+ connectivity check:
539
+
540
+ ```ruby
541
+ require_relative "test_helper"
542
+
543
+ class PersistenceTest < Minitest::Test
544
+ #{sample_test_body}
545
+ end
546
+ ```
547
+
548
+ Then:
549
+
550
+ ```bash
551
+ bundle install
552
+ bundle exec rake test
553
+ ```
554
+
555
+ `test/test_helper.rb` sets `MONK_ENV=test` itself (so `Monk.env.test?`
556
+ reads correctly if the app ever branches on it) and loads `.env.test` for
557
+ the actual connection details -- but per the note at the top, those are
558
+ two separate knobs: `MONK_ENV` doesn't affect `DB_NAME` on its own,
559
+ `.env.test`'s `DB_NAME=#{app_name}_test` is what actually points tests at
560
+ the right database.
561
+ MARKDOWN
562
+ end
563
+
564
+ def auth_or_redis_confirmation_note
565
+ return "" unless @auth || @redis
566
+
567
+ flags = [("authenticate: true" if @auth), ("redis fan-out: on" if @redis)].compact.join(", ")
568
+ visible = if @auth && @redis
569
+ "both `AUTH_SECRET` and `REDIS_URL` are"
570
+ else
571
+ @auth ? "`AUTH_SECRET` is" : "`REDIS_URL` is"
572
+ end
573
+
574
+ "\n`bin/websocket_server`'s startup line should print `#{flags}` once " \
575
+ "#{visible} visible to it -- that confirms everything's actually wired up.\n"
576
+ end
577
+
578
+ def sample_test_body
579
+ if @auth
580
+ <<~RUBY.chomp.gsub(/^/, " ")
581
+ def test_connects_to_the_test_database_and_sees_the_auth_tables
582
+ Monk::Persistence::Pg.checkout(:primary) do |conn|
583
+ result = conn.exec("SELECT to_regclass('login_tokens') IS NOT NULL AS present")
584
+ assert_equal true, result[0]["present"]
585
+ end
586
+ end
587
+ RUBY
588
+ else
589
+ <<~RUBY.chomp.gsub(/^/, " ")
590
+ def test_connects_to_the_test_database
591
+ Monk::Persistence::Pg.checkout(:primary) do |conn|
592
+ assert_equal "1", conn.exec("SELECT 1").getvalue(0, 0)
593
+ end
594
+ end
595
+ RUBY
596
+ end
597
+ end
598
+
599
+ def write_file(relative_path, template_path, executable: false)
600
+ destination = File.join(@dir, relative_path)
601
+ FileUtils.mkdir_p(File.dirname(destination))
602
+ FileUtils.cp(File.join(TEMPLATES_DIR, template_path), destination)
603
+ File.chmod(0o755, destination) if executable
604
+ end
605
+ end
606
+ end