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,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,7 @@
1
+ module Monk
2
+ class PersistenceTimeoutError < StandardError
3
+ end
4
+
5
+ class UnknownPersistenceError < StandardError
6
+ end
7
+ 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,4 @@
1
+ module Monk
2
+ class MalformedMigrationError < StandardError
3
+ end
4
+ 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