rivulet-rb 0.1.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 (50) hide show
  1. checksums.yaml +7 -0
  2. data/bin/rivulet +4 -0
  3. data/lib/rivulet/application.rb +90 -0
  4. data/lib/rivulet/cli/console.rb +15 -0
  5. data/lib/rivulet/cli/db/migrate.rb +17 -0
  6. data/lib/rivulet/cli/generate/handler/operation.rb +75 -0
  7. data/lib/rivulet/cli/generate/handler/step.rb +91 -0
  8. data/lib/rivulet/cli/generate/handler.rb +135 -0
  9. data/lib/rivulet/cli/generate/migration.rb +31 -0
  10. data/lib/rivulet/cli/generate/operation.rb +111 -0
  11. data/lib/rivulet/cli/generate/resource.rb +25 -0
  12. data/lib/rivulet/cli/generate/service/operation.rb +118 -0
  13. data/lib/rivulet/cli/generate/service/projection.rb +89 -0
  14. data/lib/rivulet/cli/generate/service/step.rb +91 -0
  15. data/lib/rivulet/cli/generate/service.rb +143 -0
  16. data/lib/rivulet/cli/new.rb +191 -0
  17. data/lib/rivulet/cli/routes.rb +15 -0
  18. data/lib/rivulet/cli.rb +43 -0
  19. data/lib/rivulet/container.rb +28 -0
  20. data/lib/rivulet/operation.rb +21 -0
  21. data/lib/rivulet/operations/dispatch_request.rb +21 -0
  22. data/lib/rivulet/operations/migrate.rb +23 -0
  23. data/lib/rivulet/operations/print_routes.rb +17 -0
  24. data/lib/rivulet/operations/run_console.rb +25 -0
  25. data/lib/rivulet/operations/startup.rb +23 -0
  26. data/lib/rivulet/projection.rb +6 -0
  27. data/lib/rivulet/request.rb +18 -0
  28. data/lib/rivulet/response.rb +12 -0
  29. data/lib/rivulet/routing/mapper.rb +78 -0
  30. data/lib/rivulet/routing/route.rb +14 -0
  31. data/lib/rivulet/step.rb +22 -0
  32. data/lib/rivulet/steps/build_config.rb +26 -0
  33. data/lib/rivulet/steps/build_context.rb +76 -0
  34. data/lib/rivulet/steps/compile_response.rb +113 -0
  35. data/lib/rivulet/steps/dispatch.rb +17 -0
  36. data/lib/rivulet/steps/load_app.rb +20 -0
  37. data/lib/rivulet/steps/load_db.rb +24 -0
  38. data/lib/rivulet/steps/load_routes.rb +28 -0
  39. data/lib/rivulet/steps/load_settings.rb +42 -0
  40. data/lib/rivulet/steps/print_routes.rb +123 -0
  41. data/lib/rivulet/steps/run_console.rb +26 -0
  42. data/lib/rivulet/steps/run_migrations.rb +50 -0
  43. data/lib/rivulet/steps/validate_response.rb +42 -0
  44. data/lib/rivulet/telemetry/node.rb +8 -0
  45. data/lib/rivulet/telemetry/sequel_extension.rb +19 -0
  46. data/lib/rivulet/telemetry/timing_wrapper.rb +12 -0
  47. data/lib/rivulet/telemetry.rb +62 -0
  48. data/lib/rivulet/version.rb +3 -0
  49. data/lib/rivulet.rb +66 -0
  50. metadata +342 -0
@@ -0,0 +1,123 @@
1
+ require "io/console"
2
+
3
+ module Rivulet
4
+ module Steps
5
+ class PrintRoutes < Rivulet::Step
6
+ HEADERS = ['HTTP Verb', 'Path', 'Handler#action']
7
+ GAP = 2
8
+
9
+ def call(input)
10
+ routes = input[:resource].routes
11
+
12
+ widths = calculate_widths(routes)
13
+
14
+ print_row(HEADERS, widths)
15
+ puts "" # Spacer after header
16
+
17
+ routes.each do |route|
18
+ print_route(route, widths)
19
+ end
20
+
21
+ Success(input)
22
+ end
23
+
24
+ private
25
+
26
+ def terminal_width
27
+ IO.console.winsize[1]
28
+ rescue
29
+ 80
30
+ end
31
+
32
+ def calculate_widths(routes)
33
+ cols = HEADERS.size
34
+ route_methods = [:http_method, :path, :callable]
35
+
36
+ widths = Array.new(cols, 0)
37
+
38
+ cols.times do |i|
39
+ widths[i] = [
40
+ HEADERS[i].length,
41
+ *routes.map { |r| r.send(route_methods[i]).to_s.length }
42
+ ].max
43
+ end
44
+
45
+ total_width_needed = widths.sum + GAP * (cols - 1)
46
+
47
+ return widths if total_width_needed <= terminal_width
48
+
49
+ # If too wide, shrink the last column to fit, prioritizing wrapping there.
50
+ fixed_width = widths[0..-2].sum + GAP * (cols - 2)
51
+ available_for_last = terminal_width - fixed_width - GAP
52
+
53
+ widths[-1] = [available_for_last, 20].max
54
+
55
+ # Final fallback to ensure total doesn't exceed terminal width
56
+ while (widths.sum + GAP * (cols - 1)) > terminal_width && widths[-1] > 5
57
+ widths[-
58
+ 1] -= 1
59
+ end
60
+
61
+ widths
62
+ end
63
+
64
+ def print_route(route, widths)
65
+ handler_display = route.handler_name || route.callable.inspect
66
+ row_data = [route.http_method, route.path, handler_display]
67
+ print_row(row_data, widths)
68
+ end
69
+
70
+ def print_row(cells, widths)
71
+ wrapped_cells = cells.each_with_index.map do |cell, i|
72
+ wrap(cell.to_s, widths[i])
73
+ end
74
+
75
+ num_lines = wrapped_cells.map(&:size).max
76
+
77
+ num_lines.times do |line_idx|
78
+ line_parts = wrapped_cells.each_with_index.map do |lines, col_idx|
79
+ text = lines[line_idx] || ""
80
+ text.ljust(widths[col_idx])
81
+ end
82
+
83
+ puts line_parts.join(" " * GAP)
84
+ end
85
+ end
86
+
87
+ def wrap(text, width)
88
+ return [] if text.empty?
89
+
90
+ tokens = text.split(/(\s+)/)
91
+ lines = []
92
+ current_line = ""
93
+
94
+ tokens.each do |token|
95
+ if (current_line + token).length <= width
96
+ current_line += token
97
+ else
98
+ if token.strip.empty?
99
+ lines << current_line.rstrip
100
+ current_line = ""
101
+ elsif token.length > width
102
+ parts = token.scan(/.{1,#{width}}/)
103
+ parts.each_with_index do |part, idx|
104
+ if idx == parts.size - 1
105
+ current_line += part
106
+ else
107
+ lines << (current_line + part).rstrip
108
+ current_line = ""
109
+ end
110
+ end
111
+ else
112
+ lines << current_line.rstrip
113
+ current_line = token
114
+ end
115
+ end
116
+ end
117
+
118
+ lines << current_line.rstrip unless current_line.empty?
119
+ lines.reject(&:empty?)
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,26 @@
1
+ require 'irb'
2
+ require 'irb/completion'
3
+
4
+ module Rivulet
5
+ module Steps
6
+ class RunConsole < Rivulet::Step
7
+ def call(input)
8
+ app = input[:resource]
9
+
10
+ IRB.setup(nil)
11
+
12
+ IRB.conf[:USE_AUTOCOMPLETE] = false
13
+ IRB.conf[:AP_NAME] = 'rivulet'
14
+
15
+ # workspace = IRB::WorkSpace.new(binding)
16
+ # irb = IRB::Irb.new(workspace)
17
+
18
+ # IRB.conf[:MAIN_CONTEXT] = irb.context
19
+
20
+ IRB::Irb.new.run(IRB.conf)
21
+
22
+ Success(input)
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,50 @@
1
+ module Rivulet
2
+ module Steps
3
+ class RunMigrations < Rivulet::Step
4
+ TABLE = :schema_migrations
5
+
6
+ def call(input)
7
+ db = input[:resource].db
8
+ return Failure("Database not configured") unless db
9
+
10
+ ensure_table(db)
11
+ migrations = pending(db)
12
+
13
+ if migrations.empty?
14
+ puts " up to date"
15
+ else
16
+ migrations.each do |path|
17
+ version = File.basename(path, '.sql')
18
+ db.transaction do
19
+ db.run(File.read(path))
20
+ db[TABLE].insert(version: version)
21
+ end
22
+ puts " applied #{File.basename(path)}"
23
+ end
24
+ end
25
+
26
+ Success(input)
27
+ end
28
+
29
+ private
30
+
31
+ def ensure_table(db)
32
+ db.create_table?(TABLE) do
33
+ String :version, null: false
34
+ primary_key [:version]
35
+ end
36
+ end
37
+
38
+ def pending(db)
39
+ applied = db[TABLE].select_map(:version).to_set
40
+ all_files.reject { |f| applied.include?(File.basename(f, '.sql')) }
41
+ end
42
+
43
+ def all_files
44
+ dir = File.expand_path('db/migrations')
45
+ return [] unless Dir.exist?(dir)
46
+ Dir[File.join(dir, '*.sql')].sort
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,42 @@
1
+ module Rivulet
2
+ module Steps
3
+ class ValidateResponse < Rivulet::Step
4
+ NO_BODY_STATUSES = [204, 304].freeze
5
+ FORMAT_VALUES = %i[json text file stream as_is].freeze
6
+
7
+ def call(input)
8
+ input => { response:, route: }
9
+
10
+ unless response.is_a? Rivulet::Response
11
+ return Failure[:wrong_response_type, "Invalid response type for #{route.path}"]
12
+ end
13
+
14
+ if NO_BODY_STATUSES.include?(response.status) && !response.body.nil?
15
+ return Failure[:conflicting_response, "Status #{response.status} has body #{response.body}"]
16
+ end
17
+
18
+ unless FORMAT_VALUES.include?(response.format)
19
+ return Failure[:wrong_response_format, "Unsupported response format #{response.format.inspect}"]
20
+ end
21
+
22
+ if response.format == :stream && !io_like?(response.body)
23
+ return Failure[:wrong_response_type, 'Response body is not supported for stream format']
24
+ end
25
+
26
+ if response.format == :file
27
+ body = response.body
28
+ return Failure[:wrong_response_type, "File body requires :path key"] if body.is_a?(Hash) && !body.key?(:path)
29
+ return Failure[:wrong_response_type, "Response body is not supported for file format"] unless body.is_a?(String) || body.is_a?(Hash)
30
+ end
31
+
32
+ Success(input)
33
+ end
34
+
35
+ private
36
+
37
+ def io_like?(obj)
38
+ %i[gets each read rewind].all? { obj.respond_to?(_1) }
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,8 @@
1
+ module Rivulet
2
+ class Telemetry
3
+ Node = Struct.new(
4
+ :name, :started_at, :ended_at, :duration_ms,
5
+ :self_ms, :children, keyword_init: true
6
+ )
7
+ end
8
+ end
@@ -0,0 +1,19 @@
1
+ module Rivulet
2
+ class Telemetry
3
+ module SequelExtension
4
+ def log_connection_yield(*args, &block)
5
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
6
+ super
7
+ ensure
8
+ elapsed = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000
9
+ Fiber[:rivulet_telemetry]&.record_db(elapsed)
10
+ end
11
+ end
12
+ end
13
+ end
14
+
15
+ if defined?(Sequel)
16
+ Sequel::Database.register_extension(:rivulet_telemetry) do |db|
17
+ db.extend(Rivulet::Telemetry::SequelExtension)
18
+ end
19
+ end
@@ -0,0 +1,12 @@
1
+ module Rivulet
2
+ class Telemetry
3
+ module TimingWrapper
4
+ def call(...)
5
+ Fiber[:rivulet_telemetry]&.start_recording(self)
6
+ super
7
+ ensure
8
+ Fiber[:rivulet_telemetry]&.stop_recording(self)
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,62 @@
1
+ module Rivulet
2
+ class Telemetry
3
+ attr_reader :db_ms
4
+
5
+ def initialize
6
+ @started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
7
+ @root = nil
8
+ @stack = []
9
+ @db_ms = 0.0
10
+ end
11
+
12
+ def start_recording(activity)
13
+ node = Rivulet::Telemetry::Node.new(
14
+ name: activity.class.name,
15
+ started_at: Process.clock_gettime(Process::CLOCK_MONOTONIC),
16
+ children: []
17
+ )
18
+
19
+ if @stack.empty?
20
+ @root = node
21
+ else
22
+ @stack.last[:children] << node
23
+ end
24
+
25
+ @stack << node
26
+ end
27
+
28
+ def stop_recording(activity)
29
+ node = @stack.pop
30
+ node.ended_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
31
+ node.duration_ms =
32
+ ((node.ended_at - node.started_at) * 1000.0).round(3)
33
+ node.self_ms =
34
+ (node.duration_ms - node.children.sum(&:duration_ms)).round(3)
35
+ end
36
+
37
+ def record_db(ms)
38
+ @db_ms = (@db_ms + ms).round(3)
39
+ end
40
+
41
+ def total_ms
42
+ ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - @started_at) * 1000).round(3)
43
+ end
44
+
45
+ def flow
46
+ @root
47
+ end
48
+
49
+ def print_flow(node = @root, offset = 0)
50
+ entry = " " * 2 * offset
51
+ entry += "#{node.name} (#{node.self_ms})"
52
+ entry += " =>" if node.children.any?
53
+ entry += "\n"
54
+
55
+ node.children.each do |cnode|
56
+ entry += print_flow(cnode, offset + 1)
57
+ end
58
+
59
+ entry
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,3 @@
1
+ module Rivulet
2
+ VERSION = '0.1.0'
3
+ end
data/lib/rivulet.rb ADDED
@@ -0,0 +1,66 @@
1
+ require 'forwardable'
2
+ require 'dry/monads'
3
+ require 'dry/operation'
4
+ require 'dry/configurable'
5
+ require 'dry/auto_inject'
6
+ require 'dry/core/container'
7
+ require 'dry/logger'
8
+ require 'dry/validation'
9
+ require 'dry/transformer'
10
+ require 'oj'
11
+ require 'protocol/http'
12
+ require 'protocol/http/body/file'
13
+ require 'protocol/http/body/stream'
14
+ require 'rack'
15
+ require 'rack/utils'
16
+ require 'sequel'
17
+
18
+ require_relative 'rivulet/version'
19
+ require_relative 'rivulet/telemetry'
20
+ require_relative 'rivulet/telemetry/node'
21
+ require_relative 'rivulet/telemetry/sequel_extension'
22
+ require_relative 'rivulet/telemetry/timing_wrapper'
23
+ require_relative 'rivulet/step'
24
+ require_relative 'rivulet/container'
25
+ require_relative 'rivulet/operation'
26
+ require_relative 'rivulet/request'
27
+ require_relative 'rivulet/response'
28
+ require_relative 'rivulet/projection'
29
+ require_relative 'rivulet/routing/route'
30
+ require_relative 'rivulet/routing/mapper'
31
+ require_relative 'rivulet/steps/build_config'
32
+ require_relative 'rivulet/steps/build_context'
33
+ require_relative 'rivulet/steps/validate_response'
34
+ require_relative 'rivulet/steps/compile_response'
35
+ require_relative 'rivulet/steps/dispatch'
36
+ require_relative 'rivulet/steps/load_app'
37
+ require_relative 'rivulet/steps/load_db'
38
+ require_relative 'rivulet/steps/load_routes'
39
+ require_relative 'rivulet/steps/load_settings'
40
+ require_relative 'rivulet/steps/print_routes'
41
+ require_relative 'rivulet/steps/run_migrations'
42
+ require_relative 'rivulet/steps/run_console'
43
+ require_relative 'rivulet/operations/startup'
44
+ require_relative 'rivulet/operations/dispatch_request'
45
+ require_relative 'rivulet/operations/migrate'
46
+ require_relative 'rivulet/operations/print_routes'
47
+ require_relative 'rivulet/operations/run_console'
48
+ require_relative 'rivulet/application'
49
+
50
+ module Rivulet
51
+ extend SingleForwardable
52
+
53
+ def_delegators :app, :config, :configure, :routes, :logger
54
+
55
+ def self.app
56
+ return @app if @app
57
+
58
+ @app = Application.new
59
+ @app
60
+ end
61
+
62
+ def self.plugin(as: 'rivulet')
63
+ Dry::Core::Container::Namespace.new(as) do
64
+ end
65
+ end
66
+ end