drive 0.2.0 → 0.4.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5bcf5e0361ac44acb57bedda1f651a948abd3ec6bc43cd214080556a142d4b72
4
- data.tar.gz: 8c0e23b04b48848ee631f8d08a8f2c97cf15263a4ac5c4c6f58c14654de6fe1a
3
+ metadata.gz: cf5ec0cbf839ae0e64fabcecc4f66f40a1e4654252053c57837c8895b290366f
4
+ data.tar.gz: e5819d0ad8774657ad71eb4695971399ac22215fd7c37d96cf7cc8db3c64f543
5
5
  SHA512:
6
- metadata.gz: 7a671f1e9f52a5a4422cbef14c40ab75d968be2bfe3423b9fccf3c225b6fd0227b5bfe8c301d9acd49036433b4e8efbf389a8b3068492f918aaff9b8139c15bd
7
- data.tar.gz: de0351f9c2748aa969ef3fb1e2fbbf027dec38d689b733911f366f4d6e5b1e1f87ec6c7beaf365fa1cd2ab24bbd72f8e7ff42563964e37ebbbabeefd71b780cb
6
+ metadata.gz: c2589ac5dc34686550476cf266d06ef8424c0e3d8307761cb4861d15bc3cd7b00884407ac273132f4efa70b4ec1cc671cec67b0bbec70e426aa53d65dbd3bfd3
7
+ data.tar.gz: e4610a657c780d8458a236bc7512b8dc984e75337e4a17624cff92cc53458799375761027fa5361432c924f0fdfaa088ac5dcde2eddf012039d746ba7e4945f9
data/CHANGELOG.md CHANGED
@@ -6,6 +6,26 @@ For more information about changelogs, check [Keep a Changelog](http://keepachan
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## 0.4.0 - 2026-10-06
10
+
11
+ * [BREAKING] No more log of changes, and no feed
12
+
13
+ Nothing followed the feed: the apps that read a mirror read its tables directly, from the same
14
+ database. `Change`, `Change.prune`, `FeedsController` and the `changes` table go;
15
+ `bin/rails drive:install:migrations db:migrate` copies and runs the migration that drops them.
16
+ `Mirrored` counts the rows a refresh inserts, updates and deletes instead of logging them, and
17
+ `Logged` is now `Counted`, which counts each save and delete in the run at hand; a table
18
+ including it no longer needs `KEY` or `SOURCE`.
19
+
20
+ ## 0.3.0 - 2026-10-06
21
+
22
+ * [FEATURE] `RateLimited` no longer limits a search engine's own crawler
23
+
24
+ `Crawler.verified?(request)` believes a user agent naming Google's, Bing's or Apple's crawler
25
+ only where its address resolves to their domain and that host back to the address, and
26
+ remembers the answer for a day. Behind a proxy, `request.remote_ip` must be the visitor's:
27
+ list the proxy's ranges in `config.action_dispatch.trusted_proxies`.
28
+
9
29
  ## 0.2.0 - 2026-10-05
10
30
 
11
31
  * [BREAKING] `drive` is a new gem: a Rails engine for apps that mirror what a source publishes
data/README.md CHANGED
@@ -1,16 +1,18 @@
1
1
  # Drive
2
2
 
3
3
  A Rails engine for an app that keeps a copy of what somebody else publishes — a government's
4
- files, a monthly database — and tells another app exactly which rows changed.
4
+ files, a monthly database — changing only the rows the source did, and counting them.
5
5
 
6
6
  - **`Mirrored`**: a table copying one of the source's tables field for field, as text. A refresh
7
7
  stages the source's table, then inserts, updates and deletes only the rows the source did.
8
- - **`Logged`**: a table written a record at a time, such as a document fetched by URL.
9
- - **`Run`**: one refresh, in one transaction, the only one running.
10
- - **`Change`**: every row a run inserted, updated or deleted, with the row as it is now.
11
- - **`FeedsController`**: `GET /feed?after=ID&kind=TABLE`, the next 5,000 changes, as JSON.
8
+ - **`Counted`**: a table written a record at a time, such as a document fetched by URL.
9
+ - **`Run`**: one refresh, in one transaction, the only one running, with how many rows it
10
+ inserted, updated and deleted.
12
11
  - **`AccessFile`**: stages a table of a Microsoft Access file, read with `mdb-export`.
13
- - **`RateLimited`**: 20 requests a minute and 200 a day per address, for public pages.
12
+ - **`RateLimited`**: 20 requests a minute and 200 a day per address, for public pages. A search
13
+ engine's own crawler has no limit.
14
+ - **`Crawler`**: whether a request comes from Google's, Bing's or Apple's crawler: its address
15
+ resolves to their domain and back, checked once a day per address.
14
16
 
15
17
  It needs PostgreSQL.
16
18
 
@@ -22,12 +24,12 @@ gem install drive
22
24
 
23
25
  ```ruby
24
26
  # Gemfile
25
- gem 'drive', '~> 0.2.0'
27
+ gem 'drive', '~> 0.4.0'
26
28
  ```
27
29
 
28
- `~> 0.2.0` takes every 0.2.x and stops short of 0.3, which may break what 0.2 does.
30
+ `~> 0.4.0` takes every 0.4.x and stops short of 0.5, which may break what 0.4 does.
29
31
 
30
- Then copy its migration, of runs and changes, and run it:
32
+ Then copy its migrations, of runs, and run them:
31
33
 
32
34
  ```bash
33
35
  bin/rails drive:install:migrations db:migrate
@@ -55,29 +57,16 @@ Run.record 'engines' do
55
57
  end
56
58
  ```
57
59
 
58
- A table written a record at a time names its key column and where its records come from:
60
+ A table written a record at a time counts each save and delete in the run at hand:
59
61
 
60
62
  ```ruby
61
63
  class Publication < ApplicationRecord
62
- include Logged
63
-
64
- KEY = 'document_number'.freeze
65
- SOURCE = 'federal_register'.freeze
64
+ include Counted
66
65
  end
67
66
  ```
68
67
 
69
- The feed is one line in `config/routes.rb`:
70
-
71
- ```ruby
72
- resource :feed, only: :show
73
- ```
74
-
75
- Where `FEED_TOKEN` is set, it asks for that token (`Authorization: Bearer …`). Runs commit one
76
- at a time, so a reader that remembers the last id it applied never misses a change.
77
-
78
- The log is meant to hold about a month: call `Change.prune` after each night's refresh, and it
79
- deletes the changes of runs that started over 30 days ago. A reader that falls further behind
80
- copies the tables afresh.
68
+ Runs commit one at a time, so another app reading the tables directly sees either the last
69
+ run's rows or the one before's, never a mix.
81
70
 
82
71
  In tests, `Drive::TestHelper#stage` stages rows as a source's file would:
83
72
 
@@ -1,9 +1,10 @@
1
- # The limits of a page anybody may ask for, by the address asking: 20 a minute, 200 a day.
1
+ # The limits of a page anybody may ask for, by the address asking: 20 a minute, 200 a day. A search
2
+ # engine's own crawler has none, so it can list every page.
2
3
  module RateLimited
3
4
  extend ActiveSupport::Concern
4
5
 
5
6
  included do
6
- rate_limit to: 20, within: 1.minute, name: 'minute'
7
- rate_limit to: 200, within: 1.day, name: 'day'
7
+ rate_limit to: 20, within: 1.minute, name: 'minute', unless: -> { Crawler.verified? request }
8
+ rate_limit to: 200, within: 1.day, name: 'day', unless: -> { Crawler.verified? request }
8
9
  end
9
10
  end
@@ -0,0 +1,17 @@
1
+ # A table written a record at a time, each save and delete counted in the current run.
2
+ module Counted
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ after_save :count_upsert, if: :saved_changes?
7
+ after_destroy :count_delete
8
+ end
9
+
10
+ private
11
+
12
+ def count_upsert
13
+ Run.current.tally(**(previously_new_record? ? { inserted: 1 } : { updated: 1 }))
14
+ end
15
+
16
+ def count_delete = Run.current.tally(deleted: 1)
17
+ end
@@ -17,9 +17,7 @@ module Mirrored
17
17
  # @return [Hash{Symbol => Integer}] how many rows were inserted, updated and deleted.
18
18
  def mirror(staged, source:)
19
19
  stage_incoming staged
20
- counts = {
21
- inserted: log_inserted(source), updated: log_updated(source), deleted: log_deleted(source),
22
- }
20
+ counts = { inserted: inserted(source), updated: updated(source), deleted: deleted(source) }
23
21
  apply source
24
22
  counts.tap { Run.current.tally(**it) }
25
23
  end
@@ -47,37 +45,30 @@ module Mirrored
47
45
  connection.execute 'ANALYZE mirror_incoming'
48
46
  end
49
47
 
50
- def log_inserted(source)
51
- log "'upsert'", "to_jsonb(i) - 'row_key' - 'row_hash'", source, <<~SQL.squish
48
+ def inserted(source)
49
+ rows <<~SQL.squish
52
50
  FROM mirror_incoming i
53
51
  WHERE NOT EXISTS (SELECT 1 FROM #{quoted_table_name} t
54
52
  WHERE t.source = #{connection.quote source} AND t.row_key = i.row_key)
55
53
  SQL
56
54
  end
57
55
 
58
- def log_updated(source)
59
- log "'upsert'", "to_jsonb(i) - 'row_key' - 'row_hash'", source, <<~SQL.squish
56
+ def updated(source)
57
+ rows <<~SQL.squish
60
58
  FROM mirror_incoming i JOIN #{quoted_table_name} t USING (row_key)
61
59
  WHERE t.source = #{connection.quote source} AND t.row_hash <> i.row_hash
62
60
  SQL
63
61
  end
64
62
 
65
- def log_deleted(source)
66
- log "'delete'", 'NULL', source, <<~SQL.squish
63
+ def deleted(source)
64
+ rows <<~SQL.squish
67
65
  FROM #{quoted_table_name} i
68
66
  WHERE i.source = #{connection.quote source}
69
67
  AND NOT EXISTS (SELECT 1 FROM mirror_incoming n WHERE n.row_key = i.row_key)
70
68
  SQL
71
69
  end
72
70
 
73
- def log(operation, record, source, from)
74
- connection.update <<~SQL.squish
75
- INSERT INTO changes (operation, kind, source, row_key, run_id, record, created_at)
76
- SELECT #{operation}, #{connection.quote table_name}, #{connection.quote source}, i.row_key,
77
- #{Run.current.id}, #{record}, now()
78
- #{from}
79
- SQL
80
- end
71
+ def rows(from) = connection.select_value("SELECT count(*) #{from}").to_i
81
72
 
82
73
  def apply(source)
83
74
  quoted = fields.map { connection.quote_column_name it }
@@ -0,0 +1,33 @@
1
+ require 'resolv'
2
+
3
+ # A search engine's own crawler, told apart from a client that only names one in its user agent:
4
+ # its address resolves to the search engine's domain, and that host back to the address, which is
5
+ # how Google and Bing say to check theirs.
6
+ module Crawler
7
+ # The user agents of the crawlers checked.
8
+ AGENTS = /Googlebot|Google-InspectionTool|Storebot-Google|GoogleOther|bingbot|Applebot/i
9
+
10
+ # The domains their addresses resolve to.
11
+ DOMAINS = %w[.googlebot.com .google.com .search.msn.com .applebot.apple.com].freeze
12
+
13
+ # @param request [ActionDispatch::Request] request a page was asked with.
14
+ # @return [Boolean] whether it came from a crawler, checked once a day for each address.
15
+ def self.verified?(request)
16
+ ip = request.remote_ip
17
+ request.user_agent.to_s.match?(AGENTS) &&
18
+ Rails.cache.fetch(['crawler', ip], expires_in: 1.day) { resolves? ip }
19
+ end
20
+
21
+ # @param ip [String] address a request came from.
22
+ # @return [Boolean] whether it names a crawler's host, and that host names it back.
23
+ def self.resolves?(ip)
24
+ dns = Resolv::DNS.new.tap { it.timeouts = 2 }
25
+ host = dns.getname(ip).to_s
26
+ DOMAINS.any? { host.end_with? it } && dns.getaddresses(host).map(&:to_s).include?(ip)
27
+ rescue Resolv::ResolvError
28
+ false
29
+ ensure
30
+ dns&.close
31
+ end
32
+ private_class_method :resolves?
33
+ end
data/app/models/run.rb CHANGED
@@ -1,11 +1,8 @@
1
1
  # One refresh of a source: when it started and finished, and how many rows it changed.
2
2
  class Run < ApplicationRecord
3
- # The lock every refresh takes, so runs commit one at a time and their changes in id order.
3
+ # The lock every refresh takes, so runs commit one at a time.
4
4
  LOCK = 1_729_001
5
5
 
6
- # Named apart from Active Record’s own `changes`, which says what an unsaved record changed.
7
- has_many :row_changes, class_name: 'Change', dependent: :delete_all
8
-
9
6
  # Refreshes a source inside one transaction, the only one running, recording what it changed.
10
7
  # @param source [String] what is refreshed, such as "registry".
11
8
  # @return [Run] finished run.
@@ -0,0 +1,16 @@
1
+ class DropChanges < ActiveRecord::Migration[8.1]
2
+ def change
3
+ drop_table :changes do |t|
4
+ t.enum :operation, enum_type: :operation, null: false
5
+ t.text :kind, null: false
6
+ t.text :source, null: false
7
+ t.text :row_key, null: false
8
+ t.references :run, null: false, foreign_key: true
9
+ t.jsonb :record
10
+ t.datetime :created_at, null: false
11
+ t.index %i[kind row_key]
12
+ end
13
+
14
+ drop_enum :operation, %w[upsert delete]
15
+ end
16
+ end
data/lib/drive/engine.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  module Drive
2
- # Lends the host its models (Run, Change, Mirrored, Logged, AccessFile), the feed's controller
3
- # and the migration of runs and changes (`bin/rails drive:install:migrations`).
2
+ # Lends the host its models (Run, Mirrored, Counted, AccessFile, Crawler), RateLimited and the
3
+ # migrations of runs (`bin/rails drive:install:migrations`).
4
4
  class Engine < ::Rails::Engine
5
5
  engine_name 'drive'
6
6
  end
data/lib/drive/version.rb CHANGED
@@ -1,4 +1,4 @@
1
1
  module Drive
2
2
  # Version of the gem, read by the gemspec.
3
- VERSION = '0.2.0'.freeze
3
+ VERSION = '0.4.0'.freeze
4
4
  end
data/lib/drive.rb CHANGED
@@ -2,6 +2,6 @@ require 'drive/version'
2
2
  require 'drive/engine'
3
3
 
4
4
  # Keeps a copy of the tables another source publishes, changing only the rows the source did,
5
- # and logs every change for another app to follow (Mirrored, Logged, Run, Change, the feed).
5
+ # and counts what each refresh changed (Mirrored, Counted, Run).
6
6
  module Drive
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: drive
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - claudiob
@@ -52,8 +52,8 @@ dependencies:
52
52
  - !ruby/object:Gem::Version
53
53
  version: '8.1'
54
54
  description: 'A Rails engine for apps that mirror a public source table for table:
55
- each refresh changes only the rows the source did, in one run, and logs each change
56
- for another app to follow through a JSON feed.'
55
+ each refresh changes only the rows the source did, in one run, and counts how many
56
+ it inserted, updated and deleted.'
57
57
  email:
58
58
  - claudiob@users.noreply.github.com
59
59
  executables: []
@@ -64,14 +64,14 @@ files:
64
64
  - MIT-LICENSE
65
65
  - README.md
66
66
  - app/controllers/concerns/rate_limited.rb
67
- - app/controllers/feeds_controller.rb
68
67
  - app/models/access_file.rb
69
- - app/models/change.rb
70
- - app/models/concerns/logged.rb
68
+ - app/models/concerns/counted.rb
71
69
  - app/models/concerns/mirrored.rb
70
+ - app/models/crawler.rb
72
71
  - app/models/drive/current.rb
73
72
  - app/models/run.rb
74
73
  - db/migrate/20261005100000_create_runs_and_changes.rb
74
+ - db/migrate/20261006100000_drop_changes.rb
75
75
  - lib/drive.rb
76
76
  - lib/drive/engine.rb
77
77
  - lib/drive/test_helper.rb
@@ -99,5 +99,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
99
99
  requirements: []
100
100
  rubygems_version: 4.0.3
101
101
  specification_version: 4
102
- summary: Keep a copy of what a source publishes, and a feed of every row it changes.
102
+ summary: Keep a copy of what a source publishes, changing only the rows it changed.
103
103
  test_files: []
@@ -1,25 +0,0 @@
1
- # The changes after one another app has read, a page at a time, for it to keep its copy current.
2
- class FeedsController < ApplicationController
3
- # How many changes a page holds.
4
- PAGE = 5_000
5
-
6
- # The fields of a change the feed answers.
7
- FIELDS = %w[id operation kind source row_key record created_at].freeze
8
-
9
- before_action :authenticate, if: -> { ENV['FEED_TOKEN'].present? }
10
-
11
- # Answers the changes after `after`, of one `kind` (table) or all, as JSON.
12
- def show
13
- changes = Change.after(params[:after]).limit PAGE
14
- changes = changes.where(kind: params[:kind]) if params[:kind]
15
- render json: changes.map { it.attributes.slice(*FIELDS) }
16
- end
17
-
18
- private
19
-
20
- def authenticate
21
- authenticate_or_request_with_http_token do |token|
22
- ActiveSupport::SecurityUtils.secure_compare token, ENV.fetch('FEED_TOKEN')
23
- end
24
- end
25
- end
data/app/models/change.rb DELETED
@@ -1,19 +0,0 @@
1
- # A row a run inserted, changed or deleted, which another app follows by id to keep its copy.
2
- class Change < ApplicationRecord
3
- enum :operation, %w[upsert delete].index_by(&:itself), prefix: true
4
-
5
- belongs_to :run
6
-
7
- # The changes after one another app has already read, in the order they were made.
8
- scope :after, ->(id) { where(id: (id.to_i + 1)..).order(:id) }
9
-
10
- # Deletes the changes of runs that started before a time, so the log holds about a month.
11
- # @param before [Time] start of the oldest run whose changes are kept.
12
- # @return [Integer] changes deleted.
13
- def self.prune(before: 30.days.ago)
14
- where(run_id: Run.where(started_at: ...before).select(:id)).delete_all
15
- end
16
-
17
- # @return [String] operation, table and row key, as the changes' pages name a change.
18
- def to_s = "#{operation} #{kind} #{row_key}"
19
- end
@@ -1,26 +0,0 @@
1
- # A table written a record at a time, each save and delete logged as a change of the current run.
2
- module Logged
3
- extend ActiveSupport::Concern
4
-
5
- included do
6
- after_save :log_upsert, if: :saved_changes?
7
- after_destroy :log_delete
8
- end
9
-
10
- private
11
-
12
- def log_upsert
13
- log :upsert, attributes.except('id', 'created_at', 'updated_at')
14
- Run.current.tally(**(previously_new_record? ? { inserted: 1 } : { updated: 1 }))
15
- end
16
-
17
- def log_delete
18
- log :delete, nil
19
- Run.current.tally deleted: 1
20
- end
21
-
22
- def log(operation, record)
23
- Change.create! operation:, kind: self.class.table_name, source: self.class::SOURCE,
24
- row_key: self[self.class::KEY], run: Run.current, record:
25
- end
26
- end