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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +1024 -0
- data/CONTEXT.md +37 -7
- data/CONTRIBUTING.md +30 -1
- data/README.md +9 -9
- data/SECURITY.md +2 -2
- data/docs/ARCHITECTURE.md +12 -4
- data/docs/CLI.md +44 -15
- data/docs/COMPATIBILITY.md +34 -8
- data/docs/CONFIGURATION.md +28 -5
- data/docs/CUSTOM_TOOLS.md +8 -2
- data/docs/FAQ.md +5 -5
- data/docs/GUIDE.md +33 -65
- data/docs/INTROSPECTORS.md +4 -4
- data/docs/QUICKSTART.md +1 -1
- data/docs/RECIPES.md +5 -5
- data/docs/RELEASING.md +3 -1
- data/docs/SECURITY.md +255 -74
- data/docs/SETUP.md +1 -1
- data/docs/STANDALONE.md +2 -2
- data/docs/TOOLS.md +151 -90
- data/docs/TROUBLESHOOTING.md +123 -8
- data/docs/_config.yml +1 -1
- data/docs/adr/0008-query-runs-only-where-it-can-guard.md +38 -0
- data/docs/adr/0009-dbhub-shapes-in-ruby.md +26 -0
- data/docs/adr/0010-one-config-tool-with-sections.md +32 -0
- data/docs/index.md +2 -2
- data/exe/rails-ai-context +24 -9
- data/lib/generators/rails_ai_context/install/install_generator.rb +2 -0
- data/lib/rails_ai_context/app_databases.rb +377 -0
- data/lib/rails_ai_context/app_kind.rb +2 -0
- data/lib/rails_ai_context/cli/app_root.rb +1 -0
- data/lib/rails_ai_context/cli/tool_runner.rb +110 -99
- data/lib/rails_ai_context/configuration.rb +15 -8
- data/lib/rails_ai_context/database_family/mysql_runtime.rb +170 -0
- data/lib/rails_ai_context/database_family/postgresql_plan.rb +124 -0
- data/lib/rails_ai_context/database_family/postgresql_runtime.rb +211 -0
- data/lib/rails_ai_context/database_family/sqlite_runtime.rb +391 -0
- data/lib/rails_ai_context/database_family.rb +282 -0
- data/lib/rails_ai_context/database_file.rb +61 -7
- data/lib/rails_ai_context/database_yml.rb +7 -29
- data/lib/rails_ai_context/doctor.rb +15 -16
- data/lib/rails_ai_context/instrumentation.rb +1 -1
- data/lib/rails_ai_context/introspectors/connection_pool_introspector.rb +8 -23
- data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +6 -70
- data/lib/rails_ai_context/introspectors/declared_constant.rb +19 -7
- data/lib/rails_ai_context/introspectors/env_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/env_references.rb +1 -1
- data/lib/rails_ai_context/introspectors/job_introspector.rb +7 -7
- data/lib/rails_ai_context/introspectors/migration_introspector.rb +8 -11
- data/lib/rails_ai_context/introspectors/migration_replay/statements.rb +9 -9
- data/lib/rails_ai_context/introspectors/model_introspector.rb +33 -39
- data/lib/rails_ai_context/introspectors/multi_database_introspector.rb +33 -30
- data/lib/rails_ai_context/introspectors/node_source.rb +2 -0
- data/lib/rails_ai_context/introspectors/performance_introspector.rb +80 -24
- data/lib/rails_ai_context/introspectors/pg_partitions.rb +1 -4
- data/lib/rails_ai_context/introspectors/schema_conventions.rb +6 -8
- data/lib/rails_ai_context/introspectors/schema_dump_path.rb +12 -14
- data/lib/rails_ai_context/introspectors/schema_introspector.rb +47 -27
- data/lib/rails_ai_context/introspectors/schema_reader.rb +1 -1
- data/lib/rails_ai_context/introspectors/source_scan.rb +39 -4
- data/lib/rails_ai_context/migration_status.rb +18 -46
- data/lib/rails_ai_context/payload.rb +48 -1
- data/lib/rails_ai_context/pending_migrations.rb +3 -4
- data/lib/rails_ai_context/run_cache.rb +5 -0
- data/lib/rails_ai_context/schema_adapter.rb +13 -18
- data/lib/rails_ai_context/secret_columns.rb +506 -0
- data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +2 -2
- data/lib/rails_ai_context/serializers/markdown_serializer.rb +14 -10
- data/lib/rails_ai_context/serializers/section_facts.rb +12 -2
- data/lib/rails_ai_context/serializers/stack_overview_helper.rb +5 -2
- data/lib/rails_ai_context/serializers/tool_guide_helper.rb +35 -7
- data/lib/rails_ai_context/server.rb +143 -29
- data/lib/rails_ai_context/sql_gate.rb +1007 -0
- data/lib/rails_ai_context/tasks/rails_ai_context.rake +1 -2
- data/lib/rails_ai_context/test_helper.rb +3 -1
- data/lib/rails_ai_context/tools/analyze_feature.rb +11 -7
- data/lib/rails_ai_context/tools/base_tool.rb +71 -48
- data/lib/rails_ai_context/tools/dependency_graph.rb +1 -1
- data/lib/rails_ai_context/tools/diagnose.rb +105 -42
- data/lib/rails_ai_context/tools/generate_test.rb +4 -6
- data/lib/rails_ai_context/tools/get_active_support.rb +1 -6
- data/lib/rails_ai_context/tools/get_api.rb +1 -6
- data/lib/rails_ai_context/tools/get_autoload.rb +1 -6
- data/lib/rails_ai_context/tools/get_callbacks.rb +5 -5
- data/lib/rails_ai_context/tools/get_component_catalog.rb +6 -7
- data/lib/rails_ai_context/tools/get_concern.rb +6 -6
- data/lib/rails_ai_context/tools/get_config.rb +189 -12
- data/lib/rails_ai_context/tools/get_context.rb +111 -105
- data/lib/rails_ai_context/tools/get_controllers.rb +8 -5
- data/lib/rails_ai_context/tools/get_conventions.rb +1 -6
- data/lib/rails_ai_context/tools/get_engines.rb +1 -6
- data/lib/rails_ai_context/tools/get_env.rb +1 -6
- data/lib/rails_ai_context/tools/get_env_config.rb +2 -8
- data/lib/rails_ai_context/tools/get_frontend_stack.rb +1 -6
- data/lib/rails_ai_context/tools/get_gems.rb +1 -6
- data/lib/rails_ai_context/tools/get_helper_methods.rb +4 -5
- data/lib/rails_ai_context/tools/get_i18n.rb +1 -7
- data/lib/rails_ai_context/tools/get_job_pattern.rb +1 -1
- data/lib/rails_ai_context/tools/get_mailers.rb +1 -1
- data/lib/rails_ai_context/tools/get_model_details.rb +5 -8
- data/lib/rails_ai_context/tools/get_partial_interface.rb +3 -4
- data/lib/rails_ai_context/tools/get_schema.rb +10 -8
- data/lib/rails_ai_context/tools/get_service_pattern.rb +60 -24
- data/lib/rails_ai_context/tools/get_turbo_map.rb +1 -1
- data/lib/rails_ai_context/tools/migration_advisor.rb +47 -32
- data/lib/rails_ai_context/tools/onboard.rb +24 -24
- data/lib/rails_ai_context/tools/performance_check.rb +15 -2
- data/lib/rails_ai_context/tools/query.rb +142 -1356
- data/lib/rails_ai_context/tools/read_logs.rb +1 -1
- data/lib/rails_ai_context/tools/review_changes.rb +6 -7
- data/lib/rails_ai_context/tools/runtime_info.rb +125 -88
- data/lib/rails_ai_context/tools/safe_call.rb +70 -28
- data/lib/rails_ai_context/tools/search_code.rb +0 -4
- data/lib/rails_ai_context/tools/search_docs.rb +3 -1
- data/lib/rails_ai_context/tools/security_scan.rb +9 -5
- data/lib/rails_ai_context/tools/session_context.rb +18 -12
- data/lib/rails_ai_context/tools/validate.rb +1 -0
- data/lib/rails_ai_context/tools/validate_semantics.rb +5 -5
- data/lib/rails_ai_context/version.rb +1 -1
- data/lib/rails_ai_context.rb +4 -7
- data/server.json +2 -2
- 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
|
-
**
|
|
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
|
|
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
|
-
#
|
|
96
|
-
#
|
|
97
|
-
|
|
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
|
-
|
|
243
|
-
puts ::RailsAiContext::CLI::ToolRunner.
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
|
@@ -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
|