drive 0.6.0 → 0.7.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: d7864c84eb75ad2394534293c1a9f3232eaff9b7a69352d84d8e64605aba56ca
4
- data.tar.gz: 6545371dbd8af274b39307dc8139b1cb21ad76f49f4ef15a0ff7478b791eb8fe
3
+ metadata.gz: ba84684bcb7ceb627421453571e0976616e77b7756ef5677ae79f7837b23945b
4
+ data.tar.gz: 2068762d2e4c2ec3c94eee70b35cc14b6270d74504c55d02d4be4bc2ccf7b280
5
5
  SHA512:
6
- metadata.gz: fc337a89d407e9b9ac35ae10febdffb7d202a056bf6403f6533e0cc289965917b74543fa625ee56a15718a9c0ddd818f03fc1fd7cca9cd02e139cf077033aaf9
7
- data.tar.gz: 3d3b30536d3427158fd118908f8b9ab98f82e3a876d835cb7f62846c1c250bbda0e9a4c680f890de1c79d8ec30f66eb01a08f5ee13b30afbe1d88498c14af057
6
+ metadata.gz: 15717e1c2263a8335350fe3d6bcba8d9807df283f6c72180b4e273d6912ef39a1f82172798e7265f189ae92c39b5689ba6cbdf3fd25e08c0be09b1a0aa4b21c0
7
+ data.tar.gz: 881c0fdbefb2d8466f1443d6d867c1965964b52f68801fd1000cf4117e5c300086f05b1a8cef048558ced13eae2398c4ed38dcb84e4f0cb5663d6e671d5f2541
data/CHANGELOG.md CHANGED
@@ -6,6 +6,16 @@ For more information about changelogs, check [Keep a Changelog](http://keepachan
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## 0.7.0 - 2026-10-16
10
+
11
+ * [BREAKING] `Mirrored`, `Counted`, `Run`, `AccessFile` and `Drive::TestHelper` are the `mirroring` gem's
12
+
13
+ An app that mirrors adds `gem 'mirroring'` and declares each mirrored model with
14
+ `mirroring key: :code` (`key: false` where the source names none) in place of `include Mirrored`
15
+ and `KEY`, and includes `Mirroring::TestHelper` in its tests. `Run.record`, `mirror`,
16
+ `include Counted` and `AccessFile` keep their names. Drive no longer needs PostgreSQL, nor ships
17
+ the migrations of runs: an app keeps the `runs` table it has.
18
+
9
19
  ## 0.6.0 - 2026-10-09
10
20
 
11
21
  * [BREAKING] `include RateLimited` sets no limit by itself: a controller calls `rate_limited`
data/README.md CHANGED
@@ -1,15 +1,9 @@
1
1
  # Drive
2
2
 
3
- A Rails engine for the easyFAA apps. Each keeps a copy of what the FAA or the NTSB publishes,
4
- changing only the rows the source did and counting them, and each sits behind Cloudflare and
5
- shows the same error pages.
3
+ A Rails engine for the easyFAA apps. Each sits behind Cloudflare and shows the same error pages.
4
+ A copy of the tables a source publishes is the [`mirroring`](https://github.com/easyfaa/mirroring)
5
+ gem's.
6
6
 
7
- - **`Mirrored`**: a table copying one of the source's tables field for field, as text. A refresh
8
- stages the source's table, then inserts, updates and deletes only the rows the source did.
9
- - **`Counted`**: a table written a record at a time, such as a document fetched by URL.
10
- - **`Run`**: one refresh, in one transaction, the only one running, with how many rows it
11
- inserted, updated and deleted.
12
- - **`AccessFile`**: stages a table of a Microsoft Access file, read with `mdb-export`.
13
7
  - **`RateLimited`**: `rate_limited` holds an address to 20 pages a minute and 200 a day, or the
14
8
  app's own limits, and tells it so in words. A search engine's own crawler has no limit.
15
9
  - **`Crawler`**: whether a request comes from a search engine's crawler: Cloudflare vouches for
@@ -21,8 +15,6 @@ shows the same error pages.
21
15
  - **`NNumber`**, **`Designation`**, **`Serial`**: an N-number, a designation's key and a serial
22
16
  number, as every app reads them.
23
17
 
24
- It needs PostgreSQL.
25
-
26
18
  ## How to install
27
19
 
28
20
  ```bash
@@ -31,55 +23,10 @@ gem install drive
31
23
 
32
24
  ```ruby
33
25
  # Gemfile
34
- gem 'drive', '~> 0.6.0'
35
- ```
36
-
37
- `~> 0.6.0` takes every 0.6.x and stops short of 0.7, which may break what 0.6 does.
38
-
39
- Then copy its migrations, of runs, and run them:
40
-
41
- ```bash
42
- bin/rails drive:install:migrations db:migrate
43
- ```
44
-
45
- ## How to use
46
-
47
- A mirrored table has the source's fields as text columns, plus `source`, `row_key` and
48
- `row_hash`, unique on `source` and `row_key`. Its model says which fields tell a row apart:
49
-
50
- ```ruby
51
- class Engine < ApplicationRecord
52
- include Mirrored
53
-
54
- KEY = %w[code].freeze # [] where the source names none: a row is then told apart by all it says
55
- end
26
+ gem 'drive', '~> 0.7.0'
56
27
  ```
57
28
 
58
- A refresh stages the source's table and mirrors it, inside a run:
59
-
60
- ```ruby
61
- Run.record 'engines' do
62
- staged = AccessFile.new('ENGINE.accdb').stage('ENGINE', Engine.connection)
63
- Engine.mirror staged, source: 'ENGINE'
64
- end
65
- ```
66
-
67
- A table written a record at a time counts each save and delete in the run at hand:
68
-
69
- ```ruby
70
- class Publication < ApplicationRecord
71
- include Counted
72
- end
73
- ```
74
-
75
- Runs commit one at a time, so another app reading the tables directly sees either the last
76
- run's rows or the one before's, never a mix.
77
-
78
- In tests, `Drive::TestHelper#stage` stages rows as a source's file would:
79
-
80
- ```ruby
81
- ActiveSupport.on_load(:active_support_test_case) { include Drive::TestHelper }
82
- ```
29
+ `~> 0.7.0` takes every 0.7.x and stops short of 0.8, which may break what 0.7 does.
83
30
 
84
31
  ## Development
85
32
 
data/lib/drive/engine.rb CHANGED
@@ -1,7 +1,6 @@
1
1
  module Drive
2
- # Lends the host its models (Run, Mirrored, Counted, AccessFile, Crawler, NNumber, Designation,
3
- # Serial), RateLimited, the error pages and the migrations of runs
4
- # (`bin/rails drive:install:migrations`).
2
+ # Lends the host its models (Crawler, NNumber, Designation, Serial), RateLimited and the error
3
+ # pages.
5
4
  class Engine < ::Rails::Engine
6
5
  engine_name 'drive'
7
6
 
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.6.0'.freeze
3
+ VERSION = '0.7.0'.freeze
4
4
  end
data/lib/drive.rb CHANGED
@@ -3,8 +3,7 @@ require 'drive/inflections'
3
3
  require 'drive/cloudflare'
4
4
  require 'drive/engine'
5
5
 
6
- # What the easyFAA apps share: a copy of the tables another source publishes, changing only the
7
- # rows the source did (Mirrored, Counted, Run), and what every app in front of Cloudflare needs
8
- # alike (Drive::Cloudflare, Crawler, RateLimited, the error pages, NNumber, Designation, Serial).
6
+ # What every easyFAA app in front of Cloudflare needs alike: Drive::Cloudflare, Crawler,
7
+ # RateLimited, the error pages, NNumber, Designation and Serial.
9
8
  module Drive
10
9
  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.6.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - claudiob
@@ -9,34 +9,6 @@ bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
- - !ruby/object:Gem::Dependency
13
- name: csv
14
- requirement: !ruby/object:Gem::Requirement
15
- requirements:
16
- - - ">="
17
- - !ruby/object:Gem::Version
18
- version: '0'
19
- type: :runtime
20
- prerelease: false
21
- version_requirements: !ruby/object:Gem::Requirement
22
- requirements:
23
- - - ">="
24
- - !ruby/object:Gem::Version
25
- version: '0'
26
- - !ruby/object:Gem::Dependency
27
- name: pg
28
- requirement: !ruby/object:Gem::Requirement
29
- requirements:
30
- - - ">="
31
- - !ruby/object:Gem::Version
32
- version: '0'
33
- type: :runtime
34
- prerelease: false
35
- version_requirements: !ruby/object:Gem::Requirement
36
- requirements:
37
- - - ">="
38
- - !ruby/object:Gem::Version
39
- version: '0'
40
12
  - !ruby/object:Gem::Dependency
41
13
  name: rails
42
14
  requirement: !ruby/object:Gem::Requirement
@@ -51,8 +23,7 @@ dependencies:
51
23
  - - ">="
52
24
  - !ruby/object:Gem::Version
53
25
  version: '8.1'
54
- description: 'A Rails engine for the easyFAA apps: each mirrors a public source (the
55
- FAA, the NTSB) table for table, changing only the rows the source did, and each
26
+ description: 'A Rails engine for the easyFAA apps: each sits behind Cloudflare and
56
27
  shows the same error pages, limits, crawlers and N-numbers.'
57
28
  email:
58
29
  - claudiob@users.noreply.github.com
@@ -65,24 +36,16 @@ files:
65
36
  - README.md
66
37
  - app/controllers/concerns/rate_limited.rb
67
38
  - app/controllers/drive/errors_controller.rb
68
- - app/models/access_file.rb
69
- - app/models/concerns/counted.rb
70
- - app/models/concerns/mirrored.rb
71
39
  - app/models/crawler.rb
72
40
  - app/models/designation.rb
73
- - app/models/drive/current.rb
74
41
  - app/models/n_number.rb
75
- - app/models/run.rb
76
42
  - app/models/serial.rb
77
43
  - app/views/drive/errors/show.html.erb
78
44
  - app/views/layouts/drive/errors.html.erb
79
- - db/migrate/20261005100000_create_runs_and_changes.rb
80
- - db/migrate/20261006100000_drop_changes.rb
81
45
  - lib/drive.rb
82
46
  - lib/drive/cloudflare.rb
83
47
  - lib/drive/engine.rb
84
48
  - lib/drive/inflections.rb
85
- - lib/drive/test_helper.rb
86
49
  - lib/drive/version.rb
87
50
  homepage: https://github.com/easyfaa/drive
88
51
  licenses:
@@ -105,7 +68,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
105
68
  - !ruby/object:Gem::Version
106
69
  version: '0'
107
70
  requirements: []
108
- rubygems_version: 4.0.3
71
+ rubygems_version: 4.0.20
109
72
  specification_version: 4
110
- summary: 'What the easyFAA apps share: mirrored FAA and NTSB tables, and more.'
73
+ summary: 'What the easyFAA apps share: error pages, Cloudflare, limits and N-numbers.'
111
74
  test_files: []
@@ -1,47 +0,0 @@
1
- require 'csv'
2
-
3
- # A Microsoft Access database, as DRS and the NTSB publish theirs, read with mdb-export (mdbtools).
4
- class AccessFile
5
- # The mdb-export command, writing dates with their century, which it leaves out by default.
6
- EXPORT = ['mdb-export', '-D', '%Y-%m-%d', '-T', '%Y-%m-%d %H:%M:%S'].freeze
7
-
8
- # @param path [String] .accdb or .mdb file.
9
- def initialize(path)
10
- @path = path
11
- end
12
-
13
- # Copies a table into a temporary one of text columns, dropped when the transaction commits:
14
- # "AD Number" is ad_number, and the table's own id is access_id.
15
- # @param table [String] table name in the Access file, such as AD_DATA or events.
16
- # @param connection [ActiveRecord::ConnectionAdapters::PostgreSQLAdapter] inside a transaction.
17
- # @return [String] temporary table.
18
- def stage(table, connection)
19
- staged = "staged_#{column table}"
20
- IO.popen [*EXPORT, @path, table], 'rb' do |io|
21
- columns = CSV.parse_line(io.gets.force_encoding(Encoding::UTF_8)).map { column it }
22
- connection.execute "DROP TABLE IF EXISTS #{staged}"
23
- connection.execute <<~SQL.squish
24
- CREATE TEMP TABLE #{staged} (#{columns.map { %("#{it}" text) }.join ', '}) ON COMMIT DROP
25
- SQL
26
- copy io, staged, connection.raw_connection
27
- end
28
- staged
29
- end
30
-
31
- private
32
-
33
- def column(name)
34
- column = name.strip.downcase.gsub(/[^a-z0-9]+/, '_').delete_prefix('_').delete_suffix('_')
35
- # An Access table's own counter, named apart from the id Rails gives every row.
36
- column == 'id' ? 'access_id' : column
37
- end
38
-
39
- def copy(io, staged, raw)
40
- raw.copy_data "COPY #{staged} FROM STDIN WITH (FORMAT csv)" do
41
- while (chunk = io.read(1 << 20))
42
- # Some narratives carry null bytes, which PostgreSQL cannot keep in text.
43
- raw.put_copy_data chunk.delete("\x00")
44
- end
45
- end
46
- end
47
- end
@@ -1,17 +0,0 @@
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
@@ -1,90 +0,0 @@
1
- # A table copying a source's table field for field as text, changing only the rows the source did.
2
- module Mirrored
3
- extend ActiveSupport::Concern
4
-
5
- # The columns this app adds to what the source published.
6
- OWN = %w[id source row_key row_hash created_at updated_at].freeze
7
-
8
- included do
9
- # A source's own `type` column is data, not Rails' single-table inheritance.
10
- self.inheritance_column = nil
11
- end
12
-
13
- class_methods do
14
- # Brings the rows from one source in line with a table staged from it, in the current run.
15
- # @param staged [String] temporary table holding what the source publishes.
16
- # @param source [String] file or document type the rows come from.
17
- # @return [Hash{Symbol => Integer}] how many rows were inserted, updated and deleted.
18
- def mirror(staged, source:)
19
- stage_incoming staged
20
- counts = { inserted: inserted(source), updated: updated(source), deleted: deleted(source) }
21
- apply source
22
- counts.tap { Run.current.tally(**it) }
23
- end
24
-
25
- private
26
-
27
- def fields = column_names - OWN
28
-
29
- def stage_incoming(staged)
30
- present = connection.columns(staged).map(&:name)
31
- picked = fields.map { |field| present.include?(field) ? %(s."#{field}") : 'NULL::text' }
32
- hash = "md5(ROW(#{picked.join ', '})::text)"
33
- keys = self::KEY.map { %(s."#{it}") }
34
- connection.execute 'DROP TABLE IF EXISTS mirror_incoming'
35
- connection.execute <<~SQL.squish
36
- CREATE TEMP TABLE mirror_incoming ON COMMIT DROP AS
37
- SELECT DISTINCT ON (row_key) * FROM (
38
- SELECT #{picked.zip(fields).map { |value, field| %(#{value} AS "#{field}") }.join ', '},
39
- #{keys.any? ? "concat_ws('|', #{keys.join ', '})" : hash} AS row_key,
40
- #{hash} AS row_hash
41
- FROM #{staged} s
42
- ) rows ORDER BY row_key, row_hash
43
- SQL
44
- connection.execute 'CREATE INDEX ON mirror_incoming (row_key)'
45
- connection.execute 'ANALYZE mirror_incoming'
46
- end
47
-
48
- def inserted(source)
49
- rows <<~SQL.squish
50
- FROM mirror_incoming i
51
- WHERE NOT EXISTS (SELECT 1 FROM #{quoted_table_name} t
52
- WHERE t.source = #{connection.quote source} AND t.row_key = i.row_key)
53
- SQL
54
- end
55
-
56
- def updated(source)
57
- rows <<~SQL.squish
58
- FROM mirror_incoming i JOIN #{quoted_table_name} t USING (row_key)
59
- WHERE t.source = #{connection.quote source} AND t.row_hash <> i.row_hash
60
- SQL
61
- end
62
-
63
- def deleted(source)
64
- rows <<~SQL.squish
65
- FROM #{quoted_table_name} i
66
- WHERE i.source = #{connection.quote source}
67
- AND NOT EXISTS (SELECT 1 FROM mirror_incoming n WHERE n.row_key = i.row_key)
68
- SQL
69
- end
70
-
71
- def rows(from) = connection.select_value("SELECT count(*) #{from}").to_i
72
-
73
- def apply(source)
74
- quoted = fields.map { connection.quote_column_name it }
75
- connection.delete <<~SQL.squish
76
- DELETE FROM #{quoted_table_name} t WHERE t.source = #{connection.quote source}
77
- AND NOT EXISTS (SELECT 1 FROM mirror_incoming n WHERE n.row_key = t.row_key)
78
- SQL
79
- connection.execute <<~SQL.squish
80
- INSERT INTO #{quoted_table_name} AS t
81
- (source, row_key, row_hash, #{quoted.join ', '}, created_at, updated_at)
82
- SELECT #{connection.quote source}, row_key, row_hash, #{quoted.join ', '}, now(), now()
83
- FROM mirror_incoming
84
- ON CONFLICT (source, row_key) DO UPDATE SET row_hash = excluded.row_hash,
85
- #{quoted.map { "#{it} = excluded.#{it}" }.join ', '}, updated_at = now()
86
- WHERE t.row_hash <> excluded.row_hash
87
- SQL
88
- end
89
- end
90
- end
@@ -1,7 +0,0 @@
1
- module Drive
2
- # The run of the refresh at hand, which every change belongs to.
3
- class Current < ActiveSupport::CurrentAttributes
4
- # The run every change of the refresh at hand belongs to.
5
- attribute :run
6
- end
7
- end
data/app/models/run.rb DELETED
@@ -1,33 +0,0 @@
1
- # One refresh of a source: when it started and finished, and how many rows it changed.
2
- class Run < ApplicationRecord
3
- # The lock every refresh takes, so runs commit one at a time.
4
- LOCK = 1_729_001
5
-
6
- # Refreshes a source inside one transaction, the only one running, recording what it changed.
7
- # @param source [String] what is refreshed, such as "registry".
8
- # @return [Run] finished run.
9
- def self.record(source)
10
- transaction do
11
- connection.execute "SELECT pg_advisory_xact_lock(#{LOCK})"
12
- run = create! source:, started_at: Time.current
13
- Drive::Current.set(run:) { yield run }
14
- run.tap { it.update! finished_at: Time.current }
15
- end
16
- end
17
-
18
- # @return [Run] run of the refresh at hand.
19
- def self.current = Drive::Current.run
20
-
21
- # Adds what a table's refresh did to this run's counts.
22
- # @param inserted [Integer] rows inserted.
23
- # @param updated [Integer] rows updated.
24
- # @param deleted [Integer] rows deleted.
25
- def tally(inserted: 0, updated: 0, deleted: 0)
26
- self.inserted += inserted
27
- self.updated += updated
28
- self.deleted += deleted
29
- end
30
-
31
- # @return [String] source and start, as the runs' table names a run.
32
- def to_s = "#{source} #{started_at.to_fs :db}"
33
- end
@@ -1,28 +0,0 @@
1
- # Each refresh of a source (a run), and every row it inserted, changed or deleted (a change),
2
- # which is the feed another app follows by the change's id.
3
- class CreateRunsAndChanges < ActiveRecord::Migration[8.1]
4
- def change
5
- create_enum :operation, %w[ upsert delete ]
6
-
7
- create_table :runs do |t|
8
- t.text :source, null: false
9
- t.integer :inserted, default: 0, null: false
10
- t.integer :updated, default: 0, null: false
11
- t.integer :deleted, default: 0, null: false
12
- t.datetime :started_at, null: false
13
- t.datetime :finished_at
14
- t.virtual :name, type: :text, as: "source || ' #' || id", stored: true
15
- end
16
-
17
- create_table :changes do |t|
18
- t.enum :operation, enum_type: :operation, null: false
19
- t.text :kind, null: false
20
- t.text :source, null: false
21
- t.text :row_key, null: false
22
- t.references :run, null: false, foreign_key: true
23
- t.jsonb :record
24
- t.datetime :created_at, null: false
25
- t.index %i[ kind row_key ]
26
- end
27
- end
28
- end
@@ -1,16 +0,0 @@
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
@@ -1,20 +0,0 @@
1
- module Drive
2
- # What a host's tests need to mirror rows without a source's file.
3
- module TestHelper
4
- # Stages rows as a source's file would, a text column per field, for a model to mirror.
5
- # @param table [String] temporary table to create.
6
- # @param rows [Array<Hash{String => String}>] the rows, each by field.
7
- # @return [String] the table.
8
- def stage(table, rows)
9
- columns = rows.flat_map(&:keys).uniq
10
- connection = ActiveRecord::Base.connection
11
- connection.execute "DROP TABLE IF EXISTS #{table}"
12
- connection.execute "CREATE TEMP TABLE #{table} (#{columns.map { %("#{it}" text) }.join ', '})"
13
- rows.each do |row|
14
- values = columns.map { connection.quote row[it] }
15
- connection.execute "INSERT INTO #{table} VALUES (#{values.join ', '})"
16
- end
17
- table
18
- end
19
- end
20
- end