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,233 @@
1
+ require_relative "../pg"
2
+ require_relative "../model"
3
+
4
+ module Monk
5
+ module Persistence
6
+ module Pg
7
+ # Deliberately not an ORM: no associations, no validations, no
8
+ # callbacks, no dirty-tracking, no live row objects. Every method
9
+ # takes or returns plain Hashes (Symbol-keyed), so nothing here has
10
+ # to cross a Ractor boundary as anything but copyable data.
11
+ class Model < Monk::Persistence::Model
12
+ # Maps a `where` comparison-operator key to its SQL operator.
13
+ # Deliberately a small, closed set -- not a general expression DSL.
14
+ COMPARISON_OPERATORS = {
15
+ gt: ">",
16
+ gte: ">=",
17
+ lt: "<",
18
+ lte: "<=",
19
+ ne: "<>",
20
+ }.freeze
21
+
22
+ class << self
23
+ def create(data)
24
+ Monk::Persistence::Pg.checkout(db_name) do |conn|
25
+ columns = data.keys.map { |c| conn.quote_ident(c.to_s) }
26
+ placeholders = (1..data.size).map { |i| "$#{i}" }
27
+ sql = "INSERT INTO #{conn.quote_ident(table_name)} (#{columns.join(", ")}) " \
28
+ "VALUES (#{placeholders.join(", ")}) RETURNING *"
29
+ to_row(conn.exec_params(sql, data.values))
30
+ end
31
+ end
32
+
33
+ # One INSERT, one round trip, one implicit transaction (a single
34
+ # SQL statement is atomic on its own -- all rows land or none
35
+ # do). Every Hash must have the same set of keys; order within
36
+ # each Hash doesn't matter, but a differing key set would mean
37
+ # a differing column list per row, which a single VALUES clause
38
+ # can't express -- raises rather than silently NULL-filling the
39
+ # gap, which would mask a caller bug. Row order in the result
40
+ # matches `rows`' order in every version of Postgres this has
41
+ # been checked against, but that isn't a documented SQL-standard
42
+ # guarantee for multi-row RETURNING -- don't rely on it holding
43
+ # across an exotic BEFORE INSERT trigger.
44
+ def create_all(rows)
45
+ return [] if rows.empty?
46
+
47
+ columns = rows.first.keys
48
+ unless rows.all? { |row| row.keys.map(&:to_s).sort == columns.map(&:to_s).sort }
49
+ raise ArgumentError, "create_all requires every row to have the same columns"
50
+ end
51
+
52
+ Monk::Persistence::Pg.checkout(db_name) do |conn|
53
+ quoted_columns = columns.map { |c| conn.quote_ident(c.to_s) }
54
+ values = []
55
+ row_placeholders = rows.map do |row|
56
+ placeholders = columns.map do |column|
57
+ values << row.fetch(column)
58
+ "$#{values.size}"
59
+ end
60
+ "(#{placeholders.join(", ")})"
61
+ end
62
+
63
+ sql = "INSERT INTO #{conn.quote_ident(table_name)} (#{quoted_columns.join(", ")}) " \
64
+ "VALUES #{row_placeholders.join(", ")} RETURNING *"
65
+ to_rows(conn.exec_params(sql, values))
66
+ end
67
+ end
68
+
69
+ def find(id)
70
+ Monk::Persistence::Pg.checkout(db_name) do |conn|
71
+ sql = "SELECT * FROM #{conn.quote_ident(table_name)} WHERE id = $1"
72
+ to_row(conn.exec_params(sql, [id]))
73
+ end
74
+ end
75
+
76
+ # One round trip, positionally matched to `ids` -- same length,
77
+ # same order, a `nil` in place of any id that doesn't exist
78
+ # (mirrors `find`'s single-row "nil means missing" rather than
79
+ # silently dropping the position, which would desync a caller
80
+ # zipping the result back against `ids`). A duplicate id in the
81
+ # input appears at every one of its positions in the output.
82
+ def find_all(ids)
83
+ return [] if ids.empty?
84
+
85
+ rows = where(id: ids)
86
+ by_id = rows.each_with_object({}) { |row, h| h[row[:id]] = row }
87
+ ids.map { |id| by_id[id] }
88
+ end
89
+
90
+ # AND-only -- still no OR, no arbitrary boolean trees. A condition
91
+ # value is either a scalar (equality), an Array (IN -- an empty
92
+ # Array matches no rows rather than producing invalid SQL), or a
93
+ # Hash of comparison-operator => operand (see
94
+ # COMPARISON_OPERATORS; multiple keys on one column AND together,
95
+ # e.g. `quantity: { gte: 1, lt: 10 }`). An empty conditions Hash
96
+ # means no filter (all rows), not an error: the natural
97
+ # degenerate case of zero AND'd conditions.
98
+ #
99
+ # `options[:order]` is a column Symbol/String (ascending) or a
100
+ # Hash of column => :asc/:desc for more than one column.
101
+ # `options[:limit]` is an Integer row cap. Both are optional and
102
+ # independent of conditions.
103
+ #
104
+ # `options` is a plain trailing Hash, not `order:`/`limit:`
105
+ # keyword parameters -- Ruby only lets a bare `where(col: val)`
106
+ # call collapse into the `conditions` positional Hash (the
107
+ # calling convention every existing caller uses) as long as
108
+ # `where` declares no real keyword parameters of its own; adding
109
+ # `order:`/`limit:` as keywords would make Ruby try to parse
110
+ # `col: val` as keyword arguments instead and raise.
111
+ def where(conditions, options = {})
112
+ Monk::Persistence::Pg.checkout(db_name) do |conn|
113
+ clause, values = where_clause(conn, conditions)
114
+
115
+ sql = +"SELECT * FROM #{conn.quote_ident(table_name)}"
116
+ sql << " WHERE #{clause}" if clause
117
+ sql << order_clause(conn, options[:order]) if options[:order]
118
+ if (limit = options[:limit])
119
+ raise ArgumentError, "limit must be an Integer, got #{limit.inspect}" unless limit.is_a?(Integer)
120
+
121
+ values << limit
122
+ sql << " LIMIT $#{values.size}"
123
+ end
124
+
125
+ to_rows(conn.exec_params(sql, values))
126
+ end
127
+ end
128
+
129
+ def update(id, data)
130
+ Monk::Persistence::Pg.checkout(db_name) do |conn|
131
+ sets = data.keys.each_with_index
132
+ .map { |c, i| "#{conn.quote_ident(c.to_s)} = $#{i + 2}" }.join(", ")
133
+ sql = "UPDATE #{conn.quote_ident(table_name)} SET #{sets} WHERE id = $1 RETURNING *"
134
+ to_row(conn.exec_params(sql, [id, *data.values]))
135
+ end
136
+ end
137
+
138
+ # Conditional UPDATE ... RETURNING * -- the row or nil, in one
139
+ # atomic statement instead of read-then-update, so two concurrent
140
+ # claims of the same row can't both succeed (see Model.update's
141
+ # unconditional `WHERE id = $1`, which can't express this guard).
142
+ # Equality + AND only, consistent with `where`: a `nil` condition
143
+ # value maps to `IS NULL`, not to a bound `= NULL` (which would
144
+ # never match, since SQL NULL comparisons aren't true/false).
145
+ def claim(conditions, data)
146
+ Monk::Persistence::Pg.checkout(db_name) do |conn|
147
+ sets = data.keys.each_with_index
148
+ .map { |c, i| "#{conn.quote_ident(c.to_s)} = $#{i + 1}" }.join(", ")
149
+
150
+ bound, nil_conditions = conditions.partition { |_, v| !v.nil? }
151
+ where_parts = bound.each_with_index.map { |(c, _), i| "#{conn.quote_ident(c.to_s)} = $#{data.size + i + 1}" }
152
+ where_parts += nil_conditions.map { |c, _| "#{conn.quote_ident(c.to_s)} IS NULL" }
153
+
154
+ sql = "UPDATE #{conn.quote_ident(table_name)} SET #{sets} " \
155
+ "WHERE #{where_parts.join(" AND ")} RETURNING *"
156
+ to_row(conn.exec_params(sql, data.values + bound.map(&:last)))
157
+ end
158
+ end
159
+
160
+ def delete(id)
161
+ Monk::Persistence::Pg.checkout(db_name) do |conn|
162
+ sql = "DELETE FROM #{conn.quote_ident(table_name)} WHERE id = $1"
163
+ conn.exec_params(sql, [id]).cmd_tuples.positive?
164
+ end
165
+ end
166
+
167
+ private
168
+
169
+ # Returns [clause_string_or_nil, bound_values]. Building the
170
+ # values array alongside the clause (rather than a separate
171
+ # pass) keeps each condition's placeholder index in lockstep
172
+ # with where it lands in `values`, including the multi-operator
173
+ # Hash case where one column contributes more than one param.
174
+ def where_clause(conn, conditions)
175
+ values = []
176
+ parts = conditions.map do |column, condition|
177
+ ident = conn.quote_ident(column.to_s)
178
+ case condition
179
+ when Hash
180
+ condition.map { |op, operand| comparison(ident, op, operand, values) }.join(" AND ")
181
+ when Array
182
+ in_clause(ident, condition, values)
183
+ else
184
+ values << condition
185
+ "#{ident} = $#{values.size}"
186
+ end
187
+ end
188
+ [parts.empty? ? nil : parts.join(" AND "), values]
189
+ end
190
+
191
+ def comparison(ident, op, operand, values)
192
+ operator = COMPARISON_OPERATORS.fetch(op) do
193
+ raise ArgumentError, "unsupported where operator #{op.inspect}"
194
+ end
195
+ values << operand
196
+ "#{ident} #{operator} $#{values.size}"
197
+ end
198
+
199
+ def in_clause(ident, list, values)
200
+ return "1 = 0" if list.empty?
201
+
202
+ placeholders = list.map do |item|
203
+ values << item
204
+ "$#{values.size}"
205
+ end
206
+ "#{ident} IN (#{placeholders.join(", ")})"
207
+ end
208
+
209
+ def order_clause(conn, order)
210
+ columns = order.is_a?(Hash) ? order : { order => :asc }
211
+ clause = columns.map do |column, direction|
212
+ direction = direction.to_sym
213
+ unless %i[asc desc].include?(direction)
214
+ raise ArgumentError, "order direction must be :asc or :desc, got #{direction.inspect}"
215
+ end
216
+
217
+ "#{conn.quote_ident(column.to_s)} #{direction.to_s.upcase}"
218
+ end.join(", ")
219
+ " ORDER BY #{clause}"
220
+ end
221
+
222
+ def to_rows(result)
223
+ result.map { |row| row.transform_keys(&:to_sym) }
224
+ end
225
+
226
+ def to_row(result)
227
+ to_rows(result).first
228
+ end
229
+ end
230
+ end
231
+ end
232
+ end
233
+ end
@@ -0,0 +1,34 @@
1
+ require "pg"
2
+
3
+ require_relative "../persistence"
4
+
5
+ module Monk
6
+ module Persistence
7
+ # Postgres backend. Opt-in: require "monk/persistence/pg" explicitly --
8
+ # `require "monk"` alone does not load this. Each Ractor lazily opens
9
+ # and memoizes its own PG::Connection on first access, never shared
10
+ # across Ractors (mirrors pg's own documented Ractor pattern --
11
+ # PG::Connection is explicitly not shareable and must be created fresh
12
+ # per Ractor). Concurrent access from sibling threads within the same
13
+ # Ractor is serialized through #checkout (from Registry), since a bare
14
+ # PG::Connection isn't safe for two threads to issue commands on at
15
+ # once.
16
+ module Pg
17
+ extend Monk::Persistence::Registry
18
+
19
+ class << self
20
+ private
21
+
22
+ def connect(**opts)
23
+ conn = PG.connect(**opts)
24
+ conn.type_map_for_results = PG::BasicTypeMapForResults.new(conn)
25
+ conn
26
+ end
27
+
28
+ def disconnect(conn)
29
+ conn.finish
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,113 @@
1
+ require_relative "freeze_hooks"
2
+
3
+ module Monk
4
+ # Backend-agnostic persistence. Concrete backends (e.g.
5
+ # Monk::Persistence::Pg, loaded separately -- persistence backends are
6
+ # opt-in, not required by `require "monk"`) extend Registry below and
7
+ # register themselves into Monk.freeze_hooks automatically, so Base#freeze!
8
+ # can seal every backend actually in use without needing to know their
9
+ # names.
10
+ module Persistence
11
+ # Shared by every backend module: per-Ractor connection lifecycle, a
12
+ # registry of named configs, and boot-time shareability sealing. A
13
+ # backend `extend`s this and implements #connect(**opts) /
14
+ # #disconnect(conn) (both private); registration, lookup, serialized
15
+ # checkout, and freezing are identical across backends and live here
16
+ # once.
17
+ module Registry
18
+ DEFAULT_CHECKOUT_TIMEOUT = 5 # seconds
19
+
20
+ Entry = Struct.new(:conn, :slot)
21
+
22
+ def self.extended(base)
23
+ Monk.freeze_hooks << base
24
+ end
25
+
26
+ def register(name, **opts)
27
+ configs[name] = opts
28
+ end
29
+
30
+ # Registered connection names (e.g. :primary) -- Monk.boot's log
31
+ # line reads this to report which backends an app actually uses.
32
+ def names
33
+ configs.keys
34
+ end
35
+
36
+ def [](name)
37
+ entry(name).conn
38
+ end
39
+
40
+ def checkout(name, timeout: DEFAULT_CHECKOUT_TIMEOUT)
41
+ e = entry(name)
42
+ token = e.slot.pop(timeout: timeout)
43
+ if token.nil?
44
+ raise Monk::PersistenceTimeoutError,
45
+ "timed out waiting for the #{name.inspect} connection " \
46
+ "(checkout held longer than #{timeout}s)"
47
+ end
48
+
49
+ yield e.conn
50
+ ensure
51
+ e.slot << true if token
52
+ end
53
+
54
+ # Called from Base#freeze! (Seam B), via Monk.freeze_hooks.
55
+ # Without this, #register'd configs are unreachable from any worker
56
+ # Ractor at all: @configs is a plain, unfrozen Hash, and reading an
57
+ # unfrozen value from a class/module instance variable raises
58
+ # Ractor::IsolationError from any non-main Ractor -- the same
59
+ # restriction Model.freeze_all! exists for, just on the
60
+ # connect-options registry instead of a Model's own config. Freezing
61
+ # the value (not the module) fixes it, the same way it did there.
62
+ def freeze_registry!
63
+ @configs = Ractor.make_shareable(configs)
64
+ end
65
+
66
+ # Test-only: drops all registered configs and this Ractor's cached
67
+ # connections. Not part of the app-facing API.
68
+ def reset!
69
+ Ractor.current[:monk_persistence]&.each_value do |e|
70
+ disconnect(e.conn)
71
+ rescue StandardError
72
+ end
73
+ Ractor.current[:monk_persistence] = {}
74
+ @configs = {}
75
+ end
76
+
77
+ private
78
+
79
+ def configs
80
+ @configs ||= {}
81
+ end
82
+
83
+ def ractor_local
84
+ Ractor.current[:monk_persistence] ||= {}
85
+ end
86
+
87
+ def entry(name)
88
+ ractor_local[name] ||= build_entry(name)
89
+ end
90
+
91
+ def build_entry(name)
92
+ opts = configs.fetch(name) do
93
+ raise Monk::UnknownPersistenceError,
94
+ "no database registered as #{name.inspect} -- call " \
95
+ "#{self}.register(#{name.inspect}, ...) first"
96
+ end
97
+
98
+ slot = SizedQueue.new(1)
99
+ slot << true
100
+
101
+ Entry.new(connect(**opts), slot)
102
+ end
103
+
104
+ def connect(**opts)
105
+ raise NotImplementedError, "#{self} must implement #connect(**opts)"
106
+ end
107
+
108
+ def disconnect(conn)
109
+ raise NotImplementedError, "#{self} must implement #disconnect(conn)"
110
+ end
111
+ end
112
+ end
113
+ end