rails-ai-context 5.35.0 → 5.35.2

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 (123) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1024 -0
  3. data/CONTEXT.md +37 -7
  4. data/CONTRIBUTING.md +30 -1
  5. data/README.md +9 -9
  6. data/SECURITY.md +2 -2
  7. data/docs/ARCHITECTURE.md +12 -4
  8. data/docs/CLI.md +44 -15
  9. data/docs/COMPATIBILITY.md +34 -8
  10. data/docs/CONFIGURATION.md +28 -5
  11. data/docs/CUSTOM_TOOLS.md +8 -2
  12. data/docs/FAQ.md +5 -5
  13. data/docs/GUIDE.md +33 -65
  14. data/docs/INTROSPECTORS.md +4 -4
  15. data/docs/QUICKSTART.md +1 -1
  16. data/docs/RECIPES.md +5 -5
  17. data/docs/RELEASING.md +3 -1
  18. data/docs/SECURITY.md +255 -74
  19. data/docs/SETUP.md +1 -1
  20. data/docs/STANDALONE.md +2 -2
  21. data/docs/TOOLS.md +151 -90
  22. data/docs/TROUBLESHOOTING.md +123 -8
  23. data/docs/_config.yml +1 -1
  24. data/docs/adr/0008-query-runs-only-where-it-can-guard.md +38 -0
  25. data/docs/adr/0009-dbhub-shapes-in-ruby.md +26 -0
  26. data/docs/adr/0010-one-config-tool-with-sections.md +32 -0
  27. data/docs/index.md +2 -2
  28. data/exe/rails-ai-context +24 -9
  29. data/lib/generators/rails_ai_context/install/install_generator.rb +2 -0
  30. data/lib/rails_ai_context/app_databases.rb +377 -0
  31. data/lib/rails_ai_context/app_kind.rb +2 -0
  32. data/lib/rails_ai_context/cli/app_root.rb +1 -0
  33. data/lib/rails_ai_context/cli/tool_runner.rb +110 -99
  34. data/lib/rails_ai_context/configuration.rb +15 -8
  35. data/lib/rails_ai_context/database_family/mysql_runtime.rb +170 -0
  36. data/lib/rails_ai_context/database_family/postgresql_plan.rb +124 -0
  37. data/lib/rails_ai_context/database_family/postgresql_runtime.rb +211 -0
  38. data/lib/rails_ai_context/database_family/sqlite_runtime.rb +391 -0
  39. data/lib/rails_ai_context/database_family.rb +282 -0
  40. data/lib/rails_ai_context/database_file.rb +61 -7
  41. data/lib/rails_ai_context/database_yml.rb +7 -29
  42. data/lib/rails_ai_context/doctor.rb +15 -16
  43. data/lib/rails_ai_context/instrumentation.rb +1 -1
  44. data/lib/rails_ai_context/introspectors/connection_pool_introspector.rb +8 -23
  45. data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +6 -70
  46. data/lib/rails_ai_context/introspectors/declared_constant.rb +19 -7
  47. data/lib/rails_ai_context/introspectors/env_introspector.rb +1 -1
  48. data/lib/rails_ai_context/introspectors/env_references.rb +1 -1
  49. data/lib/rails_ai_context/introspectors/job_introspector.rb +7 -7
  50. data/lib/rails_ai_context/introspectors/migration_introspector.rb +8 -11
  51. data/lib/rails_ai_context/introspectors/migration_replay/statements.rb +9 -9
  52. data/lib/rails_ai_context/introspectors/model_introspector.rb +33 -39
  53. data/lib/rails_ai_context/introspectors/multi_database_introspector.rb +33 -30
  54. data/lib/rails_ai_context/introspectors/node_source.rb +2 -0
  55. data/lib/rails_ai_context/introspectors/performance_introspector.rb +80 -24
  56. data/lib/rails_ai_context/introspectors/pg_partitions.rb +1 -4
  57. data/lib/rails_ai_context/introspectors/schema_conventions.rb +6 -8
  58. data/lib/rails_ai_context/introspectors/schema_dump_path.rb +12 -14
  59. data/lib/rails_ai_context/introspectors/schema_introspector.rb +47 -27
  60. data/lib/rails_ai_context/introspectors/schema_reader.rb +1 -1
  61. data/lib/rails_ai_context/introspectors/source_scan.rb +39 -4
  62. data/lib/rails_ai_context/migration_status.rb +18 -46
  63. data/lib/rails_ai_context/payload.rb +48 -1
  64. data/lib/rails_ai_context/pending_migrations.rb +3 -4
  65. data/lib/rails_ai_context/run_cache.rb +5 -0
  66. data/lib/rails_ai_context/schema_adapter.rb +13 -18
  67. data/lib/rails_ai_context/secret_columns.rb +506 -0
  68. data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +2 -2
  69. data/lib/rails_ai_context/serializers/markdown_serializer.rb +14 -10
  70. data/lib/rails_ai_context/serializers/section_facts.rb +12 -2
  71. data/lib/rails_ai_context/serializers/stack_overview_helper.rb +5 -2
  72. data/lib/rails_ai_context/serializers/tool_guide_helper.rb +35 -7
  73. data/lib/rails_ai_context/server.rb +143 -29
  74. data/lib/rails_ai_context/sql_gate.rb +1007 -0
  75. data/lib/rails_ai_context/tasks/rails_ai_context.rake +1 -2
  76. data/lib/rails_ai_context/test_helper.rb +3 -1
  77. data/lib/rails_ai_context/tools/analyze_feature.rb +11 -7
  78. data/lib/rails_ai_context/tools/base_tool.rb +71 -48
  79. data/lib/rails_ai_context/tools/dependency_graph.rb +1 -1
  80. data/lib/rails_ai_context/tools/diagnose.rb +105 -42
  81. data/lib/rails_ai_context/tools/generate_test.rb +4 -6
  82. data/lib/rails_ai_context/tools/get_active_support.rb +1 -6
  83. data/lib/rails_ai_context/tools/get_api.rb +1 -6
  84. data/lib/rails_ai_context/tools/get_autoload.rb +1 -6
  85. data/lib/rails_ai_context/tools/get_callbacks.rb +5 -5
  86. data/lib/rails_ai_context/tools/get_component_catalog.rb +6 -7
  87. data/lib/rails_ai_context/tools/get_concern.rb +6 -6
  88. data/lib/rails_ai_context/tools/get_config.rb +189 -12
  89. data/lib/rails_ai_context/tools/get_context.rb +111 -105
  90. data/lib/rails_ai_context/tools/get_controllers.rb +8 -5
  91. data/lib/rails_ai_context/tools/get_conventions.rb +1 -6
  92. data/lib/rails_ai_context/tools/get_engines.rb +1 -6
  93. data/lib/rails_ai_context/tools/get_env.rb +1 -6
  94. data/lib/rails_ai_context/tools/get_env_config.rb +2 -8
  95. data/lib/rails_ai_context/tools/get_frontend_stack.rb +1 -6
  96. data/lib/rails_ai_context/tools/get_gems.rb +1 -6
  97. data/lib/rails_ai_context/tools/get_helper_methods.rb +4 -5
  98. data/lib/rails_ai_context/tools/get_i18n.rb +1 -7
  99. data/lib/rails_ai_context/tools/get_job_pattern.rb +1 -1
  100. data/lib/rails_ai_context/tools/get_mailers.rb +1 -1
  101. data/lib/rails_ai_context/tools/get_model_details.rb +5 -8
  102. data/lib/rails_ai_context/tools/get_partial_interface.rb +3 -4
  103. data/lib/rails_ai_context/tools/get_schema.rb +10 -8
  104. data/lib/rails_ai_context/tools/get_service_pattern.rb +60 -24
  105. data/lib/rails_ai_context/tools/get_turbo_map.rb +1 -1
  106. data/lib/rails_ai_context/tools/migration_advisor.rb +47 -32
  107. data/lib/rails_ai_context/tools/onboard.rb +24 -24
  108. data/lib/rails_ai_context/tools/performance_check.rb +15 -2
  109. data/lib/rails_ai_context/tools/query.rb +142 -1356
  110. data/lib/rails_ai_context/tools/read_logs.rb +1 -1
  111. data/lib/rails_ai_context/tools/review_changes.rb +6 -7
  112. data/lib/rails_ai_context/tools/runtime_info.rb +125 -88
  113. data/lib/rails_ai_context/tools/safe_call.rb +70 -28
  114. data/lib/rails_ai_context/tools/search_code.rb +0 -4
  115. data/lib/rails_ai_context/tools/search_docs.rb +3 -1
  116. data/lib/rails_ai_context/tools/security_scan.rb +9 -5
  117. data/lib/rails_ai_context/tools/session_context.rb +18 -12
  118. data/lib/rails_ai_context/tools/validate.rb +1 -0
  119. data/lib/rails_ai_context/tools/validate_semantics.rb +5 -5
  120. data/lib/rails_ai_context/version.rb +1 -1
  121. data/lib/rails_ai_context.rb +4 -7
  122. data/server.json +2 -2
  123. metadata +14 -3
@@ -0,0 +1,38 @@
1
+ # rails_query runs only on a database family it can guard
2
+
3
+ Status: accepted
4
+
5
+ `rails_query` promises two guards the database itself enforces: a read-only transaction and a statement timeout. Each database server spells both differently, and some cannot give one at all, so the tool runs a statement only where it knows the spelling. A **database family** is that knowledge: one per kind of database, told by the adapter's name, and the only place a database's read-only and timeout rules live. `rails_query` reads the name from the database's configuration, before any connection opens.
6
+
7
+ ## Decision
8
+
9
+ - Three families: PostgreSQL (PostGIS too), MySQL (Trilogy and MariaDB too) and SQLite. Any other adapter gets a refusal that names it before anything reaches the database. Statistics answers (table sizes, row estimates, index usage) say "not available" for it instead.
10
+ - A family is claimed by the adapter's name alone, as `adapter_name` and database.yml spell it. CockroachDB's adapter subclasses PostgreSQL's, but it has no `EXPLAIN (FORMAT JSON)` and its own read-only rules, so it is refused until someone verifies them.
11
+ - The timeout per family:
12
+ - PostgreSQL: `SET LOCAL statement_timeout` inside the read-only transaction, at one millisecond or more, since zero turns the timeout off.
13
+ - MySQL: the session's `max_execution_time`, read first, set for the statement and restored after. The `MAX_EXECUTION_TIME` hint binds only the outermost SELECT and leaves a statement that starts with WITH or EXPLAIN with no bound, so it is not used.
14
+ - MariaDB: the session's `max_statement_time`, read first, set for the statement and restored after. MariaDB ignores the MySQL hint and has no `max_execution_time`.
15
+ - SQLite: a forked child with a deadline, since sqlite3-ruby holds the interpreter lock while a statement steps and nothing in the process can end it. A statement runs on the app's own connection in five cases: an in-memory database, a platform without `fork`, a statement the child cannot prepare, a transaction open on that connection, and, while that connection holds a temp object or an attachment, a statement that names one of them, the temp catalog or schema, or calls a pragma function or `dbstat`. There a watchdog's interrupt ends a statement that returns rows slowly.
16
+ - What bounds each kind of statement a call sends:
17
+ - The query and `explain: true` have `query_timeout` to themselves: `statement_timeout` on PostgreSQL, the session's statement limit with `lock_wait_timeout` on MySQL and MariaDB, the child's deadline on SQLite.
18
+ - The reads beside the query (the schema, the PostgreSQL plan with its `pg_proc` lookup, the column trace) run in a block the family bounds the same way. They share a second `query_timeout`, and each is charged the time it ran. On SQLite they run on the app's own connection with no statement limit, and a wait on another writer ends at its busy timeout.
19
+ - `rails_runtime_info`'s table sizes, index usage and pending migrations each run in such a block with `query_timeout` of their own.
20
+ - Stated limits of the bound:
21
+ - SQLite cannot end one long step on the app's own connection: an interrupt lands only between two rows. That run is reported as one without a time limit.
22
+ - PostgreSQL has no client read timeout. `statement_timeout` is the server's to keep, so a server that goes silent after it connects is not ended by the client. A leased MySQL or MariaDB connection gets a `read_timeout` of twice `query_timeout` plus 5 seconds unless its entry sets one.
23
+ - MySQL and MariaDB take `lock_wait_timeout` in whole seconds. It is set because their statement limit does not cover `EXPLAIN` or `SHOW`, and a wait on a lock ends at `query_timeout` rounded up to the next second.
24
+ - MySQL's statement limit holds `SELECT` only, so the gate refuses `SHOW` with a `WHERE` clause on every family.
25
+ - A timeout is recognised by `ActiveRecord::QueryAborted` (QueryCanceled, StatementTimeout) or by the database's native code. MariaDB's 1969 has no Rails class in any version. Message text is never read, so a missing column named "timeout" is reported as an SQL error.
26
+ - The statement checks before execution stay on every family. MySQL's read-only transaction lets `FOR SHARE`, `LOCK IN SHARE MODE`, `INTO @var` and `INTO OUTFILE` through, so it cannot be the only guard.
27
+ - Only PostgreSQL traces a result column to its table column, through the statement's plan, so there a statement runs only with a plan. MySQL, MariaDB and SQLite have no such trace. There a view is checked by the definition the database stores and refused when it names a secret column, and a `*` inside a subquery is refused when the statement names a table that holds one. A PostgreSQL materialized view stores its rows, so the plan traces none of its columns either, and it is held to the same view rule.
28
+ - A guarded read leaves an open transaction as it found it: a savepoint on PostgreSQL, and a refusal on MySQL and MariaDB, which cannot make an open transaction read-only.
29
+
30
+ ## Consequences
31
+
32
+ Apps on SQL Server, Oracle or CockroachDB have no `rails_query`. On SQL Server and Oracle the `LIMIT` its row cap appends is a syntax error, so a query without its own row limit fails there. Supporting another database means adding a family with its own read-only and timeout spelling, verified against a real server.
33
+
34
+ ## Alternatives not taken
35
+
36
+ - **Running other adapters on the statement checks alone**: the tool's description promises database-level guards, and a check on text is not one.
37
+ - **Classifying by class ancestry**: it would admit a database whose guards nobody has run.
38
+ - **Injecting the MySQL hint after a WITH list**: it needs a parse of the statement to find the outermost SELECT. The session variable needs none, and it bounds EXPLAIN ANALYZE too.
@@ -0,0 +1,26 @@
1
+ # rails_query stays in Ruby and takes dbhub's shapes
2
+
3
+ Status: accepted
4
+
5
+ bytebase/dbhub is a database MCP server: one connector per kind of database, named sources, a single read-only policy, and two tools by default. The 2026-10 architecture review read this gem through it and asked whether `rails_query` should hand its work to dbhub outright.
6
+
7
+ ## Decision
8
+
9
+ - `rails_query` stays the gem's own. dbhub needs Node 22.5 or later, while this gem is pure Ruby. dbhub connects by DSN; `rails_query` runs on the app's own connection, so database.yml, SSL settings, SQLite extensions and app-registered functions all apply. dbhub returns column values unredacted, and the **secret column** rule is this tool's main safety property. The production guard and the static-tier refusal would also go.
10
+ - The shapes come over:
11
+ - one adapter per **database family** (dbhub's connector), ADR-0008;
12
+ - one list of **app databases** with the primary first (dbhub's named sources);
13
+ - one module deciding what a statement may do (dbhub's access policy): the **SQL gate**, with the **secret column** rule beside it;
14
+ - one answer to which tools are served;
15
+ - a short default tool list, through **config sections** (ADR-0010).
16
+ - A non-primary database is reached through a `database` argument on the tools that run SQL. dbhub registers `execute_sql_<source>` per source. Here a tool per database would add tool definitions to every turn and go stale when database.yml changes under a running server.
17
+
18
+ ## Consequences
19
+
20
+ A user who needs write access, or a database the Rails app does not own, can run dbhub beside this gem. `rails_query` keeps its read-only guards and its secret column rule for the app's own databases either way.
21
+
22
+ ## Alternatives not taken
23
+
24
+ - **dbhub as rails_query's backend**: it would add Node to a pure Ruby gem, connect by DSN where `rails_query` uses the app's own connection, and return secret columns unredacted. Each of those is a property `rails_query` documents.
25
+ - **dbhub's two-tool default**: the answers here are computed (callback order, strong params lineage, filter chains). A bare search tool would hand that work back to the model.
26
+ - **Tool annotations derived from an access policy**: there is no write mode here, so a constant read-only hint is correct.
@@ -0,0 +1,32 @@
1
+ # One config tool with sections
2
+
3
+ Status: accepted
4
+
5
+ Eleven tools each read one payload section and take no target: `rails_get_config`, `_autoload`, `_engines`, `_active_support`, `_conventions`, `_gems`, `_frontend_stack`, `_api`, `_env`, `_env_config` and `_i18n`. Every one costs a full definition in `tools/list` on every turn: together about 8,800 characters, or a fifth of what all 45 cost before this decision. A client reads that list whether or not the app's configuration is asked about.
6
+
7
+ ## Decision
8
+
9
+ - `rails_get_config` takes a `section`: `app` (the default, and what the tool answered before: database, auth, assets, cache, queue, Action Cable, initializers, middleware), `gems`, `frontend`, `api`, `engines`, `conventions`, `autoload`, `active_support`, `env`, `env_config` and `i18n`. The other ten tools leave `tools/list`.
10
+ - The old names keep working wherever a name is typed:
11
+ - the CLI resolves `gems`, `rails_get_gems`, `frontend_stack` and the rest to their section;
12
+ - the MCP server answers a call to a retired name from its section and says which call to make instead, with the arguments that section reads.
13
+ - `skip_tools` naming one of the ten retired tools turns its section off, and `rails_get_config` turns off the `app` section. A skip that hid the process environment keeps hiding it after an upgrade, and a skip that hid the overview still hides only the overview. The tool leaves `tools/list` only when every section is off.
14
+ - With `app` off there is no default, so `section` is required: the published schema and the description say so, and a call with none is answered with the sections that are on.
15
+ - A custom tool named `rails_get_config` takes the built-in's place when `skip_tools` lists that name, as a custom tool takes any skipped built-in's. The ten sections go with the built-in, and a retired name is then an unknown tool.
16
+ - A custom tool that reuses a retired name is served as its own tool. The gem's own hints still mean the built-in section.
17
+ - Each section's parameters ride on the one tool (`detail`, `category`, `offset`, `limit`, `environment`, `locale`). A parameter the asked section does not read is named in the answer as ignored.
18
+ - The ten classes stay in the gem as the sections' renderers. Ruby that calls `RailsAiContext::Tools::GetGems.call` keeps working; only the MCP surface changes.
19
+
20
+ ## Consequences
21
+
22
+ The change ships in 5.35.1, with migration notes. That number was the maintainer's choice, made outside the rule in `docs/RELEASING.md` step 1, which rules out a patch number for a release with an Added entry or a Changed entry a consumer can observe. What makes it tenable is that every retired name still answers on the CLI and over MCP, with a line naming the new call, so a prompt or a script that names one keeps working.
23
+
24
+ The removal shows wherever tool names are listed or reported. `tools/list` and `tool --list` hold 35 names. The CLI's `--json` output reports `rails_get_config` for a retired name. `Tools::BaseTool.registered_tools` no longer lists the ten classes. The session context records a section call under the new name.
25
+
26
+ The section names are public interface from here on, like the tool names before them: renaming one keeps the old name as an alias.
27
+
28
+ ## Alternatives not taken
29
+
30
+ - **Keeping the ten tools beside the new section argument**: the definitions are the cost being removed.
31
+ - **A new tool name such as `rails_get_stack`**: `rails_get_config` already answered the default section, so its callers see no change.
32
+ - **Splitting into two tools (stack and configuration)**: two definitions where one carries the same sections, and a line between the two that every reader would have to learn.
data/docs/index.md CHANGED
@@ -4,7 +4,7 @@ title: Home
4
4
 
5
5
  # rails-ai-context Documentation
6
6
 
7
- **45 MCP tools that give AI agents live access to your Rails app.**
7
+ **35 MCP tools that give AI agents live access to your Rails app.**
8
8
 
9
9
  Works with Claude Code, Cursor, GitHub Copilot, OpenCode, and Codex CLI.
10
10
 
@@ -23,7 +23,7 @@ Works with Claude Code, Cursor, GitHub Copilot, OpenCode, and Codex CLI.
23
23
 
24
24
  | Guide | Description |
25
25
  |:------|:------------|
26
- | **[Tools Reference](TOOLS.md)** | All 45 tools with every parameter |
26
+ | **[Tools Reference](TOOLS.md)** | All 35 tools with every parameter |
27
27
  | **[Configuration](CONFIGURATION.md)** | 40+ config options with defaults |
28
28
  | **[CLI Reference](CLI.md)** | Commands and argument syntax |
29
29
  | **[Introspectors](INTROSPECTORS.md)** | All 40 introspectors and AST engine |
data/exe/rails-ai-context CHANGED
@@ -92,10 +92,11 @@ TYPED_IN_ENV = "RAILS_AI_CONTEXT_TYPED_IN"
92
92
  # `context` either.
93
93
  MCP_REFRESH_FLAG_SINCE = "5.33.0"
94
94
 
95
- # After one of these tools' names `--environment` is its filter, never RAILS_ENV: this is
96
- # decided before boot, when no tool schema can load. cli_smoke_spec holds it to the schemas.
97
- ENVIRONMENT_PARAM_TOOLS = %w[env_config].freeze
95
+ # The tool names and `config` sections whose `--environment` is their own filter and leaves RAILS_ENV alone.
96
+ # Decided before boot, when no tool schema can load; cli_smoke_spec holds the list to the schemas.
97
+ ENVIRONMENT_FILTER_OWNERS = %w[env_config].freeze
98
98
 
99
+ require "rbconfig"
99
100
  require "shellwords"
100
101
  require "thor"
101
102
 
@@ -237,10 +238,20 @@ class RailsAiContextCLI < Thor
237
238
  return
238
239
  end
239
240
 
241
+ # Value-taking class options land in args too.
242
+ @app_path_override = extract_value_flag!(args, "--app-path")
243
+ # `--app-path --help` names no path: the flag took the help flag for its value.
244
+ if %w[--help -h].include?(@app_path_override)
245
+ @app_path_override = nil
246
+ args << "--help"
247
+ end
248
+
240
249
  if args.include?("--help") || args.include?("-h")
250
+ # The app's configuration file says which tools and sections it serves; reading it boots nothing.
251
+ root = app_root.resolve(cwd: Dir.pwd, app_path: @app_path_override || options[:app_path]).root
241
252
  entry_boot.require_gem_without_app!
242
- tool_class = ::RailsAiContext::CLI::ToolRunner.new(name, []).tool_class
243
- puts ::RailsAiContext::CLI::ToolRunner.tool_help(tool_class)
253
+ ::RailsAiContext::Configuration.auto_load!(root || Dir.pwd)
254
+ puts ::RailsAiContext::CLI::ToolRunner.new(name, []).help
244
255
  return
245
256
  end
246
257
 
@@ -252,11 +263,15 @@ class RailsAiContextCLI < Thor
252
263
  json_mode = true if args.delete("--json")
253
264
  json_mode = true if args.delete("-j")
254
265
 
255
- # Value-taking class options land in args too. A tool that declares `environment`
256
- # keeps it: booting in the env it filters for fails where the app has no database.
257
- @app_path_override = extract_value_flag!(args, "--app-path")
266
+ # A tool that declares `environment` keeps it: booting in the env it filters for fails where the app has no database.
258
267
  environment_flag = extract_value_flag!(args, "--environment")
259
- if environment_flag && ENVIRONMENT_PARAM_TOOLS.include?(name.to_s.delete_prefix("rails_").delete_prefix("get_"))
268
+ short = name.to_s.delete_prefix("rails_").delete_prefix("get_")
269
+ # Read here because the tool parses its own arguments after boot, and the section decides
270
+ # which env boots: the last one named, as the tool reads it.
271
+ section = args.each_with_index.filter_map do |arg, at|
272
+ arg == "--section" ? args[at + 1] : (arg.split("=", 2).last if arg.start_with?("--section=", "section="))
273
+ end.last
274
+ if environment_flag && (ENVIRONMENT_FILTER_OWNERS.include?(short) || (short == "config" && ENVIRONMENT_FILTER_OWNERS.include?(section.to_s.downcase)))
260
275
  args.push("--environment", environment_flag)
261
276
  else
262
277
  @environment_override = environment_flag
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "fileutils"
4
+ require "pathname"
3
5
  require "json"
4
6
  require "open3"
5
7
  require "shellwords"
@@ -0,0 +1,377 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "did_you_mean"
4
+
5
+ module RailsAiContext
6
+ # Every database the app has, listed once per run: the primary, the rest of the running environment's,
7
+ # those only another environment configures, then dumps no environment claims. Both tiers list the same.
8
+ module AppDatabases
9
+ class Error < RailsAiContext::Error; end
10
+ class UnknownDatabase < Error; end
11
+ class NotHere < Error; end
12
+ class NotBooted < Error; end
13
+ class Missing < Error; end
14
+ class InMemory < Error; end
15
+
16
+ # reason: what stopped the connection. advice: what to do about it.
17
+ class Unreachable < Error
18
+ attr_reader :reason, :advice
19
+
20
+ def initialize(name, reason, advice)
21
+ @reason = reason
22
+ @advice = advice
23
+ super("The #{name} database cannot be reached: #{reason}. #{advice}")
24
+ end
25
+ end
26
+
27
+ class AdapterMissing < Unreachable; end
28
+
29
+ # The owner of a pool leased for another database. Rails names a pool by its owner.
30
+ class LeaseOwner
31
+ def self.primary_class?
32
+ false
33
+ end
34
+
35
+ def self.current_preventing_writes
36
+ false
37
+ end
38
+ end
39
+
40
+ # Seconds a leased MySQL connection waits for an answer past twice query_timeout.
41
+ READ_MARGIN = 5
42
+
43
+ Dump = Data.define(:format, :path)
44
+
45
+ # environments: every one that configures it, in file order.
46
+ Database = Data.define(:name, :environments, :here, :primary, :replica, :tasks, :adapter, :adapter_default,
47
+ :family, :dump, :migrate_dirs, :config) do
48
+ # Its database.yml entry by string key, or {} for a database only a dump names.
49
+ def settings
50
+ AppDatabases.settings_of(config)
51
+ end
52
+
53
+ # Raises Unreachable, naming the database, when no connection to it answers.
54
+ def with_connection(&block)
55
+ with_pool do |pool|
56
+ AppDatabases.reaching(name, adapter) { pool.with_connection(&:verify!) }
57
+ pool.with_connection(&block)
58
+ end
59
+ end
60
+
61
+ # Raises unless this process can lease the database. Connects to nothing.
62
+ def reachable!
63
+ raise NotHere, not_here_message unless here
64
+ raise NotBooted, "The #{name} database needs the booted app to connect, and this is the static tier." if RailsAiContext.static_tier? || !config.respond_to?(:configuration_hash)
65
+
66
+ return AppDatabases.present!(name, ActiveRecord::Base.connection_db_config) if primary
67
+ # A lease would open a new, empty database beside the one the app's own process holds.
68
+ raise InMemory, in_memory_message if DatabaseFile.in_memory?(config)
69
+
70
+ AppDatabases.present!(name, config)
71
+ end
72
+
73
+ # The primary is the app's own pool. Any other database gets a handler of its own
74
+ # per lease, so the app's pools are never touched and no two leases share a pool.
75
+ def with_pool
76
+ reachable!
77
+ return yield(ActiveRecord::Base.connection_pool) if primary
78
+
79
+ handler = ActiveRecord::ConnectionAdapters::ConnectionHandler.new
80
+ begin
81
+ yield AppDatabases.reaching(name, adapter) { handler.establish_connection(connecting_in_time, owner_name: LeaseOwner) }
82
+ ensure
83
+ release(handler)
84
+ end
85
+ end
86
+
87
+ # { pending: [{ version:, name: }, ...] } read through the lease, or { error: exception }.
88
+ def pending_migrations(dirs = migrate_dirs)
89
+ with_connection { |connection| { pending: AppDatabases.bounded(connection) { MigrationStatus.pending_on(connection.pool, dirs) } } }
90
+ rescue ActiveRecord::NoDatabaseError => e
91
+ { error: Missing.new(e.message) }
92
+ rescue Unreachable => e
93
+ { error: e.cause.is_a?(ActiveRecord::NoDatabaseError) ? Missing.new(e.cause.message) : e }
94
+ rescue StandardError => e
95
+ { error: e }
96
+ end
97
+
98
+ private
99
+
100
+ # Rails 7.0 verifies each connection as it disconnects a pool, which reconnects to a server that
101
+ # stopped answering. Thrown away first, no connection is left to verify. Never replaces the block's error.
102
+ def release(handler)
103
+ handler.retrieve_connection_pool(LeaseOwner.name)&.connections&.each(&:throw_away!)
104
+ handler.remove_connection_pool(LeaseOwner.name)
105
+ rescue StandardError => e
106
+ RailsAiContext.debug_fail(e, nil, label: "AppDatabases release")
107
+ end
108
+
109
+ # A server that never answers would hold the lease for as long as its driver waits. Where the entry
110
+ # sets no limit of its own, connecting ends at query_timeout and a MySQL read above the statement's budget.
111
+ def connecting_in_time
112
+ return config if family.sqlite?
113
+
114
+ budget = RailsAiContext.configuration.query_timeout
115
+ limits = { connect_timeout: budget.ceil }
116
+ limits[:read_timeout] = (2 * budget).ceil + READ_MARGIN if family.mysql?
117
+ settings = config.configuration_hash
118
+ added = limits.reject { |key, _| settings[key] }
119
+ return config if added.empty?
120
+
121
+ ActiveRecord::DatabaseConfigurations::HashConfig.new(config.env_name, config.name, settings.merge(added))
122
+ end
123
+
124
+ def in_memory_message
125
+ "The #{name} database lives in memory, inside the process that created it. " \
126
+ "A connection from here would open a new, empty database, so it cannot be read."
127
+ end
128
+
129
+ def not_here_message
130
+ if environments.empty?
131
+ "The #{name} database is only a committed dump: no environment in config/database.yml configures it, so there is nothing to connect to."
132
+ else
133
+ "The #{name} database is configured in #{AppDatabases.environment_phrase(environments)}. " \
134
+ "This process runs in #{RailsAiContext.environment_name}, so it cannot be reached from here."
135
+ end
136
+ end
137
+ end
138
+
139
+ class List
140
+ include Enumerable
141
+
142
+ def initialize(databases)
143
+ @databases = databases.freeze
144
+ end
145
+
146
+ def each(&block)
147
+ @databases.each(&block)
148
+ end
149
+
150
+ def primary
151
+ find(&:primary)
152
+ end
153
+
154
+ # The databases past the primary that Rails dumps and migrates.
155
+ def secondaries
156
+ reject { |db| db.primary || db.replica || !db.tasks }
157
+ end
158
+
159
+ def here
160
+ select(&:here)
161
+ end
162
+
163
+ def names
164
+ map(&:name)
165
+ end
166
+
167
+ def [](name)
168
+ find { |db| db.name == name.to_s }
169
+ end
170
+
171
+ # The database a dump's name stands for. No name is the primary, and so is "primary" where no entry carries it.
172
+ def of_dump_name(name)
173
+ return primary if name.nil?
174
+
175
+ self[name] || (primary if name.to_s == "primary")
176
+ end
177
+
178
+ # nil or "" is the primary.
179
+ def fetch(name)
180
+ return primary if name.nil? || name.to_s.empty?
181
+
182
+ found = self[name]
183
+ return found if found
184
+
185
+ guess = ::DidYouMean::SpellChecker.new(dictionary: names).correct(name.to_s).first
186
+ raise UnknownDatabase, "No database named #{name}.#{" Did you mean #{guess}?" if guess} This app has: #{names.join(', ')}"
187
+ end
188
+
189
+ def find_by_dump(path)
190
+ find { |db| db.dump&.path == path.to_s }
191
+ end
192
+ end
193
+
194
+ DUMP_GLOBS = { ruby: "*_schema.rb", sql: "*_structure.sql" }.freeze
195
+ ADAPTER_GEMS = { "postgresql" => "the pg gem", "postgis" => "the activerecord-postgis-adapter gem", "mysql2" => "the mysql2 gem",
196
+ "trilogy" => "the trilogy gem", "sqlite3" => "the sqlite3 gem" }.freeze
197
+
198
+ module_function
199
+
200
+ # "production", or "staging and production".
201
+ def environment_phrase(environments)
202
+ environments.to_sentence(words_connector: ", ", two_words_connector: " and ", last_word_connector: " and ")
203
+ end
204
+
205
+ # A connection to the named database for the block. No name is the app's own connection.
206
+ def lease(root, name, &block)
207
+ return self.for(root).fetch(name).with_connection(&block) unless name.to_s.empty?
208
+
209
+ own = ActiveRecord::Base.connection_db_config
210
+ present!(own.name, own)
211
+ reaching(own.name, own.adapter) { ActiveRecord::Base.connection.verify! }
212
+ yield ActiveRecord::Base.connection
213
+ end
214
+
215
+ # Runs the block that connects. What stops it is raised as Unreachable.
216
+ def reaching(name, adapter)
217
+ yield
218
+ rescue Error
219
+ raise
220
+ rescue StandardError, LoadError => e
221
+ raise unreachable(name, adapter, e)
222
+ end
223
+
224
+ # Names the database a connection did not reach, says what stopped it, and gives advice that fits.
225
+ def unreachable(name, adapter, error)
226
+ if error.is_a?(LoadError) || error.is_a?(ActiveRecord::AdapterNotFound)
227
+ gem = ADAPTER_GEMS.fetch(adapter.to_s) { "the gem that provides the #{adapter} adapter" }
228
+ return AdapterMissing.new(name, "its #{adapter} adapter did not load", "Add #{gem} to the Gemfile and run `bundle install`.")
229
+ end
230
+
231
+ advice = if DatabaseFamily.of(adapter).sqlite? then "Check the file its config/database.yml entry names."
232
+ elsif error.is_a?(ActiveRecord::NoDatabaseError) then "Run `bin/rails db:create` or `bin/rails db:prepare` to create it."
233
+ else "Start its server, or fix its settings in config/database.yml."
234
+ end
235
+ Unreachable.new(name, error.message.lines.first.to_s.strip.chomp("."), advice)
236
+ end
237
+
238
+ # A read that ends at query_timeout, so a locked table cannot hold the answer. A family with no limit runs it as it is.
239
+ def bounded(connection, &read)
240
+ family = DatabaseFamily.of(connection)
241
+ family.known? ? family.bounded(connection, timeout: RailsAiContext.configuration.query_timeout, &read) : read.call
242
+ end
243
+
244
+ # Connecting to a SQLite file that is not there would create it, so this raises first.
245
+ def present!(name, db_config)
246
+ return unless DatabaseFile.missing?(db_config)
247
+
248
+ raise Missing, "The #{name} database does not exist yet: its SQLite file #{db_config.database} is not there, " \
249
+ "and nothing was created. Run `bin/rails db:prepare` to create it."
250
+ end
251
+
252
+ def for(root)
253
+ root = root.to_s
254
+ environments = booted_environments(root)
255
+ tier = environments ? :booted : :static
256
+ RunCache.fetch([ :app_databases, root, tier ]) do
257
+ environments ||= static_environments(root)
258
+ build(environments: with_environment_file(root, environments), current: RailsAiContext.environment_name, root: root, dumps: dump_files(root),
259
+ primary: (connected_name if tier == :booted))
260
+ end
261
+ end
262
+
263
+ # environments: { env => { name => settings Hash or db_config } } in file order. dumps: [format, path] of each
264
+ # secondary-looking dump on disk. primary: the database ActiveRecord::Base is connected to, when the app is booted.
265
+ def build(environments:, current:, root:, dumps:, primary: nil)
266
+ own = environments[current] || {}
267
+ first = DatabaseYml.primary_name_in(own)
268
+ primary_name = own.key?(primary) ? primary : first
269
+ configured = ->(name) { environments.select { |_, found| found.key?(name) }.keys }
270
+ listed = own.map do |name, value|
271
+ database(root, name, value, here: true, primary: name == primary_name, first: name == first, environments: configured.call(name))
272
+ end
273
+ listed = listed.partition(&:primary).flatten
274
+
275
+ environments.each do |env_name, found|
276
+ next if env_name == current
277
+
278
+ other_primary = DatabaseYml.primary_name_in(found)
279
+ found.each do |name, value|
280
+ next if listed.any? { |db| db.name == name }
281
+
282
+ is_primary = listed.none?(&:primary) && name == other_primary
283
+ # Another environment's first database is its primary under another name, which this app already lists.
284
+ next if name == other_primary && !is_primary
285
+
286
+ listed << database(root, name, value, here: false, primary: is_primary, first: is_primary, environments: configured.call(name))
287
+ end
288
+ end
289
+
290
+ claimed = listed.filter_map { |db| db.dump&.path } + Introspectors::SchemaDumpPath.candidates(root).map(&:last)
291
+ dumps.each do |format, path|
292
+ next if claimed.include?(path)
293
+
294
+ name = File.basename(path).sub(/_(schema\.rb|structure\.sql)\z/, "")
295
+ next if listed.any? { |db| db.name == name }
296
+
297
+ listed << orphan(root, name, format, path)
298
+ end
299
+ List.new(listed)
300
+ end
301
+
302
+ # first: its environment's first entry, whose dump Rails names schema.rb.
303
+ def database(root, name, value, here:, primary:, first:, environments:)
304
+ settings = settings_of(value)
305
+ adapter, from_default = value.respond_to?(:adapter) ? [ value.adapter, false ] : DatabaseYml.adapter(name, settings)
306
+ replica = !!settings["replica"]
307
+ tasks = tasks?(settings)
308
+ dump = first ? primary_dump(root) : Introspectors::SchemaDumpPath.secondary(root, name, settings)
309
+ # Rails writes no dump for a database it runs no tasks on, so only a committed one counts.
310
+ dump = nil unless tasks || (dump && File.exist?(dump.last))
311
+ Database.new(name: name, environments: environments, here: here, primary: primary, replica: replica, tasks: tasks,
312
+ adapter: adapter, adapter_default: from_default, family: DatabaseFamily.of(adapter),
313
+ dump: (Dump.new(*dump) if dump),
314
+ migrate_dirs: Introspectors::MigrationReplay.configured_dirs(root, settings) || [ File.join(root, "db", "migrate") ],
315
+ config: value)
316
+ end
317
+
318
+ def orphan(root, name, format, path)
319
+ Database.new(name: name, environments: [], here: false, primary: false, replica: false, tasks: true,
320
+ adapter: nil, adapter_default: false, family: DatabaseFamily.of(nil), dump: Dump.new(format, path),
321
+ migrate_dirs: [ File.join(root, "db", "#{name}_migrate") ], config: nil)
322
+ end
323
+
324
+ # HashConfig#database_tasks?: a replica runs none, and neither does database_tasks: false or with no value.
325
+ def tasks?(settings)
326
+ !settings["replica"] && !!settings.fetch("database_tasks", true)
327
+ end
328
+
329
+ def settings_of(value)
330
+ value = value.configuration_hash if value.respond_to?(:configuration_hash)
331
+ value.is_a?(Hash) ? value.transform_keys(&:to_s) : {}
332
+ end
333
+
334
+ def primary_dump(root)
335
+ Introspectors::SchemaDumpPath.present(root) || Introspectors::SchemaDumpPath.candidates(root).first
336
+ end
337
+
338
+ def connected_name
339
+ ActiveRecord::Base.connection_db_config.name
340
+ rescue StandardError => e
341
+ RailsAiContext.debug_fail(e, nil, label: "connected_name")
342
+ end
343
+
344
+ # ActiveRecord's configurations describe the app booted at Rails.root; any other root is read from its file.
345
+ def booted_environments(root)
346
+ return nil if RailsAiContext.static_tier? || !defined?(ActiveRecord::Base) || !defined?(Rails.root) || Rails.root.to_s != root
347
+
348
+ configs = ActiveRecord::Base.configurations.configurations
349
+ return nil if configs.empty?
350
+
351
+ configs.each_with_object({}) { |config, found| (found[config.env_name] ||= {})[config.name] = config }
352
+ rescue StandardError => e
353
+ RailsAiContext.debug_fail(e, nil, label: "booted_environments")
354
+ end
355
+
356
+ # Rails builds the primary from DATABASE_URL when the file has no entry for the environment.
357
+ def static_environments(root)
358
+ environments = (DatabaseYml.file(root) || {}).transform_values { |config| DatabaseYml.databases_in(config) }.reject { |_, found| found.empty? }
359
+ current = RailsAiContext.environment_name
360
+ environments = environments.merge(current => { "primary" => {} }) if !environments.key?(current) && !ENV["DATABASE_URL"].to_s.empty?
361
+ environments
362
+ end
363
+
364
+ # An environment has a config/environments file. Any other key holds an anchor (`default: &default`).
365
+ def with_environment_file(root, environments)
366
+ files = Dir.glob(File.join(root, "config/environments/*.rb")).map { |path| File.basename(path, ".rb") }
367
+ return environments if files.empty?
368
+
369
+ current = RailsAiContext.environment_name
370
+ environments.select { |env_name, _| env_name == current || files.include?(env_name) }
371
+ end
372
+
373
+ def dump_files(root)
374
+ DUMP_GLOBS.flat_map { |format, glob| Dir.glob(File.join(root, "db", glob)).sort.map { |path| [ format, path ] } }
375
+ end
376
+ end
377
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "yaml"
4
+
3
5
  module RailsAiContext
4
6
  # Detects app-level facts that change which introspection applies, from
5
7
  # artifacts that exist without booting (config files, Gemfile.lock).
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "rbconfig"
3
4
  require "shellwords"
4
5
  require_relative "entry_boot"
5
6
  require_relative "../safe_path"