rubyn-code 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 (235) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +620 -0
  4. data/db/migrations/000_create_schema_migrations.sql +4 -0
  5. data/db/migrations/001_create_sessions.sql +16 -0
  6. data/db/migrations/002_create_messages.sql +16 -0
  7. data/db/migrations/003_create_tasks.sql +17 -0
  8. data/db/migrations/004_create_task_dependencies.sql +8 -0
  9. data/db/migrations/005_create_memories.sql +44 -0
  10. data/db/migrations/006_create_cost_records.sql +16 -0
  11. data/db/migrations/007_create_hooks.sql +12 -0
  12. data/db/migrations/008_create_skills_cache.sql +8 -0
  13. data/db/migrations/009_create_teams.sql +27 -0
  14. data/db/migrations/010_create_instincts.sql +15 -0
  15. data/exe/rubyn-code +6 -0
  16. data/lib/rubyn_code/agent/conversation.rb +193 -0
  17. data/lib/rubyn_code/agent/loop.rb +517 -0
  18. data/lib/rubyn_code/agent/loop_detector.rb +78 -0
  19. data/lib/rubyn_code/auth/oauth.rb +174 -0
  20. data/lib/rubyn_code/auth/server.rb +126 -0
  21. data/lib/rubyn_code/auth/token_store.rb +153 -0
  22. data/lib/rubyn_code/autonomous/daemon.rb +233 -0
  23. data/lib/rubyn_code/autonomous/idle_poller.rb +111 -0
  24. data/lib/rubyn_code/autonomous/task_claimer.rb +100 -0
  25. data/lib/rubyn_code/background/job.rb +19 -0
  26. data/lib/rubyn_code/background/notifier.rb +44 -0
  27. data/lib/rubyn_code/background/worker.rb +146 -0
  28. data/lib/rubyn_code/cli/app.rb +118 -0
  29. data/lib/rubyn_code/cli/input_handler.rb +79 -0
  30. data/lib/rubyn_code/cli/renderer.rb +205 -0
  31. data/lib/rubyn_code/cli/repl.rb +519 -0
  32. data/lib/rubyn_code/cli/spinner.rb +100 -0
  33. data/lib/rubyn_code/cli/stream_formatter.rb +149 -0
  34. data/lib/rubyn_code/config/defaults.rb +43 -0
  35. data/lib/rubyn_code/config/project_config.rb +120 -0
  36. data/lib/rubyn_code/config/settings.rb +127 -0
  37. data/lib/rubyn_code/context/auto_compact.rb +81 -0
  38. data/lib/rubyn_code/context/compactor.rb +89 -0
  39. data/lib/rubyn_code/context/manager.rb +91 -0
  40. data/lib/rubyn_code/context/manual_compact.rb +87 -0
  41. data/lib/rubyn_code/context/micro_compact.rb +135 -0
  42. data/lib/rubyn_code/db/connection.rb +176 -0
  43. data/lib/rubyn_code/db/migrator.rb +146 -0
  44. data/lib/rubyn_code/db/schema.rb +106 -0
  45. data/lib/rubyn_code/hooks/built_in.rb +124 -0
  46. data/lib/rubyn_code/hooks/registry.rb +99 -0
  47. data/lib/rubyn_code/hooks/runner.rb +88 -0
  48. data/lib/rubyn_code/hooks/user_hooks.rb +90 -0
  49. data/lib/rubyn_code/learning/extractor.rb +191 -0
  50. data/lib/rubyn_code/learning/injector.rb +138 -0
  51. data/lib/rubyn_code/learning/instinct.rb +172 -0
  52. data/lib/rubyn_code/llm/client.rb +218 -0
  53. data/lib/rubyn_code/llm/message_builder.rb +116 -0
  54. data/lib/rubyn_code/llm/streaming.rb +203 -0
  55. data/lib/rubyn_code/mcp/client.rb +139 -0
  56. data/lib/rubyn_code/mcp/config.rb +83 -0
  57. data/lib/rubyn_code/mcp/sse_transport.rb +225 -0
  58. data/lib/rubyn_code/mcp/stdio_transport.rb +196 -0
  59. data/lib/rubyn_code/mcp/tool_bridge.rb +164 -0
  60. data/lib/rubyn_code/memory/models.rb +62 -0
  61. data/lib/rubyn_code/memory/search.rb +181 -0
  62. data/lib/rubyn_code/memory/session_persistence.rb +194 -0
  63. data/lib/rubyn_code/memory/store.rb +199 -0
  64. data/lib/rubyn_code/observability/budget_enforcer.rb +159 -0
  65. data/lib/rubyn_code/observability/cost_calculator.rb +61 -0
  66. data/lib/rubyn_code/observability/models.rb +29 -0
  67. data/lib/rubyn_code/observability/token_counter.rb +42 -0
  68. data/lib/rubyn_code/observability/usage_reporter.rb +140 -0
  69. data/lib/rubyn_code/output/diff_renderer.rb +212 -0
  70. data/lib/rubyn_code/output/formatter.rb +120 -0
  71. data/lib/rubyn_code/permissions/deny_list.rb +49 -0
  72. data/lib/rubyn_code/permissions/policy.rb +59 -0
  73. data/lib/rubyn_code/permissions/prompter.rb +80 -0
  74. data/lib/rubyn_code/permissions/tier.rb +22 -0
  75. data/lib/rubyn_code/protocols/interrupt_handler.rb +95 -0
  76. data/lib/rubyn_code/protocols/plan_approval.rb +67 -0
  77. data/lib/rubyn_code/protocols/shutdown_handshake.rb +109 -0
  78. data/lib/rubyn_code/skills/catalog.rb +70 -0
  79. data/lib/rubyn_code/skills/document.rb +80 -0
  80. data/lib/rubyn_code/skills/loader.rb +57 -0
  81. data/lib/rubyn_code/sub_agents/runner.rb +168 -0
  82. data/lib/rubyn_code/sub_agents/summarizer.rb +57 -0
  83. data/lib/rubyn_code/tasks/dag.rb +208 -0
  84. data/lib/rubyn_code/tasks/manager.rb +212 -0
  85. data/lib/rubyn_code/tasks/models.rb +31 -0
  86. data/lib/rubyn_code/teams/mailbox.rb +128 -0
  87. data/lib/rubyn_code/teams/manager.rb +175 -0
  88. data/lib/rubyn_code/teams/teammate.rb +38 -0
  89. data/lib/rubyn_code/tools/background_run.rb +41 -0
  90. data/lib/rubyn_code/tools/base.rb +84 -0
  91. data/lib/rubyn_code/tools/bash.rb +81 -0
  92. data/lib/rubyn_code/tools/bundle_add.rb +53 -0
  93. data/lib/rubyn_code/tools/bundle_install.rb +41 -0
  94. data/lib/rubyn_code/tools/compact.rb +57 -0
  95. data/lib/rubyn_code/tools/db_migrate.rb +52 -0
  96. data/lib/rubyn_code/tools/edit_file.rb +49 -0
  97. data/lib/rubyn_code/tools/executor.rb +62 -0
  98. data/lib/rubyn_code/tools/git_commit.rb +97 -0
  99. data/lib/rubyn_code/tools/git_diff.rb +61 -0
  100. data/lib/rubyn_code/tools/git_log.rb +59 -0
  101. data/lib/rubyn_code/tools/git_status.rb +59 -0
  102. data/lib/rubyn_code/tools/glob.rb +44 -0
  103. data/lib/rubyn_code/tools/grep.rb +81 -0
  104. data/lib/rubyn_code/tools/load_skill.rb +41 -0
  105. data/lib/rubyn_code/tools/memory_search.rb +77 -0
  106. data/lib/rubyn_code/tools/memory_write.rb +52 -0
  107. data/lib/rubyn_code/tools/rails_generate.rb +54 -0
  108. data/lib/rubyn_code/tools/read_file.rb +38 -0
  109. data/lib/rubyn_code/tools/read_inbox.rb +64 -0
  110. data/lib/rubyn_code/tools/registry.rb +48 -0
  111. data/lib/rubyn_code/tools/review_pr.rb +145 -0
  112. data/lib/rubyn_code/tools/run_specs.rb +75 -0
  113. data/lib/rubyn_code/tools/schema.rb +59 -0
  114. data/lib/rubyn_code/tools/send_message.rb +53 -0
  115. data/lib/rubyn_code/tools/spawn_agent.rb +154 -0
  116. data/lib/rubyn_code/tools/spawn_teammate.rb +168 -0
  117. data/lib/rubyn_code/tools/task.rb +148 -0
  118. data/lib/rubyn_code/tools/web_fetch.rb +108 -0
  119. data/lib/rubyn_code/tools/web_search.rb +196 -0
  120. data/lib/rubyn_code/tools/write_file.rb +30 -0
  121. data/lib/rubyn_code/version.rb +5 -0
  122. data/lib/rubyn_code.rb +203 -0
  123. data/skills/code_quality/fits_in_your_head.md +189 -0
  124. data/skills/code_quality/naming_conventions.md +213 -0
  125. data/skills/code_quality/null_object.md +205 -0
  126. data/skills/code_quality/technical_debt.md +135 -0
  127. data/skills/code_quality/value_objects.md +216 -0
  128. data/skills/code_quality/yagni.md +176 -0
  129. data/skills/design_patterns/adapter.md +191 -0
  130. data/skills/design_patterns/bridge_memento_visitor.md +254 -0
  131. data/skills/design_patterns/builder.md +158 -0
  132. data/skills/design_patterns/command.md +126 -0
  133. data/skills/design_patterns/composite.md +147 -0
  134. data/skills/design_patterns/decorator.md +204 -0
  135. data/skills/design_patterns/facade.md +133 -0
  136. data/skills/design_patterns/factory_method.md +169 -0
  137. data/skills/design_patterns/iterator.md +116 -0
  138. data/skills/design_patterns/mediator.md +133 -0
  139. data/skills/design_patterns/observer.md +177 -0
  140. data/skills/design_patterns/proxy.md +140 -0
  141. data/skills/design_patterns/singleton.md +124 -0
  142. data/skills/design_patterns/state.md +207 -0
  143. data/skills/design_patterns/strategy.md +127 -0
  144. data/skills/design_patterns/template_method.md +173 -0
  145. data/skills/gems/devise.md +365 -0
  146. data/skills/gems/dry_rb.md +186 -0
  147. data/skills/gems/factory_bot.md +268 -0
  148. data/skills/gems/faraday.md +263 -0
  149. data/skills/gems/graphql_ruby.md +514 -0
  150. data/skills/gems/pundit.md +446 -0
  151. data/skills/gems/redis.md +219 -0
  152. data/skills/gems/rubocop.md +257 -0
  153. data/skills/gems/sidekiq.md +360 -0
  154. data/skills/gems/stripe.md +224 -0
  155. data/skills/minitest/assertions.md +185 -0
  156. data/skills/minitest/fixtures.md +238 -0
  157. data/skills/minitest/integration_tests.md +210 -0
  158. data/skills/minitest/mailers_and_jobs.md +218 -0
  159. data/skills/minitest/mocking_stubbing.md +202 -0
  160. data/skills/minitest/service_tests_and_performance.md +246 -0
  161. data/skills/minitest/structure_and_conventions.md +169 -0
  162. data/skills/minitest/system_tests.md +237 -0
  163. data/skills/rails/action_cable.md +160 -0
  164. data/skills/rails/active_record_basics.md +174 -0
  165. data/skills/rails/active_storage.md +242 -0
  166. data/skills/rails/api_design.md +212 -0
  167. data/skills/rails/associations.md +182 -0
  168. data/skills/rails/background_jobs.md +212 -0
  169. data/skills/rails/caching.md +158 -0
  170. data/skills/rails/callbacks.md +135 -0
  171. data/skills/rails/concerns_controllers.md +218 -0
  172. data/skills/rails/concerns_models.md +280 -0
  173. data/skills/rails/controllers.md +190 -0
  174. data/skills/rails/engines.md +201 -0
  175. data/skills/rails/form_objects.md +168 -0
  176. data/skills/rails/hotwire.md +229 -0
  177. data/skills/rails/internationalization.md +192 -0
  178. data/skills/rails/logging.md +198 -0
  179. data/skills/rails/mailers.md +180 -0
  180. data/skills/rails/migrations.md +200 -0
  181. data/skills/rails/multitenancy.md +207 -0
  182. data/skills/rails/n_plus_one.md +151 -0
  183. data/skills/rails/presenters.md +244 -0
  184. data/skills/rails/query_objects.md +177 -0
  185. data/skills/rails/routing.md +194 -0
  186. data/skills/rails/scopes.md +187 -0
  187. data/skills/rails/security.md +233 -0
  188. data/skills/rails/serializers.md +243 -0
  189. data/skills/rails/service_objects.md +184 -0
  190. data/skills/rails/testing_strategy.md +258 -0
  191. data/skills/rails/validations.md +206 -0
  192. data/skills/refactoring/code_smells.md +251 -0
  193. data/skills/refactoring/command_query_separation.md +166 -0
  194. data/skills/refactoring/encapsulate_collection.md +125 -0
  195. data/skills/refactoring/extract_class.md +138 -0
  196. data/skills/refactoring/extract_method.md +185 -0
  197. data/skills/refactoring/replace_conditional.md +211 -0
  198. data/skills/refactoring/value_objects.md +246 -0
  199. data/skills/rspec/build_stubbed.md +199 -0
  200. data/skills/rspec/factory_design.md +206 -0
  201. data/skills/rspec/let_vs_let_bang.md +161 -0
  202. data/skills/rspec/mocking_stubbing.md +209 -0
  203. data/skills/rspec/request_specs.md +212 -0
  204. data/skills/rspec/service_specs.md +262 -0
  205. data/skills/rspec/shared_examples.md +244 -0
  206. data/skills/rspec/system_specs.md +286 -0
  207. data/skills/rspec/test_performance.md +215 -0
  208. data/skills/ruby/blocks_procs_lambdas.md +204 -0
  209. data/skills/ruby/classes.md +155 -0
  210. data/skills/ruby/concurrency.md +194 -0
  211. data/skills/ruby/data_struct_openstruct.md +158 -0
  212. data/skills/ruby/debugging_profiling.md +204 -0
  213. data/skills/ruby/enumerable_patterns.md +168 -0
  214. data/skills/ruby/exception_handling.md +199 -0
  215. data/skills/ruby/file_io.md +217 -0
  216. data/skills/ruby/hashes.md +195 -0
  217. data/skills/ruby/metaprogramming.md +170 -0
  218. data/skills/ruby/modules.md +210 -0
  219. data/skills/ruby/pattern_matching.md +177 -0
  220. data/skills/ruby/regular_expressions.md +166 -0
  221. data/skills/ruby/result_objects.md +200 -0
  222. data/skills/ruby/strings.md +177 -0
  223. data/skills/ruby_project/bundler_dependencies.md +181 -0
  224. data/skills/ruby_project/cli_tools.md +224 -0
  225. data/skills/ruby_project/rake_tasks.md +146 -0
  226. data/skills/ruby_project/structure.md +261 -0
  227. data/skills/sinatra/application_structure.md +241 -0
  228. data/skills/sinatra/middleware_and_deployment.md +221 -0
  229. data/skills/sinatra/testing.md +233 -0
  230. data/skills/solid/dependency_inversion.md +195 -0
  231. data/skills/solid/interface_segregation.md +237 -0
  232. data/skills/solid/liskov_substitution.md +263 -0
  233. data/skills/solid/open_closed.md +212 -0
  234. data/skills/solid/single_responsibility.md +183 -0
  235. metadata +397 -0
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ module RubynCode
7
+ module Context
8
+ # LLM-driven summarization triggered explicitly by the user or agent via the
9
+ # /compact command. Identical to AutoCompact but supports an optional custom
10
+ # focus prompt so the user can steer what gets preserved.
11
+ module ManualCompact
12
+ BASE_INSTRUCTION = <<~PROMPT
13
+ You are a context compaction assistant. Summarize the following conversation transcript for continuity. Cover exactly three areas:
14
+
15
+ 1) **What was accomplished** - completed tasks, files changed, problems solved
16
+ 2) **Current state** - what the user/agent is working on right now, any pending actions
17
+ 3) **Key decisions made** - architectural choices, user preferences, constraints established
18
+
19
+ Be concise but preserve all details needed to continue the work seamlessly. Use bullet points.
20
+ PROMPT
21
+
22
+ MAX_TRANSCRIPT_CHARS = 80_000
23
+
24
+ # Compacts the conversation by summarizing it through the LLM.
25
+ #
26
+ # @param messages [Array<Hash>] current conversation messages
27
+ # @param llm_client [#chat] an LLM client that responds to #chat
28
+ # @param transcript_dir [String, nil] directory to save full transcript before compaction
29
+ # @param focus [String, nil] optional user-supplied focus prompt to guide summarization
30
+ # @return [Array<Hash>] new messages array containing only the summary
31
+ def self.call(messages, llm_client:, transcript_dir: nil, focus: nil)
32
+ save_transcript(messages, transcript_dir) if transcript_dir
33
+
34
+ transcript_text = serialize_tail(messages, MAX_TRANSCRIPT_CHARS)
35
+ instruction = build_instruction(focus)
36
+ summary = request_summary(transcript_text, instruction, llm_client)
37
+
38
+ [{ role: "user", content: "[Context compacted — manual]\n\n#{summary}" }]
39
+ end
40
+
41
+ def self.build_instruction(focus)
42
+ return BASE_INSTRUCTION if focus.nil? || focus.strip.empty?
43
+
44
+ "#{BASE_INSTRUCTION}\nAdditional focus: #{focus}"
45
+ end
46
+
47
+ def self.save_transcript(messages, dir)
48
+ FileUtils.mkdir_p(dir)
49
+ timestamp = Time.now.strftime("%Y%m%d_%H%M%S")
50
+ path = File.join(dir, "transcript_manual_#{timestamp}.json")
51
+ File.write(path, JSON.pretty_generate(messages))
52
+ path
53
+ end
54
+
55
+ def self.serialize_tail(messages, max_chars)
56
+ json = JSON.generate(messages)
57
+ return json if json.length <= max_chars
58
+
59
+ json[-max_chars..]
60
+ end
61
+
62
+ def self.request_summary(transcript_text, instruction, llm_client)
63
+ summary_messages = [
64
+ {
65
+ role: "user",
66
+ content: "#{instruction}\n\n---\n\n#{transcript_text}"
67
+ }
68
+ ]
69
+
70
+ options = {}
71
+ options[:model] = "claude-sonnet-4-20250514" if llm_client.respond_to?(:chat)
72
+
73
+ response = llm_client.chat(messages: summary_messages, **options)
74
+
75
+ case response
76
+ when String then response
77
+ when Hash then response[:content] || response["content"] || response.to_s
78
+ else
79
+ response.respond_to?(:text) ? response.text : response.to_s
80
+ end
81
+ end
82
+
83
+ private_class_method :build_instruction, :save_transcript,
84
+ :serialize_tail, :request_summary
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module Context
5
+ # Zero-cost compression that runs every turn. Replaces old tool results
6
+ # (except the most recent N) with short placeholders to reduce token count
7
+ # without losing conversational continuity.
8
+ module MicroCompact
9
+ PLACEHOLDER_TEMPLATE = "[Previous: used %<tool_name>s]"
10
+ MIN_CONTENT_LENGTH = 100
11
+
12
+ # Mutates +messages+ in place, replacing old tool_result content with
13
+ # compact placeholders.
14
+ #
15
+ # @param messages [Array<Hash>] the conversation messages array
16
+ # @param keep_recent [Integer] number of most-recent tool results to preserve
17
+ # @param preserve_tools [Array<String>] tool names whose results are never compacted
18
+ # @return [Integer] count of compacted tool results
19
+ def self.call(messages, keep_recent: 3, preserve_tools: ["read_file"])
20
+ tool_result_refs = collect_tool_results(messages)
21
+ return 0 if tool_result_refs.size <= keep_recent
22
+
23
+ tool_name_index = build_tool_name_index(messages)
24
+ candidates = tool_result_refs[0..-(keep_recent + 1)]
25
+ compacted = 0
26
+
27
+ candidates.each do |ref|
28
+ block = ref[:block]
29
+ content = extract_content(block)
30
+ next if content.nil? || content.length < MIN_CONTENT_LENGTH
31
+
32
+ tool_name = resolve_tool_name(block, tool_name_index)
33
+ next if preserve_tools.include?(tool_name)
34
+
35
+ placeholder = format(PLACEHOLDER_TEMPLATE, tool_name: tool_name || "tool")
36
+ replace_content!(block, placeholder)
37
+ compacted += 1
38
+ end
39
+
40
+ compacted
41
+ end
42
+
43
+ # Collects all tool_result content blocks across user messages, preserving
44
+ # encounter order so the most recent ones can be kept intact.
45
+ #
46
+ # @return [Array<Hash>] each entry has :message, :block, :index keys
47
+ def self.collect_tool_results(messages)
48
+ refs = []
49
+
50
+ messages.each do |msg|
51
+ next unless msg[:role] == "user" && msg[:content].is_a?(Array)
52
+
53
+ msg[:content].each_with_index do |block, idx|
54
+ next unless tool_result_block?(block)
55
+
56
+ refs << { message: msg, block: block, index: idx }
57
+ end
58
+ end
59
+
60
+ refs
61
+ end
62
+
63
+ # Builds a lookup from tool_use_id to tool name by scanning assistant
64
+ # messages for tool_use blocks.
65
+ #
66
+ # @return [Hash{String => String}]
67
+ def self.build_tool_name_index(messages)
68
+ index = {}
69
+
70
+ messages.each do |msg|
71
+ next unless msg[:role] == "assistant" && msg[:content].is_a?(Array)
72
+
73
+ msg[:content].each do |block|
74
+ case block
75
+ when Hash
76
+ index[block[:id] || block["id"]] = block[:name] || block["name"] if block_type(block) == "tool_use"
77
+ when LLM::ToolUseBlock
78
+ index[block.id] = block.name
79
+ end
80
+ end
81
+ end
82
+
83
+ index
84
+ end
85
+
86
+ def self.tool_result_block?(block)
87
+ case block
88
+ when Hash
89
+ block_type(block) == "tool_result"
90
+ when LLM::ToolResultBlock
91
+ true
92
+ else
93
+ false
94
+ end
95
+ end
96
+
97
+ def self.block_type(hash)
98
+ hash[:type] || hash["type"]
99
+ end
100
+
101
+ def self.extract_content(block)
102
+ case block
103
+ when Hash
104
+ val = block[:content] || block["content"]
105
+ val.is_a?(String) ? val : val.to_s
106
+ when LLM::ToolResultBlock
107
+ block.content.to_s
108
+ end
109
+ end
110
+
111
+ def self.resolve_tool_name(block, index)
112
+ tool_use_id = case block
113
+ when Hash then block[:tool_use_id] || block["tool_use_id"]
114
+ when LLM::ToolResultBlock then block.tool_use_id
115
+ end
116
+
117
+ index[tool_use_id]
118
+ end
119
+
120
+ def self.replace_content!(block, placeholder)
121
+ case block
122
+ when Hash
123
+ key = block.key?(:content) ? :content : "content"
124
+ block[key] = placeholder
125
+ end
126
+ # Note: Data.define instances are frozen; for ToolResultBlock objects
127
+ # we rely on messages being stored as hashes in the conversation array.
128
+ end
129
+
130
+ private_class_method :collect_tool_results, :build_tool_name_index,
131
+ :tool_result_block?, :block_type, :extract_content,
132
+ :resolve_tool_name, :replace_content!
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sqlite3"
4
+ require "monitor"
5
+ require "fileutils"
6
+
7
+ module RubynCode
8
+ module DB
9
+ # Manages a singleton SQLite3 database connection with WAL mode,
10
+ # foreign keys, and thread-safe access.
11
+ class Connection
12
+ include MonitorMixin
13
+
14
+ class << self
15
+ # Returns the singleton Connection instance, optionally initializing
16
+ # it with the given database path on first call.
17
+ #
18
+ # @param path [String] path to the SQLite3 database file
19
+ # @return [Connection]
20
+ def instance(path = nil)
21
+ @mutex ||= Mutex.new
22
+ @mutex.synchronize do
23
+ if @instance.nil?
24
+ path ||= Config::Defaults::DB_FILE
25
+ FileUtils.mkdir_p(File.dirname(path))
26
+ @instance = new(path)
27
+ end
28
+ @instance
29
+ end
30
+ end
31
+
32
+ # Executes a write statement (INSERT, UPDATE, DELETE, DDL).
33
+ #
34
+ # @param sql [String] the SQL statement
35
+ # @param params [Array] bind parameters
36
+ # @return [void]
37
+ def execute(sql, params = [])
38
+ instance.execute(sql, params)
39
+ end
40
+
41
+ # Executes a read query and returns rows as hashes.
42
+ #
43
+ # @param sql [String] the SQL query
44
+ # @param params [Array] bind parameters
45
+ # @return [Array<Hash>]
46
+ def query(sql, params = [])
47
+ instance.query(sql, params)
48
+ end
49
+
50
+ # Wraps a block in a database transaction with automatic
51
+ # commit/rollback semantics. Supports nested calls via SAVEPOINTs.
52
+ #
53
+ # @yield the block to execute within the transaction
54
+ # @return [Object] the return value of the block
55
+ def transaction(&block)
56
+ instance.transaction(&block)
57
+ end
58
+
59
+ # Tears down the singleton instance. Intended for test cleanup.
60
+ #
61
+ # @return [void]
62
+ def reset!
63
+ @mutex ||= Mutex.new
64
+ @mutex.synchronize do
65
+ if @instance
66
+ @instance.close
67
+ @instance = nil
68
+ end
69
+ end
70
+ end
71
+ end
72
+
73
+ # @param path [String] path to the SQLite3 database file
74
+ def initialize(path)
75
+ super() # MonitorMixin
76
+ @path = path
77
+ @db = SQLite3::Database.new(path)
78
+ @transaction_depth = 0
79
+ configure_connection
80
+ end
81
+
82
+ # Executes a write statement with bind parameters.
83
+ #
84
+ # @param sql [String]
85
+ # @param params [Array]
86
+ # @return [void]
87
+ def execute(sql, params = [])
88
+ synchronize do
89
+ @db.execute(sql, params)
90
+ end
91
+ end
92
+
93
+ # Executes a read query and returns all matching rows.
94
+ #
95
+ # @param sql [String]
96
+ # @param params [Array]
97
+ # @return [Array<Hash>]
98
+ def query(sql, params = [])
99
+ synchronize do
100
+ @db.execute(sql, params)
101
+ end
102
+ end
103
+
104
+ # Wraps a block in a transaction. Nested calls use SAVEPOINTs
105
+ # to avoid SQLite "cannot start a transaction within a transaction" errors.
106
+ #
107
+ # @yield the block to execute
108
+ # @return [Object] the block's return value
109
+ def transaction
110
+ synchronize do
111
+ if @transaction_depth.zero?
112
+ begin_top_level_transaction
113
+ else
114
+ begin_savepoint
115
+ end
116
+
117
+ @transaction_depth += 1
118
+ begin
119
+ result = yield
120
+ if @transaction_depth == 1
121
+ @db.execute("COMMIT")
122
+ else
123
+ @db.execute("RELEASE SAVEPOINT sp_#{@transaction_depth}")
124
+ end
125
+ result
126
+ rescue StandardError => e
127
+ if @transaction_depth == 1
128
+ @db.execute("ROLLBACK")
129
+ else
130
+ @db.execute("ROLLBACK TO SAVEPOINT sp_#{@transaction_depth}")
131
+ @db.execute("RELEASE SAVEPOINT sp_#{@transaction_depth}")
132
+ end
133
+ raise e
134
+ ensure
135
+ @transaction_depth -= 1
136
+ end
137
+ end
138
+ end
139
+
140
+ # Closes the underlying database connection.
141
+ #
142
+ # @return [void]
143
+ def close
144
+ synchronize do
145
+ @db.close unless @db.closed?
146
+ end
147
+ end
148
+
149
+ # Returns whether the connection is open.
150
+ #
151
+ # @return [Boolean]
152
+ def open?
153
+ !@db.closed?
154
+ end
155
+
156
+ private
157
+
158
+ def configure_connection
159
+ @db.results_as_hash = true
160
+ @db.execute("PRAGMA journal_mode = WAL")
161
+ @db.execute("PRAGMA foreign_keys = ON")
162
+ @db.execute("PRAGMA busy_timeout = 5000")
163
+ @db.execute("PRAGMA synchronous = NORMAL")
164
+ @db.execute("PRAGMA cache_size = -20000") # 20 MB
165
+ end
166
+
167
+ def begin_top_level_transaction
168
+ @db.execute("BEGIN IMMEDIATE")
169
+ end
170
+
171
+ def begin_savepoint
172
+ @db.execute("SAVEPOINT sp_#{@transaction_depth + 1}")
173
+ end
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module DB
5
+ # Reads SQL migration files from db/migrations/, tracks applied versions
6
+ # in a schema_migrations table, and applies new migrations in order.
7
+ class Migrator
8
+ # @return [String] absolute path to the migrations directory
9
+ MIGRATIONS_DIR = File.expand_path("../../../db/migrations", __dir__).freeze
10
+
11
+ # @param connection [Connection] the database connection to migrate
12
+ def initialize(connection)
13
+ @connection = connection
14
+ ensure_schema_migrations_table
15
+ end
16
+
17
+ # Applies all pending migrations in version order.
18
+ #
19
+ # @return [Array<Integer>] list of newly applied migration versions
20
+ def migrate!
21
+ pending = pending_migrations
22
+ return [] if pending.empty?
23
+
24
+ applied = []
25
+ pending.each do |version, path|
26
+ apply_migration(version, path)
27
+ applied << version
28
+ end
29
+ applied
30
+ end
31
+
32
+ # Returns migration versions that have not yet been applied.
33
+ #
34
+ # @return [Array<Array(Integer, String)>] pairs of [version, file_path]
35
+ def pending_migrations
36
+ applied = applied_versions
37
+ available_migrations.reject { |version, _| applied.include?(version) }
38
+ end
39
+
40
+ # Returns the set of already-applied migration versions.
41
+ #
42
+ # @return [Set<Integer>]
43
+ def applied_versions
44
+ rows = @connection.query(
45
+ "SELECT version FROM schema_migrations ORDER BY version"
46
+ ).to_a
47
+ rows.map { |row| row["version"] }.to_set
48
+ end
49
+
50
+ # Returns the current schema version (highest applied migration).
51
+ #
52
+ # @return [Integer, nil]
53
+ def current_version
54
+ row = @connection.query(
55
+ "SELECT MAX(version) AS max_version FROM schema_migrations"
56
+ ).to_a.first
57
+ row && row["max_version"]
58
+ end
59
+
60
+ # Lists all available migration files sorted by version.
61
+ #
62
+ # @return [Array<Array(Integer, String)>] pairs of [version, file_path]
63
+ def available_migrations
64
+ pattern = File.join(MIGRATIONS_DIR, "*.sql")
65
+ Dir.glob(pattern)
66
+ .map { |path| parse_migration_file(path) }
67
+ .compact
68
+ .sort_by(&:first)
69
+ end
70
+
71
+ private
72
+
73
+ def ensure_schema_migrations_table
74
+ @connection.execute(<<~SQL)
75
+ CREATE TABLE IF NOT EXISTS schema_migrations (
76
+ version INTEGER PRIMARY KEY,
77
+ applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
78
+ )
79
+ SQL
80
+ end
81
+
82
+ def apply_migration(version, path)
83
+ sql = File.read(path)
84
+ @connection.transaction do
85
+ # Execute each statement separately (SQLite doesn't support multi-statement execute)
86
+ split_statements(sql).each do |statement|
87
+ @connection.execute(statement)
88
+ end
89
+ @connection.execute(
90
+ "INSERT INTO schema_migrations (version) VALUES (?)", [version]
91
+ )
92
+ end
93
+ end
94
+
95
+ # Splits a SQL file into individual statements, handling semicolons
96
+ # inside string literals and ignoring empty/comment-only fragments.
97
+ #
98
+ # @param sql [String]
99
+ # @return [Array<String>]
100
+ def split_statements(sql)
101
+ statements = []
102
+ current = +""
103
+ in_block = false
104
+
105
+ sql.each_line do |line|
106
+ stripped = line.strip
107
+
108
+ # Track BEGIN/END blocks (e.g., triggers)
109
+ in_block = true if stripped.match?(/\bBEGIN\b/i) && !stripped.match?(/\ABEGIN\s+(IMMEDIATE|DEFERRED|EXCLUSIVE)/i)
110
+ current << line
111
+
112
+ if in_block
113
+ if stripped.match?(/\bEND\b\s*;?\s*$/i)
114
+ in_block = false
115
+ statements << current.strip.chomp(";")
116
+ current = +""
117
+ end
118
+ elsif stripped.end_with?(";")
119
+ stmt = current.strip.chomp(";").strip
120
+ statements << stmt unless stmt.empty? || (stmt.match?(/\A\s*--/) && !stmt.include?("\n"))
121
+ current = +""
122
+ end
123
+ end
124
+
125
+ # Handle any remaining content
126
+ remainder = current.strip.chomp(";").strip
127
+ statements << remainder unless remainder.empty?
128
+
129
+ statements
130
+ end
131
+
132
+ # Extracts the version number and name from a migration filename.
133
+ #
134
+ # @param path [String]
135
+ # @return [Array(Integer, String), nil]
136
+ def parse_migration_file(path)
137
+ basename = File.basename(path, ".sql")
138
+ match = basename.match(/\A(\d+)_/)
139
+ return nil unless match
140
+
141
+ version = match[1].to_i
142
+ [version, path]
143
+ end
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module DB
5
+ # Provides schema introspection helpers and version checking
6
+ # for the database.
7
+ class Schema
8
+ # @param connection [Connection] the database connection
9
+ def initialize(connection)
10
+ @connection = connection
11
+ end
12
+
13
+ # Returns the current schema version (highest applied migration).
14
+ #
15
+ # @return [Integer, nil] the version number, or nil if no migrations applied
16
+ def current_version
17
+ row = @connection.query(
18
+ "SELECT MAX(version) AS max_version FROM schema_migrations"
19
+ ).to_a.first
20
+ row && row["max_version"]
21
+ rescue StandardError
22
+ nil
23
+ end
24
+
25
+ # Returns all applied migration versions in order.
26
+ #
27
+ # @return [Array<Integer>]
28
+ def applied_versions
29
+ @connection.query(
30
+ "SELECT version FROM schema_migrations ORDER BY version"
31
+ ).to_a.map { |row| row["version"] }
32
+ rescue StandardError
33
+ []
34
+ end
35
+
36
+ # Checks whether a specific migration version has been applied.
37
+ #
38
+ # @param version [Integer]
39
+ # @return [Boolean]
40
+ def version_applied?(version)
41
+ rows = @connection.query(
42
+ "SELECT 1 FROM schema_migrations WHERE version = ?", [version]
43
+ ).to_a
44
+ !rows.empty?
45
+ rescue StandardError
46
+ false
47
+ end
48
+
49
+ # Returns a list of table names in the database (excluding internal SQLite tables).
50
+ #
51
+ # @return [Array<String>]
52
+ def tables
53
+ @connection.query(
54
+ "SELECT name FROM sqlite_master WHERE type = 'table' " \
55
+ "AND name NOT LIKE 'sqlite_%' ORDER BY name"
56
+ ).to_a.map { |row| row["name"] }
57
+ end
58
+
59
+ # Returns column information for the given table.
60
+ #
61
+ # @param table_name [String]
62
+ # @return [Array<Hash>] each hash has keys: cid, name, type, notnull, dflt_value, pk
63
+ def columns(table_name)
64
+ @connection.query("PRAGMA table_info(#{quote_identifier(table_name)})").to_a
65
+ end
66
+
67
+ # Returns index information for the given table.
68
+ #
69
+ # @param table_name [String]
70
+ # @return [Array<Hash>]
71
+ def indexes(table_name)
72
+ @connection.query("PRAGMA index_list(#{quote_identifier(table_name)})").to_a
73
+ end
74
+
75
+ # Checks whether a given table exists in the database.
76
+ #
77
+ # @param table_name [String]
78
+ # @return [Boolean]
79
+ def table_exists?(table_name)
80
+ rows = @connection.query(
81
+ "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?",
82
+ [table_name]
83
+ ).to_a
84
+ !rows.empty?
85
+ end
86
+
87
+ # Returns true if the schema is up to date with all available migrations.
88
+ #
89
+ # @param migrator [Migrator]
90
+ # @return [Boolean]
91
+ def up_to_date?(migrator)
92
+ migrator.pending_migrations.empty?
93
+ end
94
+
95
+ private
96
+
97
+ # Safely quotes a SQL identifier to prevent injection.
98
+ #
99
+ # @param name [String]
100
+ # @return [String]
101
+ def quote_identifier(name)
102
+ "\"#{name.gsub('"', '""')}\""
103
+ end
104
+ end
105
+ end
106
+ end