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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +581 -0
- data/LICENSE.txt +21 -0
- data/README.md +88 -0
- data/exe/monk +116 -0
- data/lib/monk/assets.rb +197 -0
- data/lib/monk/auth/errors.rb +13 -0
- data/lib/monk/auth/helpers.rb +76 -0
- data/lib/monk/auth/login_token.rb +11 -0
- data/lib/monk/auth/rate_limiter.rb +46 -0
- data/lib/monk/auth/session.rb +11 -0
- data/lib/monk/auth.rb +301 -0
- data/lib/monk/base.rb +323 -0
- data/lib/monk/context.rb +78 -0
- data/lib/monk/environment.rb +50 -0
- data/lib/monk/errors.rb +37 -0
- data/lib/monk/freeze_hooks.rb +23 -0
- data/lib/monk/live/client/idiomorph.LICENSE +13 -0
- data/lib/monk/live/client/idiomorph.js +4 -0
- data/lib/monk/live/client/monk_live.js +204 -0
- data/lib/monk/live/client/protocol.js +87 -0
- data/lib/monk/live/envelope.rb +51 -0
- data/lib/monk/live/errors.rb +9 -0
- data/lib/monk/live/helpers.rb +22 -0
- data/lib/monk/live/policy.rb +58 -0
- data/lib/monk/live/publisher.rb +91 -0
- data/lib/monk/live/renderer.rb +47 -0
- data/lib/monk/live/session.rb +121 -0
- data/lib/monk/live.rb +96 -0
- data/lib/monk/log.rb +130 -0
- data/lib/monk/persistence/errors.rb +7 -0
- data/lib/monk/persistence/model.rb +41 -0
- data/lib/monk/persistence/pg/errors.rb +4 -0
- data/lib/monk/persistence/pg/migrator.rb +165 -0
- data/lib/monk/persistence/pg/model.rb +233 -0
- data/lib/monk/persistence/pg.rb +34 -0
- data/lib/monk/persistence.rb +113 -0
- data/lib/monk/scaffold.rb +606 -0
- data/lib/monk/settings.rb +151 -0
- data/lib/monk/state_ractor.rb +45 -0
- data/lib/monk/templates/auth/config/auth.rb +28 -0
- data/lib/monk/templates/auth/db/migrate/00000000000001_create_auth_tables.down.sql +2 -0
- data/lib/monk/templates/auth/db/migrate/00000000000001_create_auth_tables.up.sql +18 -0
- data/lib/monk/templates/base/.dockerignore +5 -0
- data/lib/monk/templates/base/.gitignore +4 -0
- data/lib/monk/templates/base/.ruby-version +1 -0
- data/lib/monk/templates/base/Dockerfile +28 -0
- data/lib/monk/templates/base/Gemfile +7 -0
- data/lib/monk/templates/base/bin/server +5 -0
- data/lib/monk/templates/base/bin/websocket_server +62 -0
- data/lib/monk/templates/base/config/settings.rb +30 -0
- data/lib/monk/templates/base/config.ru +13 -0
- data/lib/monk/templates/base/public/css/app.css +17 -0
- data/lib/monk/templates/base/public/js/app.js +5 -0
- data/lib/monk/templates/base/views/index.erb +6 -0
- data/lib/monk/templates/base/views/layouts/app.erb +18 -0
- data/lib/monk/templates/live/bin/websocket_server +30 -0
- data/lib/monk/templates/live/config/live.rb +47 -0
- data/lib/monk/templates/live/config.ru +27 -0
- data/lib/monk/templates/live/views/index.erb +18 -0
- data/lib/monk/templates/live/views/live/_hits.erb +1 -0
- data/lib/monk/templates/postgres/Dockerfile +30 -0
- data/lib/monk/templates/postgres/Gemfile.extra +2 -0
- data/lib/monk/templates/postgres/bin/console +7 -0
- data/lib/monk/templates/postgres/bin/migrate +22 -0
- data/lib/monk/templates/postgres/bin/setup_db +9 -0
- data/lib/monk/templates/postgres/config/persistence.rb +10 -0
- data/lib/monk/templates/redis/Gemfile.extra +1 -0
- data/lib/monk/version.rb +9 -0
- data/lib/monk/views.rb +175 -0
- data/lib/monk/websocket/connection.rb +226 -0
- data/lib/monk/websocket/errors.rb +9 -0
- data/lib/monk/websocket/frame.rb +71 -0
- data/lib/monk/websocket/handshake.rb +77 -0
- data/lib/monk/websocket/redis_fanout.rb +103 -0
- data/lib/monk/websocket/registry.rb +92 -0
- data/lib/monk/websocket/server.rb +234 -0
- data/lib/monk/websocket.rb +19 -0
- data/lib/monk.rb +45 -0
- metadata +252 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
module Monk
|
|
2
|
+
module Live
|
|
3
|
+
# Renders a Monk::Views template to a String fragment, for pushing to
|
|
4
|
+
# open browsers (ADR 0010) -- no HTTP response, no layout, and no
|
|
5
|
+
# request behind it: a detached Monk::Context, so a live partial can
|
|
6
|
+
# only see what it's given as locals. Callable from any Ractor once
|
|
7
|
+
# views are frozen (Phase 0 spike: ~3us per render).
|
|
8
|
+
module Renderer
|
|
9
|
+
def self.render(partial, **locals)
|
|
10
|
+
# Context#render's own `layout:` keyword would swallow this
|
|
11
|
+
# silently and render the wrong thing (a layout around a
|
|
12
|
+
# fragment), so it's rejected rather than treated as a local.
|
|
13
|
+
if locals.key?(:layout)
|
|
14
|
+
raise ArgumentError, "`layout` is reserved: a live fragment is never wrapped in a layout"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
ensure_frozen!
|
|
18
|
+
html = Monk::Context.new({}).render(partial, layout: false, **locals)
|
|
19
|
+
# A plain, frozen String, not Views::Raw: frozen means Ractor-
|
|
20
|
+
# shareable, so a broadcast hands every port a reference instead
|
|
21
|
+
# of a copy (ADR 0010, measured in the Phase 0 fan-out spike).
|
|
22
|
+
String.new(html).freeze
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Views are compiled and sealed by Monk.freeze! in the main Ractor.
|
|
26
|
+
# Unfrozen, a render from a non-main Ractor dies with an opaque
|
|
27
|
+
# Ractor::IsolationError from inside Monk::Views, and in the main
|
|
28
|
+
# Ractor it would work by accident and then fail in production --
|
|
29
|
+
# so both cases fail here, with the fix in the message (ADR 0003).
|
|
30
|
+
def self.ensure_frozen!
|
|
31
|
+
return if Ractor.shareable?(Monk::Views.registry)
|
|
32
|
+
|
|
33
|
+
raise_not_frozen
|
|
34
|
+
rescue Ractor::IsolationError
|
|
35
|
+
raise_not_frozen
|
|
36
|
+
end
|
|
37
|
+
private_class_method :ensure_frozen!
|
|
38
|
+
|
|
39
|
+
def self.raise_not_frozen
|
|
40
|
+
raise Monk::Live::NotFrozenError,
|
|
41
|
+
"Monk::Live can't render before views are frozen -- call Monk.freeze! (or Monk.boot(app)) " \
|
|
42
|
+
"in the main Ractor first"
|
|
43
|
+
end
|
|
44
|
+
private_class_method :raise_not_frozen
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module Monk
|
|
4
|
+
module Live
|
|
5
|
+
# One WebSocket connection's live state, running inside its own
|
|
6
|
+
# connection Ractor: the subscribe/unsubscribe protocol, the topic
|
|
7
|
+
# authorization check, and the relay that stamps each published
|
|
8
|
+
# envelope with this connection's own `seq` before writing it.
|
|
9
|
+
#
|
|
10
|
+
# Client -> server: {"op":"subscribe"|"unsubscribe","topics":[...]}.
|
|
11
|
+
# Replies: subscribed (+ denied), unsubscribed, or error/bad_message.
|
|
12
|
+
# A denial never says why or whether the topic exists.
|
|
13
|
+
#
|
|
14
|
+
# `connection` is anything with #read, #write and #subject.
|
|
15
|
+
class Session
|
|
16
|
+
# No `*`, no whitespace or NUL, ASCII only: a client can't smuggle a
|
|
17
|
+
# glob or a delimiter into a topic, and only a topic that passed this
|
|
18
|
+
# and the policy is ever turned into a Symbol.
|
|
19
|
+
TOPIC_FORMAT = %r{\A[\w:.\-/@]{1,200}\z}
|
|
20
|
+
MAX_TOPICS_PER_MESSAGE = 100
|
|
21
|
+
|
|
22
|
+
def initialize(connection, registry:, rules:, max_topics:)
|
|
23
|
+
@connection = connection
|
|
24
|
+
@registry = registry
|
|
25
|
+
@rules = rules
|
|
26
|
+
@max_topics = max_topics
|
|
27
|
+
@topics = {} # String topic => the Symbol it is registered under
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def run
|
|
31
|
+
@port = Ractor::Port.new
|
|
32
|
+
@relay = Thread.new { relay }
|
|
33
|
+
while (raw = @connection.read)
|
|
34
|
+
dispatch(raw)
|
|
35
|
+
end
|
|
36
|
+
ensure
|
|
37
|
+
cleanup
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def dispatch(raw)
|
|
43
|
+
message = JSON.parse(raw, max_nesting: 5)
|
|
44
|
+
topics = message.is_a?(Hash) ? message["topics"] : nil
|
|
45
|
+
return reply(op: "error", reason: "bad_message") unless valid_topics?(topics)
|
|
46
|
+
|
|
47
|
+
case message["op"]
|
|
48
|
+
when "subscribe" then subscribe(topics)
|
|
49
|
+
when "unsubscribe" then unsubscribe(topics)
|
|
50
|
+
else reply(op: "error", reason: "bad_message")
|
|
51
|
+
end
|
|
52
|
+
rescue JSON::ParserError
|
|
53
|
+
reply(op: "error", reason: "bad_message")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def valid_topics?(topics)
|
|
57
|
+
topics.is_a?(Array) && topics.size <= MAX_TOPICS_PER_MESSAGE &&
|
|
58
|
+
topics.all? { |topic| topic.is_a?(String) && topic.valid_encoding? }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def subscribe(topics)
|
|
62
|
+
allowed, denied = topics.partition { |topic| try_subscribe?(topic) }
|
|
63
|
+
reply(op: "subscribed", topics: allowed, denied: denied)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def try_subscribe?(topic)
|
|
67
|
+
return true if @topics.key?(topic)
|
|
68
|
+
return false unless permitted?(topic) && @topics.size < @max_topics
|
|
69
|
+
|
|
70
|
+
key = topic.to_sym
|
|
71
|
+
@registry.register(key, @port)
|
|
72
|
+
@topics[topic] = key
|
|
73
|
+
true
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def permitted?(topic)
|
|
77
|
+
TOPIC_FORMAT.match?(topic) && Policy.allowed?(@rules, @connection.subject, topic)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def unsubscribe(topics)
|
|
81
|
+
removed = topics.select { |topic| @topics.key?(topic) }
|
|
82
|
+
removed.each { |topic| @registry.unregister(@topics.delete(topic), @port) }
|
|
83
|
+
reply(op: "unsubscribed", topics: removed)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def reply(message)
|
|
87
|
+
@connection.write(JSON.generate(message))
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Runs on its own thread: each published envelope gets the next
|
|
91
|
+
# per-connection seq spliced in after its opening `{`. The
|
|
92
|
+
# publisher's frozen string is shared by every subscriber (ADR
|
|
93
|
+
# 0010), so the stamp has to be a per-connection copy made here.
|
|
94
|
+
def relay
|
|
95
|
+
seq = 0
|
|
96
|
+
loop do
|
|
97
|
+
payload = @port.receive
|
|
98
|
+
seq += 1
|
|
99
|
+
@connection.write(payload.start_with?("{") ? "{\"seq\":#{seq},#{payload.byteslice(1..)}" : payload)
|
|
100
|
+
end
|
|
101
|
+
rescue Ractor::ClosedError, IOError, SystemCallError
|
|
102
|
+
# cleanup closed the port, or the socket went away first.
|
|
103
|
+
rescue StandardError => e
|
|
104
|
+
# Anything else would leave a connected client silently receiving
|
|
105
|
+
# nothing: a stale page nobody knows is stale. Closing makes the
|
|
106
|
+
# client reconnect and resync (ADR 0011), which is the recovery.
|
|
107
|
+
warn "monk-live: relay failed, closing connection: #{e.class}: #{e.message}"
|
|
108
|
+
@connection.close(code: 1011, reason: "relay failed")
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# On every exit path -- clean hang-up, close handshake, a crashed
|
|
112
|
+
# read -- so a connection never leaves a stale registry entry.
|
|
113
|
+
def cleanup
|
|
114
|
+
@relay&.kill
|
|
115
|
+
@topics.each_value { |key| @registry.unregister(key, @port) }
|
|
116
|
+
@topics.clear
|
|
117
|
+
@port&.close
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
data/lib/monk/live.rb
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
require_relative "../monk"
|
|
2
|
+
# Live sits on top of the WebSocket layer (ADR 0008): loading one loads the
|
|
3
|
+
# other, so a WS process or a publisher-only one needn't remember both.
|
|
4
|
+
require_relative "websocket"
|
|
5
|
+
require_relative "live/errors"
|
|
6
|
+
require_relative "live/renderer"
|
|
7
|
+
require_relative "live/envelope"
|
|
8
|
+
require_relative "live/publisher"
|
|
9
|
+
require_relative "live/policy"
|
|
10
|
+
require_relative "live/session"
|
|
11
|
+
require_relative "live/helpers"
|
|
12
|
+
|
|
13
|
+
# Opt-in, like Monk::WebSocket and Monk::Auth: require "monk/live"
|
|
14
|
+
# explicitly. `require "monk"` alone must not load this (ADR 0008).
|
|
15
|
+
module Monk
|
|
16
|
+
Context.include(Live::Helpers)
|
|
17
|
+
|
|
18
|
+
module Live
|
|
19
|
+
# Eager, never `@x ||=`: a lazy reader writes on first access, which is
|
|
20
|
+
# an isolation error from a non-main Ractor (same rule as Monk::Views).
|
|
21
|
+
@publisher = nil
|
|
22
|
+
@registry = nil
|
|
23
|
+
@rules = [].freeze
|
|
24
|
+
@max_topics = 100
|
|
25
|
+
|
|
26
|
+
# Handed to Monk::WebSocket::Server#run in a WS process:
|
|
27
|
+
# server.run(&Monk::Live::HANDLER)
|
|
28
|
+
# Built here, in the module body, where self is shareable, as the
|
|
29
|
+
# server requires.
|
|
30
|
+
HANDLER = proc do |connection|
|
|
31
|
+
registry = Monk::Live.registry || raise(Monk::Live::NotConfiguredError,
|
|
32
|
+
"Monk::Live isn't configured -- call Monk::Live.configure(registry: ...) at boot",)
|
|
33
|
+
Monk::Live::Session.new(
|
|
34
|
+
connection, registry: registry, rules: Monk::Live.rules, max_topics: Monk::Live.max_topics,
|
|
35
|
+
).run
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
CLIENT_DIR = File.expand_path("live/client", __dir__).freeze
|
|
39
|
+
|
|
40
|
+
class << self
|
|
41
|
+
attr_reader :publisher, :registry, :rules, :max_topics
|
|
42
|
+
|
|
43
|
+
# Where the browser runtime ships inside the gem: monk_live.js,
|
|
44
|
+
# protocol.js and idiomorph.js (they import each other by relative
|
|
45
|
+
# path, so an app serves or copies the directory as a unit).
|
|
46
|
+
def client_dir = CLIENT_DIR
|
|
47
|
+
|
|
48
|
+
# Boot-time, main Ractor. `registry` is a Monk::WebSocket::Registry,
|
|
49
|
+
# or a RedisFanout wrapping one when publishing has to reach WS
|
|
50
|
+
# connections in another process. It must be Ractor-shareable (both
|
|
51
|
+
# are), since request workers read the publisher from their own
|
|
52
|
+
# Ractors -- checked here, at boot, rather than on a live request.
|
|
53
|
+
def configure(registry:, max_topics: 100)
|
|
54
|
+
publisher = Publisher.new(registry)
|
|
55
|
+
unless Ractor.shareable?(publisher)
|
|
56
|
+
raise ArgumentError,
|
|
57
|
+
"Monk::Live.configure(registry:) needs a Ractor-shareable registry, got #{registry.class}"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
@publisher = publisher
|
|
61
|
+
@registry = registry
|
|
62
|
+
@max_topics = max_topics
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Boot-time, main Ractor. See Monk::Live::Policy for the semantics:
|
|
66
|
+
# deny by default, first matching rule wins, anonymous subjects need
|
|
67
|
+
# `anonymous: true`. The block must be Ractor-shareable.
|
|
68
|
+
def authorize(pattern, anonymous: false, &block)
|
|
69
|
+
@rules = Ractor.make_shareable([*@rules, Policy.build_rule(pattern, anonymous, block)])
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def authorized?(subject, topic) = Policy.allowed?(rules, subject, topic)
|
|
73
|
+
|
|
74
|
+
# Test-only, like Monk::Views.reset!.
|
|
75
|
+
def reset!
|
|
76
|
+
@publisher = nil
|
|
77
|
+
@registry = nil
|
|
78
|
+
@rules = [].freeze
|
|
79
|
+
@max_topics = 100
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def patch(topic, **) = current.patch(topic, **)
|
|
83
|
+
def append(topic, **) = current.append(topic, **)
|
|
84
|
+
def prepend(topic, **) = current.prepend(topic, **)
|
|
85
|
+
def remove(topic, **) = current.remove(topic, **)
|
|
86
|
+
def batch(topic, &) = current.batch(topic, &)
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def current
|
|
91
|
+
publisher || raise(Monk::Live::NotConfiguredError,
|
|
92
|
+
"Monk::Live isn't configured -- call Monk::Live.configure(registry: ...) at boot",)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
data/lib/monk/log.rb
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
|
|
3
|
+
require_relative "freeze_hooks"
|
|
4
|
+
require_relative "environment"
|
|
5
|
+
require_relative "settings"
|
|
6
|
+
|
|
7
|
+
module Monk
|
|
8
|
+
# One line per request, appended to log/<env>.log -- log/development.log,
|
|
9
|
+
# log/test.log, log/production.log, log/staging.log, Rails-style. Runs
|
|
10
|
+
# alongside Base#log_request's $stdout line, not instead of it: stdout
|
|
11
|
+
# stays development-only (a human tailing a dev console), but the file
|
|
12
|
+
# is unconditional -- every environment gets one, including production
|
|
13
|
+
# and test.
|
|
14
|
+
#
|
|
15
|
+
# A File isn't Ractor-shareable the way $stdout is: Ruby special-cases
|
|
16
|
+
# $stdout/$stderr/$stdin for cross-Ractor use, but a File this module
|
|
17
|
+
# opens itself gets no such treatment. So unlike Assets' one frozen
|
|
18
|
+
# manifest every worker reads, there's no single handle every worker can
|
|
19
|
+
# share, and a class ivar won't do either -- routes freezes solid at
|
|
20
|
+
# Boot, and a plain File isn't shareable enough to freeze into one.
|
|
21
|
+
# Instead, each worker Ractor opens its own append-mode handle to the
|
|
22
|
+
# same path, lazily, on its first write, and keeps it for the rest of
|
|
23
|
+
# its life in Ractor-local storage. Concurrent O_APPEND writers to the
|
|
24
|
+
# same path need no extra locking -- the same guarantee #log_request
|
|
25
|
+
# already relies on for several worker Ractors sharing $stdout.
|
|
26
|
+
module Log
|
|
27
|
+
DEFAULT_ROOT = "log".freeze
|
|
28
|
+
DEFAULT_LEVEL = "info".freeze
|
|
29
|
+
|
|
30
|
+
# Least to most severe -- the same list Settings validates LOG_LEVEL
|
|
31
|
+
# against, so a level Settings would reject can never reach #enabled?.
|
|
32
|
+
LEVELS = Settings::LOG_LEVEL_VALUES
|
|
33
|
+
|
|
34
|
+
class << self
|
|
35
|
+
attr_reader :root
|
|
36
|
+
attr_writer :root
|
|
37
|
+
|
|
38
|
+
# Called from Base#freeze! via Monk.freeze_hooks, the same seam
|
|
39
|
+
# Assets/Settings use. Creates log/ once, in the main Ractor, and
|
|
40
|
+
# freezes the path a worker will later append to -- unfrozen, a
|
|
41
|
+
# worker reading it back would raise Ractor::IsolationError before
|
|
42
|
+
# ever opening a handle.
|
|
43
|
+
#
|
|
44
|
+
# Runs after Settings' own freeze_registry! (registered first, in
|
|
45
|
+
# monk.rb's require order), so Settings[:log_level] is already the
|
|
46
|
+
# frozen, boot-validated value by the time this reads it.
|
|
47
|
+
def freeze_registry!
|
|
48
|
+
FileUtils.mkdir_p(root)
|
|
49
|
+
@path = File.join(root, "#{Monk.env}.log").freeze
|
|
50
|
+
@threshold = Settings[:log_level]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Test-only. Also drops *this* Ractor's memoized handle -- tests
|
|
54
|
+
# all run in the main Ractor, so without this a second #with_log
|
|
55
|
+
# in the same process would keep writing into the first one's
|
|
56
|
+
# (by then torn-down) tmpdir instead of picking up the new @path.
|
|
57
|
+
def reset!
|
|
58
|
+
@root = DEFAULT_ROOT
|
|
59
|
+
@path = nil
|
|
60
|
+
@threshold = DEFAULT_LEVEL
|
|
61
|
+
if (handle = Ractor.current[:monk_log_handle])
|
|
62
|
+
handle.close
|
|
63
|
+
Ractor.current[:monk_log_handle] = nil
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def write(line)
|
|
68
|
+
handle.write(line)
|
|
69
|
+
handle.flush
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# UTC, millisecond precision, ISO 8601 -- sortable as plain text and
|
|
73
|
+
# unambiguous across machines/timezones. Shared by #log below and by
|
|
74
|
+
# Base#log_request, so the access log and app-level log lines carry
|
|
75
|
+
# the same stamp format.
|
|
76
|
+
def timestamp
|
|
77
|
+
Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%3NZ")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# App-level logging, one line per call, gated by :log_level (default
|
|
81
|
+
# "info" -- see Settings::DEFAULT_DECLARATIONS). Below the configured
|
|
82
|
+
# threshold, a call is a no-op: cheap enough to leave debug logging
|
|
83
|
+
# in place rather than stripping it per environment. Unlike #write,
|
|
84
|
+
# never echoed to $stdout -- that's Base#log_request's own concern,
|
|
85
|
+
# gated on Monk.env instead.
|
|
86
|
+
#
|
|
87
|
+
# Four plain, hand-written methods, not one define_method(&block) per
|
|
88
|
+
# LEVELS entry -- a method backed by a Proc closure raises "defined
|
|
89
|
+
# with an un-shareable Proc in a different Ractor" the first time a
|
|
90
|
+
# worker Ractor other than the one that defined it calls it, same as
|
|
91
|
+
# Environment (see its comment) ran into for MONK_ENV_VALUES.
|
|
92
|
+
def debug(message)
|
|
93
|
+
log("debug", message)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def info(message)
|
|
97
|
+
log("info", message)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def warn(message)
|
|
101
|
+
log("warn", message)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def error(message)
|
|
105
|
+
log("error", message)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
def log(level, message)
|
|
111
|
+
return unless enabled?(level)
|
|
112
|
+
|
|
113
|
+
write("#{timestamp} #{level.upcase} #{message}\n")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def enabled?(level)
|
|
117
|
+
LEVELS.index(level) >= LEVELS.index(@threshold)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def handle
|
|
121
|
+
Ractor.current[:monk_log_handle] ||= File.open(@path, "a")
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
@root = DEFAULT_ROOT
|
|
126
|
+
@threshold = DEFAULT_LEVEL
|
|
127
|
+
|
|
128
|
+
Monk.freeze_hooks << self
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
module Monk
|
|
2
|
+
module Persistence
|
|
3
|
+
# Backend-agnostic base for persistence models: tracks subclasses and
|
|
4
|
+
# freezes their db_name/table_name at boot (Seam B), so they're
|
|
5
|
+
# readable from a worker Ractor -- freezing the class itself would do
|
|
6
|
+
# nothing, since Class/Module objects are always Ractor.shareable?
|
|
7
|
+
# regardless of their instance variables. A concrete backend (e.g.
|
|
8
|
+
# Monk::Persistence::Pg::Model, loaded separately) subclasses this and
|
|
9
|
+
# adds the actual CRUD methods; nothing here is CRUD-specific or
|
|
10
|
+
# backend-specific.
|
|
11
|
+
class Model
|
|
12
|
+
class << self
|
|
13
|
+
attr_accessor :db_name, :table_name
|
|
14
|
+
|
|
15
|
+
def inherited(subclass)
|
|
16
|
+
super
|
|
17
|
+
Model.subclasses << subclass
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def subclasses
|
|
21
|
+
@subclasses ||= []
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Called from Base#freeze! (Seam B). Deliberately does NOT check
|
|
25
|
+
# whether db_name is registered with a backend -- that registry is
|
|
26
|
+
# process-global, not scoped to whichever Base subclass happens to
|
|
27
|
+
# be booting, so that check would produce false failures (e.g. a
|
|
28
|
+
# Model used by one app tripping another app's boot before its own
|
|
29
|
+
# db is registered).
|
|
30
|
+
def freeze_all!
|
|
31
|
+
Model.subclasses.each do |subclass|
|
|
32
|
+
subclass.db_name = Ractor.make_shareable(subclass.db_name)
|
|
33
|
+
subclass.table_name = Ractor.make_shareable(subclass.table_name)
|
|
34
|
+
rescue Ractor::Error => e
|
|
35
|
+
raise Monk::UnshareableModelError, "#{subclass} is not Ractor-shareable: #{e.message}"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
require_relative "../pg"
|
|
2
|
+
require_relative "errors"
|
|
3
|
+
|
|
4
|
+
module Monk
|
|
5
|
+
module Persistence
|
|
6
|
+
module Pg
|
|
7
|
+
# Applies/rolls back versioned .sql file pairs against a registered
|
|
8
|
+
# Monk::Persistence::Pg database. Deliberately not a DSL: each
|
|
9
|
+
# <version>_<name>.up.sql / .down.sql pair is sent to Postgres
|
|
10
|
+
# verbatim -- see docs/history/plan-migrations.md for the full rationale.
|
|
11
|
+
class Migrator
|
|
12
|
+
FILENAME = /\A(\d+)_(.+)\.(up|down)\.sql\z/
|
|
13
|
+
|
|
14
|
+
Migration = Struct.new(:version, :name, :up_path, :down_path)
|
|
15
|
+
|
|
16
|
+
attr_reader :migrations
|
|
17
|
+
|
|
18
|
+
def initialize(db_name:, dir: "db/migrate")
|
|
19
|
+
@db_name = db_name
|
|
20
|
+
@dir = dir
|
|
21
|
+
@migrations = load_migrations
|
|
22
|
+
@schema_migrations_ready = false
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Runs every pending .up.sql (its version absent from
|
|
26
|
+
# schema_migrations) in ascending order, each inside its own
|
|
27
|
+
# transaction, recording the version only after that transaction
|
|
28
|
+
# commits. A failing statement rolls back just that migration and
|
|
29
|
+
# halts the run -- later pending migrations are never attempted.
|
|
30
|
+
# Returns the versions actually applied.
|
|
31
|
+
def migrate!
|
|
32
|
+
applied = []
|
|
33
|
+
|
|
34
|
+
Monk::Persistence::Pg.checkout(@db_name) do |conn|
|
|
35
|
+
ensure_schema_migrations_table(conn)
|
|
36
|
+
already_applied = applied_versions(conn)
|
|
37
|
+
|
|
38
|
+
migrations.reject { |m| already_applied.include?(m.version) }.each do |m|
|
|
39
|
+
conn.transaction do
|
|
40
|
+
conn.exec(File.read(m.up_path))
|
|
41
|
+
conn.exec_params("INSERT INTO schema_migrations (version) VALUES ($1)", [m.version])
|
|
42
|
+
end
|
|
43
|
+
applied << m.version
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
applied
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Runs .down.sql for the `steps` most recently applied migrations,
|
|
51
|
+
# most-recent-first, each inside its own transaction, removing the
|
|
52
|
+
# version from schema_migrations only after that transaction
|
|
53
|
+
# commits. `steps` beyond the number actually applied rolls back
|
|
54
|
+
# everything and stops cleanly. Returns the versions reverted.
|
|
55
|
+
def rollback!(steps: 1)
|
|
56
|
+
reverted = []
|
|
57
|
+
|
|
58
|
+
Monk::Persistence::Pg.checkout(@db_name) do |conn|
|
|
59
|
+
ensure_schema_migrations_table(conn)
|
|
60
|
+
|
|
61
|
+
recently_applied(conn, steps).each do |version|
|
|
62
|
+
migration = migrations.find { |m| m.version == version }
|
|
63
|
+
unless migration
|
|
64
|
+
raise Monk::MalformedMigrationError,
|
|
65
|
+
"applied migration #{version.inspect} has no matching .down.sql file on disk"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
conn.transaction do
|
|
69
|
+
conn.exec(File.read(migration.down_path))
|
|
70
|
+
conn.exec_params("DELETE FROM schema_migrations WHERE version = $1", [version])
|
|
71
|
+
end
|
|
72
|
+
reverted << version
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
reverted
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Not-yet-applied versions, ascending, with no side effects.
|
|
80
|
+
def pending
|
|
81
|
+
Monk::Persistence::Pg.checkout(@db_name) do |conn|
|
|
82
|
+
ensure_schema_migrations_table(conn)
|
|
83
|
+
already_applied = applied_versions(conn)
|
|
84
|
+
migrations.reject { |m| already_applied.include?(m.version) }.map(&:version)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Already-applied versions, in the order they were applied.
|
|
89
|
+
def applied
|
|
90
|
+
Monk::Persistence::Pg.checkout(@db_name) do |conn|
|
|
91
|
+
ensure_schema_migrations_table(conn)
|
|
92
|
+
applied_versions(conn)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
def recently_applied(conn, steps)
|
|
99
|
+
conn.exec_params(
|
|
100
|
+
"SELECT version FROM schema_migrations ORDER BY applied_at DESC, version DESC LIMIT $1", [steps]
|
|
101
|
+
).map { |row| row["version"] }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Checks existence via information_schema first, rather than relying
|
|
105
|
+
# on CREATE TABLE IF NOT EXISTS's own "already exists, skipping"
|
|
106
|
+
# NOTICE -- confusing noise on every call once the table exists.
|
|
107
|
+
# Memoized per instance since a single Migrator's checks/commands
|
|
108
|
+
# often chain multiple calls together (e.g. bin/migrate status
|
|
109
|
+
# calling #applied then #pending).
|
|
110
|
+
def ensure_schema_migrations_table(conn)
|
|
111
|
+
return if @schema_migrations_ready
|
|
112
|
+
|
|
113
|
+
exists = conn.exec(
|
|
114
|
+
"SELECT 1 FROM information_schema.tables WHERE table_name = 'schema_migrations'"
|
|
115
|
+
).ntuples.positive?
|
|
116
|
+
|
|
117
|
+
conn.exec(<<~SQL) unless exists
|
|
118
|
+
CREATE TABLE schema_migrations (
|
|
119
|
+
version TEXT PRIMARY KEY,
|
|
120
|
+
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
121
|
+
)
|
|
122
|
+
SQL
|
|
123
|
+
|
|
124
|
+
@schema_migrations_ready = true
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def applied_versions(conn)
|
|
128
|
+
conn.exec("SELECT version FROM schema_migrations ORDER BY applied_at, version").map { |row| row["version"] }
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def load_migrations
|
|
132
|
+
by_version = {}
|
|
133
|
+
|
|
134
|
+
Dir.glob(File.join(@dir, "*.sql")).sort.each do |path|
|
|
135
|
+
match = FILENAME.match(File.basename(path))
|
|
136
|
+
raise Monk::MalformedMigrationError,
|
|
137
|
+
"#{path.inspect} doesn't match the <version>_<name>.(up|down).sql convention" unless match
|
|
138
|
+
|
|
139
|
+
version, name, direction = match.captures
|
|
140
|
+
migration = (by_version[version] ||= Migration.new(version, name))
|
|
141
|
+
|
|
142
|
+
if migration.name != name
|
|
143
|
+
raise Monk::MalformedMigrationError,
|
|
144
|
+
"version #{version.inspect} names two different migrations " \
|
|
145
|
+
"(#{migration.name.inspect} vs #{name.inspect})"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
direction == "up" ? migration.up_path = path : migration.down_path = path
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
ordered = by_version.values.sort_by { |m| m.version.to_i }
|
|
152
|
+
ordered.each do |m|
|
|
153
|
+
next if m.up_path && m.down_path
|
|
154
|
+
|
|
155
|
+
missing = m.up_path ? "down" : "up"
|
|
156
|
+
raise Monk::MalformedMigrationError,
|
|
157
|
+
"migration #{m.version}_#{m.name} is missing its .#{missing}.sql file"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
ordered
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|