schema_reaper 2.0.0 → 2.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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +3 -0
  3. data/.schema_reaper.yml.example +17 -0
  4. data/CHANGELOG.md +144 -0
  5. data/PRO.md +11 -6
  6. data/README.md +421 -71
  7. data/RELEASE_CHECKLIST.md +13 -2
  8. data/lib/generators/schema_reaper/install/install_generator.rb +11 -11
  9. data/lib/generators/schema_reaper/install/templates/initializer.rb.tt +8 -0
  10. data/lib/schema_reaper/alert_config.rb +21 -1
  11. data/lib/schema_reaper/alert_state.rb +35 -0
  12. data/lib/schema_reaper/analyzers/always_null_column.rb +12 -5
  13. data/lib/schema_reaper/analyzers/base.rb +66 -2
  14. data/lib/schema_reaper/analyzers/dead_column.rb +12 -6
  15. data/lib/schema_reaper/analyzers/dead_table.rb +10 -6
  16. data/lib/schema_reaper/analyzers/duplicate_foreign_key.rb +53 -0
  17. data/lib/schema_reaper/analyzers/duplicate_index.rb +1 -1
  18. data/lib/schema_reaper/analyzers/missing_fk_index.rb +10 -4
  19. data/lib/schema_reaper/analyzers/sequence_exhaustion.rb +55 -0
  20. data/lib/schema_reaper/analyzers/single_value_column.rb +17 -5
  21. data/lib/schema_reaper/analyzers/unused_index.rb +11 -6
  22. data/lib/schema_reaper/baseline.rb +95 -10
  23. data/lib/schema_reaper/cli/baseline_support.rb +45 -0
  24. data/lib/schema_reaper/cli.rb +69 -14
  25. data/lib/schema_reaper/config.rb +28 -0
  26. data/lib/schema_reaper/doctor.rb +151 -0
  27. data/lib/schema_reaper/explain.rb +102 -0
  28. data/lib/schema_reaper/gem_awareness.rb +31 -1
  29. data/lib/schema_reaper/inflect.rb +36 -0
  30. data/lib/schema_reaper/introspect/postgres.rb +258 -55
  31. data/lib/schema_reaper/migration_generator.rb +90 -4
  32. data/lib/schema_reaper/notifier.rb +69 -8
  33. data/lib/schema_reaper/railtie.rb +1 -1
  34. data/lib/schema_reaper/reporters/sarif.rb +15 -6
  35. data/lib/schema_reaper/reporters/slack.rb +67 -0
  36. data/lib/schema_reaper/runner.rb +58 -6
  37. data/lib/schema_reaper/runtime.rb +94 -11
  38. data/lib/schema_reaper/scan_job.rb +24 -2
  39. data/lib/schema_reaper/schema.rb +32 -3
  40. data/lib/schema_reaper/schema_locator.rb +75 -0
  41. data/lib/schema_reaper/static/cache.rb +66 -0
  42. data/lib/schema_reaper/static/scanner.rb +174 -6
  43. data/lib/schema_reaper/tasks/schema_reaper.rake +2 -2
  44. data/lib/schema_reaper/version.rb +1 -1
  45. data/lib/schema_reaper.rb +18 -1
  46. data/schema_reaper.gemspec +1 -2
  47. metadata +13 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 27824319f6c1b0a06c833b1de1e6c9f627f042964f12920083899aebea81c3ee
4
- data.tar.gz: 4a5a7b872774fbb6a4fad373206baa54878d2a1bf77e31f8d24232a45e8e65cb
3
+ metadata.gz: 65c5a4cc1857d1fbf940b5907cbb7d8e48de74cebb7b69eb6dd1b8e2a0c26bfa
4
+ data.tar.gz: 679e01d41a797c37dfd94858464127b1ce8789bda1d92cb905f011a2369fe1c8
5
5
  SHA512:
6
- metadata.gz: 95bfcabf3a810c80deab66508b5f75c04df8149f15e5d134c2977749a893b01e1ba5f471c9774dddad2d431dbe26e6178bb348d973c370b99bdb42f5e1249c18
7
- data.tar.gz: 95f99230df34ab0f09f2ccf713552cf02c513d6c67d27715224283d2af107c60d0929c01a102232c468774766f644f1c831ed97db39d61675591a1aa4032244d
6
+ metadata.gz: ef8476dfea36578ef5c97c43fc97c7334371ce38aa14f0d736028d57e42d370b43363922511676af417d76f5fd9b9b35536e98168253efc9c6a663230c56d8c6
7
+ data.tar.gz: cf4f22deed425352ac3d0553deefb34495e4c234170f402167866fa445b4b4bf707fc3d078a3a612fc99477ed39b9c152f96f4cdfb45555ecfa7a0eaaab9cd82
data/.rubocop.yml CHANGED
@@ -31,6 +31,7 @@ Metrics/BlockLength:
31
31
  - "*.gemspec"
32
32
 
33
33
  Metrics/ParameterLists:
34
+ CountKeywordArgs: false # keyword args name themselves at every call site
34
35
  Exclude:
35
36
  - "spec/**/*"
36
37
 
@@ -45,6 +46,8 @@ Metrics/MethodLength:
45
46
 
46
47
  Metrics/ClassLength:
47
48
  Max: 135
49
+ Exclude:
50
+ - "lib/schema_reaper/introspect/postgres.rb" # mostly SQL heredocs, one per catalog query
48
51
 
49
52
  Metrics/CyclomaticComplexity:
50
53
  Max: 12
@@ -2,6 +2,13 @@
2
2
  ---
3
3
  # database_url: postgres://localhost/myapp_development # else ENV["DATABASE_URL"]
4
4
 
5
+ # PostgreSQL schemas to scan. Tables outside public are reported as schema.table.
6
+ schemas:
7
+ - public
8
+
9
+ # Hide findings below this confidence on every command (the CLI flag overrides).
10
+ min_confidence: 0.0
11
+
5
12
  scan_paths:
6
13
  - app
7
14
  - lib
@@ -33,11 +40,21 @@ gem_awareness: true
33
40
 
34
41
  # Runtime usage log written by the tracker (see README > Runtime signal).
35
42
  runtime_log: .schema_reaper/runtime.jsonl
43
+ # Rotate it to runtime.jsonl.1 past this many bytes; both are read. null: never.
44
+ runtime_log_max_bytes: 52428800
36
45
 
37
46
  # Append-only snapshot log for `schema_reaper trend`.
38
47
  history_log: .schema_reaper/history.jsonl
39
48
 
40
49
  baseline: .schema_reaper/baseline.json
41
50
 
51
+ # Per-file cache of scan results; repeat scans re-parse only changed files.
52
+ scan_cache: .schema_reaper/scan_cache.json
53
+
54
+ # global: a column name anywhere in code counts as use on every table.
55
+ # model: a name only in an app/models file counts for that model's table and
56
+ # the tables it mentions. Stricter; review its findings before acting.
57
+ column_scope: global
58
+
42
59
  # Extra Ruby files to load before analysis -- register custom analyzers here.
43
60
  require: []
data/CHANGELOG.md CHANGED
@@ -1,5 +1,149 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.1.0] - 2026-10-14
4
+
5
+ ### Fixed
6
+ - **Introspection no longer mixes up same-named objects across schemas.**
7
+ Row counts were read from `pg_class` by name alone, foreign keys were joined
8
+ on constraint name alone (constraint names are only unique per table), and
9
+ the primary-key lookup resolved the table through `search_path`. All three
10
+ are now qualified by schema. Foreign keys are read from `pg_constraint`, so
11
+ composite keys keep their columns together.
12
+ - **`ignore.columns` now applies to every column analyzer.** It was only
13
+ honoured by `dead_column`; `always_null_column`, `single_value_column` and
14
+ `missing_fk_index` ignored it.
15
+ - **Partitioned tables are reported once, as the parent.** Each partition used
16
+ to appear as its own table -- and, unreferenced by name, got a `dead_table`
17
+ finding. The parent's row count is now summed from its partitions, and its
18
+ indexes' usage from the partitions' indexes, so `unused_index` no longer
19
+ flags every index on a partitioned table (PostgreSQL 12+).
20
+ - **Stats-based findings say when they are estimates.** On tables larger than
21
+ ANALYZE's 30,000-row sample, `always_null_column` (0.85 -> 0.7) and
22
+ `single_value_column` (0.6 -> 0.5) lower their confidence and say so, and
23
+ `single_value_column` no longer claims "the same value in every row" for a
24
+ column that also holds NULLs.
25
+ - **`missing_fk_index`** finds the referenced table in a non-public table's own
26
+ schema.
27
+ - **Runtime `observed_days` no longer depends on line order.** Several
28
+ processes append to one log, so the span is now min..max timestamp rather
29
+ than first..last line.
30
+
31
+ ### Added
32
+ - `schemas:` config key to scan PostgreSQL schemas besides `public`. Tables
33
+ outside `public` are reported as `schema.table`; `public` tables keep their
34
+ bare names, so existing baselines and ignore lists still match.
35
+ - `sequence_exhaustion` analyzer (CYCLE and descending sequences excluded): a serial/IDENTITY sequence past 50% (medium)
36
+ or 75% (high) of its column type's range -- e.g. an `integer` key fed by a
37
+ bigint sequence.
38
+ - `duplicate_foreign_key` analyzer: two FK constraints on the same columns
39
+ referencing the same key, with the same ON UPDATE / ON DELETE, MATCH and
40
+ deferrability (anything else changes behaviour when dropped). A validated
41
+ copy is kept over a NOT VALID one.
42
+ - `--only` / `--except` to choose analyzers (custom ones included), and a
43
+ `min_confidence` config key used by every command.
44
+ - `generate-migration TABLE --index NAME` (concurrent `remove_index` with
45
+ `disable_ddl_transaction!`; the migration checks at run time whether the
46
+ index is partitioned, which PostgreSQL can't drop concurrently, and drops it
47
+ plainly then) and `generate-migration TABLE` (`drop_table`). The
48
+ `unused_index` / `duplicate_index` fixes say the same for partitioned
49
+ indexes.
50
+ - The runtime tracker also samples tables read by executed SQL (`track_sql:`,
51
+ on by default; FROM / JOIN / UPDATE, not INSERT targets, so a write-only
52
+ table can still be found), so a table read only through raw SQL isn't
53
+ reported dead.
54
+ - The runtime log rotates to `<path>.1` past `runtime_log_max_bytes` (default
55
+ 50 MB), under a file lock so concurrent workers can't rotate twice; both
56
+ generations are read.
57
+ - `schema_reaper doctor`: checks the connection, statistics age, tables never
58
+ `ANALYZE`d, query history, replica status, runtime log length, expired
59
+ baseline entries, the schema file and alert config.
60
+ - `schema_reaper explain ID` (full or partial id): evidence, confidence,
61
+ reclaim, schema line, baseline status and the `generate-migration` command.
62
+ - `# schema_reaper:keep TARGET -- reason` comments in code silence findings.
63
+ - `column_scope: model` (opt-in): a column name seen only in a model file
64
+ counts for that model's table and the tables it mentions, not every table.
65
+ Models are identified by their full class path (`Admin::User` is not
66
+ `User`); STI subclasses map to their parent's table (resolved the way Ruby
67
+ resolves the constant), namespaced models to both possible defaults, and
68
+ abstract bases, POROs and concerns stay shared.
69
+ - Baseline entries carry an optional reason and expiry (`baseline --reason
70
+ --expires YYYY-MM-DD`); `scan --ci` fails on expired ones again, and passing
71
+ `--reason` / `--expires` renews them. `baseline --prune` drops resolved
72
+ entries without accepting new findings. With `--only` / `--except`,
73
+ `baseline` replaces or prunes only those analyzers' entries. The 2.0
74
+ `{"ids": [...]}` file is still read, and the file keeps an `ids` key
75
+ (unexpired entries) so a 2.0.x gem sharing it still works -- though 2.0.x
76
+ ignores reasons and expiry, and rewrites the file in the old format if it
77
+ runs `baseline`.
78
+ - Scheduled reports say what changed since the last one (via `Rails.cache`);
79
+ `config.alert_on = :changes` sends only then. A report only counts as sent
80
+ once a channel accepted it -- a non-2xx webhook response is a failure -- so
81
+ a failed delivery is retried next run instead of suppressed.
82
+ - Slack webhooks get a Block Kit summary instead of a Markdown table Slack
83
+ can't render; other receivers keep the full `{"text": ...}` report
84
+ (`config.webhook_format`).
85
+ - Gem awareness covers solid_queue, solid_cache, solid_cable, good_job,
86
+ delayed_job_active_record, flipper-active_record, acts-as-taggable-on,
87
+ doorkeeper, noticed, activerecord-session_store, discard, acts_as_list and
88
+ ancestry. Every analyzer now leaves a gem-owned table's columns alone.
89
+ - Per-file scan cache (`scan_cache:`), so repeat scans only re-parse changed
90
+ files. Keyed by a SHA-256 digest, so it survives across processes.
91
+ - SARIF results point at the defining line in `db/schema.rb` or
92
+ `db/structure.sql` instead of the top of `db/schema.rb`.
93
+ - `--color` / `--no-color` works on every command.
94
+
95
+ ### Changed
96
+ - Column byte estimates use `pg_stats.avg_width` scaled by the non-NULL share
97
+ when the table has been analysed, instead of a fixed per-type guess (16
98
+ bytes for text/jsonb) that also counted NULLs as full width. An all-NULL
99
+ column is estimated at ~0 bytes.
100
+ - `unused_index` evidence notes that `idx_scan` is per server, so indexes used
101
+ only on read replicas look unused on the primary.
102
+ - CI tests Ruby 3.4 and 4.0 too, and runs the live introspection specs against
103
+ a PostgreSQL 16 service.
104
+
105
+ ## [2.0.1] - 2026-09-23
106
+
107
+ ### Fixed
108
+ - **Report emails now use the app's `ApplicationMailer`.** `SchemaReaper::Mailer`
109
+ was defined while the gem loaded (`Bundler.require`), before the app's
110
+ autoloader could see `app/mailers/application_mailer.rb`, so it always
111
+ inherited `ActionMailer::Base` and sent from the `schema_reaper@localhost`
112
+ placeholder -- which most SMTP relays reject. It is now autoloaded on first
113
+ use, after boot. The install generator's `from:` check now also accepts
114
+ `config.action_mailer.default_options` for apps without an
115
+ `ApplicationMailer`. 2.0.0 workaround: add
116
+ `SchemaReaper::Mailer.default from: "..."` to the initializer.
117
+ - **`rake schema_reaper:alert` no longer silently drops the scan on the
118
+ `:async` ActiveJob adapter** (the default before Rails 8). The job ran on a
119
+ thread inside the rake process, which exited first -- so the whenever and
120
+ plain-cron schedules never scanned. The task now runs the scan inline (with
121
+ email delivered immediately) when the adapter is `:async`, and enqueues as
122
+ before otherwise.
123
+ - **A missing `pg` gem now gives a clear error.** `pg` is deliberately not a
124
+ runtime dependency (the gem uses the host app's own), but without it the
125
+ scan failed with `NameError: uninitialized constant ...::PG` from the
126
+ connection rescue. It now says to add `gem "pg"`.
127
+ - **`min_age_days` in `.schema_reaper.yml` now takes effect.** It was defined
128
+ and documented since 1.0.0 but never read -- `dead_column` used its own
129
+ hardcoded 14-day threshold for trusting runtime data. The analyzer now reads
130
+ the config key (default still 14, so nothing changes unless you set it).
131
+
132
+ ### Changed
133
+ - README rewritten around a quickstart, with animated SVG illustrations
134
+ (`docs/assets/`, excluded from the packaged gem) and corrected details on
135
+ scheduling, the manual trigger's cooldown, CI and gem awareness. Diagrams
136
+ for each major section; animations loop with a long hold on the finished
137
+ frame, since GitHub can't start a README image's animation on scroll.
138
+ Documents the `pg` requirement, that only the `public` schema is scanned, and
139
+ that lists in `.schema_reaper.yml` replace their defaults, and what
140
+ `min_age_days` controls.
141
+ - The `automation`, `safety` and `ci` section diagrams carry label text meant
142
+ to be read, so they no longer loop a fade in/out -- the reveal plays once
143
+ and holds the finished frame, instead of pulling attention away from the
144
+ text every 12s. The safety timeline's progress line keeps a continuous
145
+ moving-dash accent once grown in, since that's motion, not text.
146
+
3
147
  ## [2.0.0] - 2026-09-23
4
148
 
5
149
  ### Added
data/PRO.md CHANGED
@@ -24,7 +24,7 @@ work that only pays off at scale and that individuals rarely need.
24
24
  | History / trend | local JSONL + `trend` | hosted timeline, burndown charts, per-team dashboards |
25
25
  | Baseline gate | one file | per-branch baselines, ownership routing (CODEOWNERS) |
26
26
  | Multi-database / shards | one DB per run | fan-out across N databases, divergence report |
27
- | Scheduled scans + alerts | — | ✅ (cron runner, "queue breached", "new dead column in `billing`") |
27
+ | Scheduled scans + alerts | ✅ self-hosted (`rails g schema_reaper:install`: whenever/sidekiq-cron + webhook/email) | ✅ hosted, zero-install + native Slack/Jira routing + per-team alert rules |
28
28
  | Mountable dashboard engine | — | ✅ `mount SchemaReaper::Pro::Engine` |
29
29
  | SSO, audit log, RBAC | — | ✅ (Enterprise) |
30
30
  | Support | GitHub issues, best effort | private issues, SLA |
@@ -59,7 +59,7 @@ team-scale.
59
59
  | Tier | Who | Price | Limits |
60
60
  |---|---|---|---|
61
61
  | **Solo Pro** | 1 dev, unlimited personal/side projects | $9 / mo ($90 / yr) | 3 databases |
62
- | **Team** | up to 10 devs | $49 / mo ($490 / yr) | 15 databases, Slack/Jira, scheduled scans |
62
+ | **Team** | up to 10 devs | $49 / mo ($490 / yr) | 15 databases, Slack/Jira, hosted scheduled scans |
63
63
  | **Business** | up to 50 devs | $199 / mo | unlimited databases, dashboard engine, priority support |
64
64
  | **Enterprise** | 50+ / regulated | custom (from ~$12k / yr) | SSO, audit log, RBAC, on-prem license server, SLA |
65
65
 
@@ -82,10 +82,15 @@ scheduler + a web app.
82
82
  1. **Now**: Sponsor button, `PRO.md`, "Pro" section in README (waitlist link).
83
83
  2. **v1.1 (OSS)**: MySQL adapter groundwork, real size math — proves demand,
84
84
  some lands free.
85
- 3. **Pro 0.1**: private gem = MySQL + Slack reporter + scheduled scans + license
86
- key. Sell to the first 10 teams at half price for feedback.
87
- 4. **Pro 0.2**: dashboard engine, multi-DB fan-out, per-branch baselines.
88
- 5. **SaaS beta**: once ~25 paying teams self-host.
85
+ 3. **v2.0 (OSS, shipped)**: self-hosted scheduled scans + webhook/email alerts
86
+ (`rails g schema_reaper:install`) — this landed free rather than Pro; it's
87
+ the kind of single-app automation the "anything a single app genuinely
88
+ needs stays free" principle below is about.
89
+ 4. **Pro 0.1**: private gem = MySQL + Slack/Jira reporters + hosted scan
90
+ runner + license key. Sell to the first 10 teams at half price for
91
+ feedback.
92
+ 5. **Pro 0.2**: dashboard engine, multi-DB fan-out, per-branch baselines.
93
+ 6. **SaaS beta**: once ~25 paying teams self-host.
89
94
 
90
95
  ---
91
96