drive 0.3.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: c3a8ca81a5230ba8bad7ae1d5f02c1dced9130c1e4f25068a079e41bb28e082d
4
- data.tar.gz: 4094ecf0fedb81c77a8764c6aedbc191e89e99400b45369a20b006a514a08663
3
+ metadata.gz: cf5ec0cbf839ae0e64fabcecc4f66f40a1e4654252053c57837c8895b290366f
4
+ data.tar.gz: e5819d0ad8774657ad71eb4695971399ac22215fd7c37d96cf7cc8db3c64f543
5
5
  SHA512:
6
- metadata.gz: b5d1ace9067f6c0ff8f7cd433742e434373ec9f4a755e31a18e37c4acc875e703ea74ce374076ebe028cc919bc347df925df25e76de8802e645111ccb76b7fe2
7
- data.tar.gz: 2e0fa021cee1892ee83e6c8e335a79794c4cc1abe144e9a562516451dd63a8b7c0e202a25b61b31eb33ff7ab4aa1c791d2ac0584f017e5ac409733dee516ef2e
6
+ metadata.gz: c2589ac5dc34686550476cf266d06ef8424c0e3d8307761cb4861d15bc3cd7b00884407ac273132f4efa70b4ec1cc671cec67b0bbec70e426aa53d65dbd3bfd3
7
+ data.tar.gz: e4610a657c780d8458a236bc7512b8dc984e75337e4a17624cff92cc53458799375761027fa5361432c924f0fdfaa088ac5dcde2eddf012039d746ba7e4945f9
data/CHANGELOG.md CHANGED
@@ -6,6 +6,17 @@ 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
+
9
20
  ## 0.3.0 - 2026-10-06
10
21
 
11
22
  * [FEATURE] `RateLimited` no longer limits a search engine's own crawler
data/README.md CHANGED
@@ -1,14 +1,13 @@
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
12
  - **`RateLimited`**: 20 requests a minute and 200 a day per address, for public pages. A search
14
13
  engine's own crawler has no limit.
@@ -25,12 +24,12 @@ gem install drive
25
24
 
26
25
  ```ruby
27
26
  # Gemfile
28
- gem 'drive', '~> 0.2.0'
27
+ gem 'drive', '~> 0.4.0'
29
28
  ```
30
29
 
31
- `~> 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.
32
31
 
33
- Then copy its migration, of runs and changes, and run it:
32
+ Then copy its migrations, of runs, and run them:
34
33
 
35
34
  ```bash
36
35
  bin/rails drive:install:migrations db:migrate
@@ -58,29 +57,16 @@ Run.record 'engines' do
58
57
  end
59
58
  ```
60
59
 
61
- 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:
62
61
 
63
62
  ```ruby
64
63
  class Publication < ApplicationRecord
65
- include Logged
66
-
67
- KEY = 'document_number'.freeze
68
- SOURCE = 'federal_register'.freeze
64
+ include Counted
69
65
  end
70
66
  ```
71
67
 
72
- The feed is one line in `config/routes.rb`:
73
-
74
- ```ruby
75
- resource :feed, only: :show
76
- ```
77
-
78
- Where `FEED_TOKEN` is set, it asks for that token (`Authorization: Bearer …`). Runs commit one
79
- at a time, so a reader that remembers the last id it applied never misses a change.
80
-
81
- The log is meant to hold about a month: call `Change.prune` after each night's refresh, and it
82
- deletes the changes of runs that started over 30 days ago. A reader that falls further behind
83
- 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.
84
70
 
85
71
  In tests, `Drive::TestHelper#stage` stages rows as a source's file would:
86
72
 
@@ -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 }
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.3.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.3.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,15 +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
72
70
  - app/models/crawler.rb
73
71
  - app/models/drive/current.rb
74
72
  - app/models/run.rb
75
73
  - db/migrate/20261005100000_create_runs_and_changes.rb
74
+ - db/migrate/20261006100000_drop_changes.rb
76
75
  - lib/drive.rb
77
76
  - lib/drive/engine.rb
78
77
  - lib/drive/test_helper.rb
@@ -100,5 +99,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
100
99
  requirements: []
101
100
  rubygems_version: 4.0.3
102
101
  specification_version: 4
103
- 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.
104
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