railscope 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 (95) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +227 -0
  5. data/Rakefile +12 -0
  6. data/app/assets/stylesheets/railscope/application.css +504 -0
  7. data/app/controllers/railscope/api/entries_controller.rb +103 -0
  8. data/app/controllers/railscope/application_controller.rb +12 -0
  9. data/app/controllers/railscope/dashboard_controller.rb +33 -0
  10. data/app/controllers/railscope/entries_controller.rb +29 -0
  11. data/app/helpers/railscope/dashboard_helper.rb +157 -0
  12. data/app/jobs/railscope/application_job.rb +6 -0
  13. data/app/jobs/railscope/purge_job.rb +15 -0
  14. data/app/models/railscope/application_record.rb +12 -0
  15. data/app/models/railscope/entry.rb +51 -0
  16. data/app/views/layouts/railscope/application.html.erb +14 -0
  17. data/app/views/railscope/application/index.html.erb +1 -0
  18. data/app/views/railscope/dashboard/index.html.erb +70 -0
  19. data/app/views/railscope/entries/show.html.erb +93 -0
  20. data/client/.gitignore +1 -0
  21. data/client/index.html +12 -0
  22. data/client/package-lock.json +2735 -0
  23. data/client/package.json +28 -0
  24. data/client/postcss.config.js +6 -0
  25. data/client/src/App.tsx +60 -0
  26. data/client/src/api/client.ts +25 -0
  27. data/client/src/api/entries.ts +36 -0
  28. data/client/src/components/Layout.tsx +17 -0
  29. data/client/src/components/PlaceholderPage.tsx +32 -0
  30. data/client/src/components/Sidebar.tsx +198 -0
  31. data/client/src/components/ui/Badge.tsx +67 -0
  32. data/client/src/components/ui/Card.tsx +38 -0
  33. data/client/src/components/ui/JsonViewer.tsx +80 -0
  34. data/client/src/components/ui/Pagination.tsx +45 -0
  35. data/client/src/components/ui/SearchInput.tsx +70 -0
  36. data/client/src/components/ui/Table.tsx +68 -0
  37. data/client/src/index.css +28 -0
  38. data/client/src/lib/hooks.ts +37 -0
  39. data/client/src/lib/types.ts +61 -0
  40. data/client/src/lib/utils.ts +38 -0
  41. data/client/src/main.tsx +13 -0
  42. data/client/src/screens/cache/Index.tsx +15 -0
  43. data/client/src/screens/client-requests/Index.tsx +15 -0
  44. data/client/src/screens/commands/Index.tsx +133 -0
  45. data/client/src/screens/commands/Show.tsx +395 -0
  46. data/client/src/screens/dumps/Index.tsx +15 -0
  47. data/client/src/screens/events/Index.tsx +15 -0
  48. data/client/src/screens/exceptions/Index.tsx +155 -0
  49. data/client/src/screens/exceptions/Show.tsx +480 -0
  50. data/client/src/screens/gates/Index.tsx +15 -0
  51. data/client/src/screens/jobs/Index.tsx +153 -0
  52. data/client/src/screens/jobs/Show.tsx +529 -0
  53. data/client/src/screens/logs/Index.tsx +15 -0
  54. data/client/src/screens/mail/Index.tsx +15 -0
  55. data/client/src/screens/models/Index.tsx +15 -0
  56. data/client/src/screens/notifications/Index.tsx +15 -0
  57. data/client/src/screens/queries/Index.tsx +159 -0
  58. data/client/src/screens/queries/Show.tsx +346 -0
  59. data/client/src/screens/redis/Index.tsx +15 -0
  60. data/client/src/screens/requests/Index.tsx +123 -0
  61. data/client/src/screens/requests/Show.tsx +395 -0
  62. data/client/src/screens/schedule/Index.tsx +15 -0
  63. data/client/src/screens/views/Index.tsx +141 -0
  64. data/client/src/screens/views/Show.tsx +337 -0
  65. data/client/tailwind.config.js +22 -0
  66. data/client/tsconfig.json +25 -0
  67. data/client/tsconfig.node.json +10 -0
  68. data/client/vite.config.ts +37 -0
  69. data/config/routes.rb +17 -0
  70. data/db/migrate/20260131023242_create_railscope_entries.rb +41 -0
  71. data/lib/generators/railscope/install_generator.rb +33 -0
  72. data/lib/generators/railscope/templates/initializer.rb +34 -0
  73. data/lib/railscope/context.rb +91 -0
  74. data/lib/railscope/engine.rb +85 -0
  75. data/lib/railscope/entry_data.rb +112 -0
  76. data/lib/railscope/filter.rb +113 -0
  77. data/lib/railscope/middleware.rb +162 -0
  78. data/lib/railscope/storage/base.rb +90 -0
  79. data/lib/railscope/storage/database.rb +83 -0
  80. data/lib/railscope/storage/redis_storage.rb +314 -0
  81. data/lib/railscope/subscribers/base_subscriber.rb +52 -0
  82. data/lib/railscope/subscribers/command_subscriber.rb +237 -0
  83. data/lib/railscope/subscribers/exception_subscriber.rb +113 -0
  84. data/lib/railscope/subscribers/job_subscriber.rb +249 -0
  85. data/lib/railscope/subscribers/query_subscriber.rb +130 -0
  86. data/lib/railscope/subscribers/request_subscriber.rb +121 -0
  87. data/lib/railscope/subscribers/view_subscriber.rb +201 -0
  88. data/lib/railscope/version.rb +5 -0
  89. data/lib/railscope.rb +145 -0
  90. data/lib/tasks/railscope_sample.rake +30 -0
  91. data/public/railscope/assets/app.css +1 -0
  92. data/public/railscope/assets/app.js +70 -0
  93. data/public/railscope/assets/index.html +13 -0
  94. data/sig/railscope.rbs +4 -0
  95. metadata +157 -0
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "subscribers/base_subscriber"
4
+ require_relative "subscribers/request_subscriber"
5
+ require_relative "subscribers/query_subscriber"
6
+ require_relative "subscribers/exception_subscriber"
7
+ require_relative "subscribers/job_subscriber"
8
+ require_relative "subscribers/command_subscriber"
9
+ require_relative "subscribers/view_subscriber"
10
+
11
+ module Railscope
12
+ class Engine < ::Rails::Engine
13
+ isolate_namespace Railscope
14
+
15
+ initializer "railscope.static_assets" do |app|
16
+ # Serve static assets from public/railscope
17
+ app.middleware.insert_before(
18
+ ActionDispatch::Static,
19
+ Rack::Static,
20
+ urls: ["/railscope/assets"],
21
+ root: Railscope::Engine.root.join("public").to_s
22
+ )
23
+ end
24
+
25
+ initializer "railscope.middleware" do |app|
26
+ app.middleware.insert_before(0, Railscope::Middleware)
27
+ end
28
+
29
+ initializer "railscope.migrations" do |app|
30
+ unless app.root.to_s.match?(root.to_s)
31
+ config.paths["db/migrate"].expanded.each do |expanded_path|
32
+ app.config.paths["db/migrate"] << expanded_path
33
+ end
34
+ end
35
+ end
36
+
37
+ initializer "railscope.subscribers", after: :load_config_initializers do
38
+ # ActionController subscribers
39
+ if defined?(ActionController::Base)
40
+ Railscope::Subscribers::RequestSubscriber.subscribe
41
+ Railscope::Subscribers::ExceptionSubscriber.subscribe
42
+ else
43
+ ActiveSupport.on_load(:action_controller) do
44
+ Railscope::Subscribers::RequestSubscriber.subscribe
45
+ Railscope::Subscribers::ExceptionSubscriber.subscribe
46
+ end
47
+ end
48
+
49
+ # ActionView subscribers
50
+ if defined?(ActionView::Base)
51
+ Railscope::Subscribers::ViewSubscriber.subscribe
52
+ else
53
+ ActiveSupport.on_load(:action_view) do
54
+ Railscope::Subscribers::ViewSubscriber.subscribe
55
+ end
56
+ end
57
+
58
+ # ActiveRecord subscribers
59
+ if defined?(ActiveRecord::Base)
60
+ Railscope::Subscribers::QuerySubscriber.subscribe
61
+ else
62
+ ActiveSupport.on_load(:active_record) do
63
+ Railscope::Subscribers::QuerySubscriber.subscribe
64
+ end
65
+ end
66
+
67
+ # ActiveJob subscribers
68
+ if defined?(ActiveJob::Base)
69
+ Railscope::Subscribers::JobSubscriber.subscribe
70
+ else
71
+ ActiveSupport.on_load(:active_job) do
72
+ Railscope::Subscribers::JobSubscriber.subscribe
73
+ end
74
+ end
75
+ end
76
+
77
+ rake_tasks do
78
+ # Load sample tasks for testing
79
+ load Railscope::Engine.root.join("lib/tasks/railscope_sample.rake")
80
+
81
+ # Subscribe to rake tasks after they're loaded
82
+ Railscope::Subscribers::CommandSubscriber.subscribe
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railscope
4
+ # Plain Ruby object representing an entry, used by both storage adapters
5
+ # This provides a consistent interface regardless of storage backend
6
+ class EntryData
7
+ ATTRIBUTES = %i[
8
+ uuid
9
+ batch_id
10
+ family_hash
11
+ entry_type
12
+ payload
13
+ tags
14
+ should_display_on_index
15
+ occurred_at
16
+ created_at
17
+ updated_at
18
+ ].freeze
19
+
20
+ attr_accessor(*ATTRIBUTES)
21
+
22
+ # Alias id to uuid for compatibility with existing code
23
+ alias id uuid
24
+
25
+ def initialize(attributes = {})
26
+ attributes = attributes.symbolize_keys if attributes.respond_to?(:symbolize_keys)
27
+
28
+ ATTRIBUTES.each do |attr|
29
+ value = attributes[attr]
30
+ # Handle time parsing for string values
31
+ if %i[occurred_at created_at updated_at].include?(attr) && value.is_a?(String)
32
+ value = begin
33
+ Time.zone.parse(value)
34
+ rescue StandardError
35
+ value
36
+ end
37
+ end
38
+ send("#{attr}=", value)
39
+ end
40
+
41
+ # Set defaults
42
+ self.tags ||= []
43
+ self.payload ||= {}
44
+ self.should_display_on_index = true if should_display_on_index.nil?
45
+ end
46
+
47
+ def to_h
48
+ ATTRIBUTES.index_with do |attr|
49
+ send(attr)
50
+ end
51
+ end
52
+
53
+ def to_json(*args)
54
+ serializable_hash.to_json(*args)
55
+ end
56
+
57
+ def serializable_hash
58
+ to_h.transform_values do |value|
59
+ case value
60
+ when Time, DateTime, ActiveSupport::TimeWithZone
61
+ value.iso8601(6)
62
+ else
63
+ value
64
+ end
65
+ end
66
+ end
67
+
68
+ def displayable?
69
+ should_display_on_index
70
+ end
71
+
72
+ # For compatibility with ActiveRecord-style access
73
+ def [](key)
74
+ send(key) if respond_to?(key)
75
+ end
76
+
77
+ def []=(key, value)
78
+ send("#{key}=", value) if respond_to?("#{key}=")
79
+ end
80
+
81
+ # Comparison for sorting
82
+ def <=>(other)
83
+ return nil unless other.is_a?(EntryData)
84
+
85
+ other.occurred_at <=> occurred_at # desc by default
86
+ end
87
+
88
+ class << self
89
+ # Create from ActiveRecord Entry model
90
+ def from_active_record(record)
91
+ new(
92
+ uuid: record.uuid,
93
+ batch_id: record.batch_id,
94
+ family_hash: record.family_hash,
95
+ entry_type: record.entry_type,
96
+ payload: record.payload,
97
+ tags: record.tags,
98
+ should_display_on_index: record.should_display_on_index,
99
+ occurred_at: record.occurred_at,
100
+ created_at: record.created_at,
101
+ updated_at: record.updated_at
102
+ )
103
+ end
104
+
105
+ # Create from JSON hash (Redis)
106
+ def from_json(json_string)
107
+ data = JSON.parse(json_string, symbolize_names: true)
108
+ new(data)
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railscope
4
+ class Filter
5
+ MASK = "[FILTERED]"
6
+
7
+ DEFAULT_SENSITIVE_KEYS = %w[
8
+ password
9
+ password_confirmation
10
+ secret
11
+ token
12
+ api_key
13
+ apikey
14
+ access_token
15
+ refresh_token
16
+ authorization
17
+ auth
18
+ credential
19
+ private_key
20
+ secret_key
21
+ credit_card
22
+ card_number
23
+ cvv
24
+ ssn
25
+ ].freeze
26
+
27
+ class << self
28
+ def filter(payload)
29
+ return payload unless payload.is_a?(Hash)
30
+
31
+ filter_hash(payload.deep_dup)
32
+ end
33
+
34
+ def sensitive_keys
35
+ @sensitive_keys ||= build_sensitive_keys
36
+ end
37
+
38
+ def add_sensitive_keys(*keys)
39
+ @sensitive_keys = nil
40
+ @custom_keys ||= []
41
+ @custom_keys.concat(keys.map(&:to_s).map(&:downcase))
42
+ end
43
+
44
+ def reset_sensitive_keys!
45
+ @sensitive_keys = nil
46
+ @custom_keys = []
47
+ end
48
+
49
+ private
50
+
51
+ def build_sensitive_keys
52
+ keys = DEFAULT_SENSITIVE_KEYS.dup
53
+ keys.concat(rails_filter_parameters)
54
+ keys.concat(@custom_keys || [])
55
+ keys.uniq
56
+ end
57
+
58
+ def rails_filter_parameters
59
+ return [] unless defined?(Rails) && Rails.application
60
+
61
+ Rails.application.config.filter_parameters.map do |param|
62
+ param.is_a?(Regexp) ? nil : param.to_s.downcase
63
+ end.compact
64
+ end
65
+
66
+ def filter_hash(hash)
67
+ hash.each do |key, value|
68
+ if sensitive_key?(key)
69
+ hash[key] = MASK
70
+ elsif value.is_a?(Hash)
71
+ hash[key] = filter_hash(value)
72
+ elsif value.is_a?(Array)
73
+ hash[key] = filter_array(value)
74
+ elsif value.is_a?(String) && looks_like_secret?(value)
75
+ hash[key] = MASK
76
+ end
77
+ end
78
+ hash
79
+ end
80
+
81
+ def filter_array(array)
82
+ array.map do |item|
83
+ case item
84
+ when Hash
85
+ filter_hash(item)
86
+ when Array
87
+ filter_array(item)
88
+ else
89
+ item
90
+ end
91
+ end
92
+ end
93
+
94
+ def sensitive_key?(key)
95
+ key_str = key.to_s.downcase
96
+ sensitive_keys.any? { |sensitive| key_str.include?(sensitive) }
97
+ end
98
+
99
+ def looks_like_secret?(value)
100
+ return false if value.length < 20
101
+
102
+ # Bearer tokens
103
+ return true if value.start_with?("Bearer ")
104
+ # Base64 encoded secrets (common pattern)
105
+ return true if value.match?(%r{\A[A-Za-z0-9+/=]{40,}\z})
106
+ # JWT tokens
107
+ return true if value.match?(/\Aey[A-Za-z0-9_-]+\.ey[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\z/)
108
+
109
+ false
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railscope
4
+ class Middleware
5
+ # Maximum size for response body capture (64KB like Telescope)
6
+ RESPONSE_SIZE_LIMIT = 64 * 1024
7
+
8
+ def initialize(app)
9
+ @app = app
10
+ end
11
+
12
+ def call(env)
13
+ return @app.call(env) unless Railscope.enabled?
14
+
15
+ setup_context(env)
16
+ status, headers, response = @app.call(env)
17
+
18
+ # Capture response body for recording
19
+ context = Context.current
20
+ if context[:recording]
21
+ # Read body from env (where Rails stores the response)
22
+ body_content = extract_body_from_env(env)
23
+
24
+ context_data = {
25
+ batch_id: context.batch_id,
26
+ env: env,
27
+ headers: headers
28
+ }
29
+
30
+ # Update entry with response data
31
+ update_entry_async(context_data, body_content)
32
+ end
33
+
34
+ [status, headers, response]
35
+ ensure
36
+ Context.clear!
37
+ end
38
+
39
+ def extract_body_from_env(env)
40
+ body = ""
41
+
42
+ # Try to get from ActionDispatch::Response stored in env
43
+ if env["action_dispatch.response"]
44
+ response = env["action_dispatch.response"]
45
+ body = response.body.to_s if response.respond_to?(:body)
46
+ end
47
+
48
+ # Try action_controller.instance
49
+ if body.empty? && env["action_controller.instance"]
50
+ controller = env["action_controller.instance"]
51
+ if controller.respond_to?(:response) && controller.response.respond_to?(:body)
52
+ body = controller.response.body.to_s
53
+ end
54
+ end
55
+
56
+ # Truncate if too large
57
+ body = body.byteslice(0, RESPONSE_SIZE_LIMIT) if body.bytesize > RESPONSE_SIZE_LIMIT
58
+
59
+ body
60
+ rescue StandardError => e
61
+ Rails.logger.debug("[Railscope] Failed to extract body: #{e.message}")
62
+ ""
63
+ end
64
+
65
+ def update_entry_async(context_data, body_content)
66
+ # Update synchronously for now (could be made async later)
67
+ self.class.update_entry_with_response(context_data, body_content)
68
+ rescue StandardError => e
69
+ Rails.logger.debug("[Railscope] Failed to update entry: #{e.message}")
70
+ end
71
+
72
+ def self.update_entry_with_response(context_data, response_body)
73
+ return unless Railscope.ready?
74
+
75
+ headers = context_data[:headers]
76
+ response_headers = begin
77
+ headers.respond_to?(:to_h) ? headers.to_h : headers.to_hash
78
+ rescue StandardError
79
+ {}
80
+ end
81
+ session_data = extract_session_from_env(context_data[:env])
82
+
83
+ # Parse JSON if applicable
84
+ content_type = response_headers["Content-Type"] || response_headers["content-type"] || ""
85
+ looks_like_json = response_body.to_s.start_with?("{", "[")
86
+ is_json = content_type.include?("application/json") || looks_like_json
87
+
88
+ parsed_body = if is_json
89
+ begin
90
+ JSON.parse(response_body)
91
+ rescue StandardError
92
+ response_body
93
+ end
94
+ else
95
+ response_body
96
+ end
97
+
98
+ payload_updates = {
99
+ "response" => parsed_body.presence,
100
+ "response_headers" => response_headers,
101
+ "session" => session_data
102
+ }
103
+
104
+ storage = Railscope.storage
105
+
106
+ if storage.is_a?(Railscope::Storage::Database)
107
+ entry = Entry.where(batch_id: context_data[:batch_id], entry_type: "request").order(created_at: :desc).first
108
+ return unless entry
109
+
110
+ entry.payload = entry.payload.merge(payload_updates)
111
+ entry.save!
112
+
113
+ elsif storage.respond_to?(:update_by_batch)
114
+ storage.update_by_batch(
115
+ batch_id: context_data[:batch_id],
116
+ entry_type: "request",
117
+ payload_updates: payload_updates
118
+ )
119
+ end
120
+ rescue StandardError => e
121
+ Rails.logger.debug("[Railscope] Failed to update entry with response: #{e.message}")
122
+ end
123
+
124
+ def self.extract_session_from_env(env)
125
+ return {} unless env
126
+
127
+ session = env["rack.session"] || env["action_dispatch.request.session"]
128
+ return {} unless session
129
+
130
+ session.to_h.transform_keys(&:to_s).except("_csrf_token", "session_id")
131
+ rescue StandardError
132
+ {}
133
+ end
134
+
135
+ private
136
+
137
+ def setup_context(env)
138
+ path = env["PATH_INFO"]
139
+ context = Context.current
140
+
141
+ # Generate a new batch_id for this request
142
+ context.batch_id = SecureRandom.uuid
143
+
144
+ # Use Rails request_id if available, otherwise use batch_id
145
+ context.request_id = env["action_dispatch.request_id"] || context.batch_id
146
+
147
+ context[:path] = path
148
+ context[:method] = env["REQUEST_METHOD"]
149
+ context[:ip_address] = extract_ip(env)
150
+ context[:recording] = Railscope.should_record?(path: path)
151
+
152
+ # Store env reference to capture session later
153
+ context[:env] = env
154
+ end
155
+
156
+ def extract_ip(env)
157
+ env["HTTP_X_FORWARDED_FOR"]&.split(",")&.first&.strip ||
158
+ env["HTTP_X_REAL_IP"] ||
159
+ env["REMOTE_ADDR"]
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railscope
4
+ module Storage
5
+ class Base
6
+ # Write a new entry
7
+ # @param attributes [Hash] entry attributes
8
+ # @return [EntryData] the created entry
9
+ def write(attributes)
10
+ raise NotImplementedError, "#{self.class}#write must be implemented"
11
+ end
12
+
13
+ # Find an entry by UUID
14
+ # @param uuid [String] the entry UUID
15
+ # @return [EntryData, nil] the entry or nil if not found
16
+ def find(uuid)
17
+ raise NotImplementedError, "#{self.class}#find must be implemented"
18
+ end
19
+
20
+ # Find an entry by UUID, raising if not found
21
+ # @param uuid [String] the entry UUID
22
+ # @return [EntryData] the entry
23
+ # @raise [RecordNotFound] if entry not found
24
+ def find!(uuid)
25
+ find(uuid) || raise(RecordNotFound, "Entry not found: #{uuid}")
26
+ end
27
+
28
+ # List entries with optional filters
29
+ # @param filters [Hash] optional filters (:type, :tag, :batch_id, :family_hash)
30
+ # @param page [Integer] page number (1-indexed)
31
+ # @param per_page [Integer] entries per page
32
+ # @param displayable_only [Boolean] only return displayable entries
33
+ # @return [Array<EntryData>] list of entries
34
+ def all(filters: {}, page: 1, per_page: 25, displayable_only: true)
35
+ raise NotImplementedError, "#{self.class}#all must be implemented"
36
+ end
37
+
38
+ # Count entries with optional filters
39
+ # @param filters [Hash] optional filters
40
+ # @param displayable_only [Boolean] only count displayable entries
41
+ # @return [Integer] count of entries
42
+ def count(filters: {}, displayable_only: true)
43
+ raise NotImplementedError, "#{self.class}#count must be implemented"
44
+ end
45
+
46
+ # Get all entries in a batch
47
+ # @param batch_id [String] the batch UUID
48
+ # @return [Array<EntryData>] list of entries in the batch
49
+ def for_batch(batch_id)
50
+ raise NotImplementedError, "#{self.class}#for_batch must be implemented"
51
+ end
52
+
53
+ # Get all entries with the same family hash
54
+ # @param family_hash [String] the family hash
55
+ # @param page [Integer] page number
56
+ # @param per_page [Integer] entries per page
57
+ # @return [Array<EntryData>] list of entries with same family
58
+ def for_family(family_hash, page: 1, per_page: 25)
59
+ raise NotImplementedError, "#{self.class}#for_family must be implemented"
60
+ end
61
+
62
+ # Count entries with the same family hash
63
+ # @param family_hash [String] the family hash
64
+ # @return [Integer] count of entries
65
+ def family_count(family_hash)
66
+ raise NotImplementedError, "#{self.class}#family_count must be implemented"
67
+ end
68
+
69
+ # Delete all entries
70
+ # @return [Integer] number of deleted entries
71
+ def destroy_all!
72
+ raise NotImplementedError, "#{self.class}#destroy_all! must be implemented"
73
+ end
74
+
75
+ # Delete expired entries (older than retention_days)
76
+ # @return [Integer] number of deleted entries
77
+ def destroy_expired!
78
+ raise NotImplementedError, "#{self.class}#destroy_expired! must be implemented"
79
+ end
80
+
81
+ # Check if storage is available and ready
82
+ # @return [Boolean] true if ready
83
+ def ready?
84
+ raise NotImplementedError, "#{self.class}#ready? must be implemented"
85
+ end
86
+ end
87
+
88
+ class RecordNotFound < StandardError; end
89
+ end
90
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railscope
4
+ module Storage
5
+ class Database < Base
6
+ def write(attributes)
7
+ # Remove uuid if present - let the database generate it
8
+ attrs = attributes.except(:uuid, :created_at, :updated_at)
9
+ record = Entry.create!(attrs)
10
+ EntryData.from_active_record(record)
11
+ end
12
+
13
+ def find(uuid)
14
+ record = Entry.find_by(uuid: uuid)
15
+ return nil unless record
16
+
17
+ EntryData.from_active_record(record)
18
+ end
19
+
20
+ def all(filters: {}, page: 1, per_page: 25, displayable_only: true)
21
+ scope = build_scope(filters, displayable_only)
22
+ records = scope.recent.limit(per_page).offset((page - 1) * per_page)
23
+ records.map { |r| EntryData.from_active_record(r) }
24
+ end
25
+
26
+ def count(filters: {}, displayable_only: true)
27
+ build_scope(filters, displayable_only).count
28
+ end
29
+
30
+ def for_batch(batch_id)
31
+ records = Entry.for_batch(batch_id).order(:occurred_at)
32
+ records.map { |r| EntryData.from_active_record(r) }
33
+ end
34
+
35
+ def for_family(family_hash, page: 1, per_page: 25)
36
+ records = Entry.for_family(family_hash).recent.limit(per_page).offset((page - 1) * per_page)
37
+ records.map { |r| EntryData.from_active_record(r) }
38
+ end
39
+
40
+ def family_count(family_hash)
41
+ Entry.for_family(family_hash).count
42
+ end
43
+
44
+ def destroy_all!
45
+ Entry.delete_all
46
+ end
47
+
48
+ def destroy_expired!
49
+ total_deleted = 0
50
+ batch_size = 1000
51
+
52
+ loop do
53
+ expired_ids = Entry.expired.limit(batch_size).pluck(:uuid)
54
+ break if expired_ids.empty?
55
+
56
+ deleted = Entry.where(uuid: expired_ids).delete_all
57
+ total_deleted += deleted
58
+ break if deleted < batch_size
59
+ end
60
+
61
+ total_deleted
62
+ end
63
+
64
+ def ready?
65
+ Entry.table_exists?
66
+ rescue StandardError
67
+ false
68
+ end
69
+
70
+ private
71
+
72
+ def build_scope(filters, displayable_only)
73
+ scope = Entry.all
74
+ scope = scope.displayable if displayable_only
75
+ scope = scope.by_type(filters[:type]) if filters[:type].present?
76
+ scope = scope.with_tag(filters[:tag]) if filters[:tag].present?
77
+ scope = scope.for_batch(filters[:batch_id]) if filters[:batch_id].present?
78
+ scope = scope.for_family(filters[:family_hash]) if filters[:family_hash].present?
79
+ scope
80
+ end
81
+ end
82
+ end
83
+ end