drive 0.5.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 +4 -4
- data/CHANGELOG.md +44 -0
- data/README.md +16 -62
- data/app/controllers/concerns/rate_limited.rb +34 -4
- data/app/controllers/drive/errors_controller.rb +33 -0
- data/app/models/crawler.rb +21 -5
- data/app/models/designation.rb +15 -0
- data/app/models/n_number.rb +29 -0
- data/app/models/serial.rb +11 -0
- data/app/views/drive/errors/show.html.erb +15 -0
- data/app/views/layouts/drive/errors.html.erb +26 -0
- data/lib/drive/cloudflare.rb +67 -0
- data/lib/drive/engine.rb +22 -2
- data/lib/drive/inflections.rb +9 -0
- data/lib/drive/version.rb +1 -1
- data/lib/drive.rb +4 -2
- metadata +13 -42
- data/app/models/access_file.rb +0 -47
- data/app/models/concerns/counted.rb +0 -17
- data/app/models/concerns/mirrored.rb +0 -90
- data/app/models/drive/current.rb +0 -7
- data/app/models/run.rb +0 -33
- data/db/migrate/20261005100000_create_runs_and_changes.rb +0 -28
- data/db/migrate/20261006100000_drop_changes.rb +0 -16
- data/lib/drive/test_helper.rb +0 -20
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ba84684bcb7ceb627421453571e0976616e77b7756ef5677ae79f7837b23945b
|
|
4
|
+
data.tar.gz: 2068762d2e4c2ec3c94eee70b35cc14b6270d74504c55d02d4be4bc2ccf7b280
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 15717e1c2263a8335350fe3d6bcba8d9807df283f6c72180b4e273d6912ef39a1f82172798e7265f189ae92c39b5689ba6cbdf3fd25e08c0be09b1a0aa4b21c0
|
|
7
|
+
data.tar.gz: 881c0fdbefb2d8466f1443d6d867c1965964b52f68801fd1000cf4117e5c300086f05b1a8cef048558ced13eae2398c4ed38dcb84e4f0cb5663d6e671d5f2541
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,50 @@ 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
|
+
|
|
19
|
+
## 0.6.0 - 2026-10-09
|
|
20
|
+
|
|
21
|
+
* [BREAKING] `include RateLimited` sets no limit by itself: a controller calls `rate_limited`
|
|
22
|
+
|
|
23
|
+
`rate_limited` keeps 20 a minute and 200 a day; `rate_limited minute: 120, day: nil,
|
|
24
|
+
scope: 'public'` sets an app's own, counted across every controller of the scope. A client over
|
|
25
|
+
the limit is told so in words, with status 429, instead of an error the app would report. A host
|
|
26
|
+
may say it its own way by defining `refuse_flood(minute:, day:)`, and `crawler?` is a helper.
|
|
27
|
+
|
|
28
|
+
* [FEATURE] `Crawler` also believes Cloudflare's verified bots, where `EDGE_KEY` is set
|
|
29
|
+
|
|
30
|
+
A request whose `X-Edge-Key` is `EDGE_KEY` and whose `X-Verified-Bot` is `true` is a crawler.
|
|
31
|
+
While a search engine's ranges cannot be fetched, no crawler naming it is believed, and the
|
|
32
|
+
request is served rather than failing.
|
|
33
|
+
|
|
34
|
+
* [FEATURE] The same error pages on every app
|
|
35
|
+
|
|
36
|
+
`Drive::ErrorsController` answers 400, 404, 422 and 500 at the paths Rails keeps for them, and
|
|
37
|
+
`/down.html`, as `config.exceptions_app` unless the app sets its own. Delete the app's
|
|
38
|
+
`public/400.html`, `404.html`, `422.html` and `500.html`: a static page wins over the route.
|
|
39
|
+
|
|
40
|
+
* [FEATURE] What every app behind Cloudflare sets alike
|
|
41
|
+
|
|
42
|
+
Cloudflare's addresses (`Drive::Cloudflare::RANGES`) are trusted proxies, unless the app sets
|
|
43
|
+
its own. `Drive::Cloudflare` purges pages by tag or everything, and warms pages, with
|
|
44
|
+
`CLOUDFLARE_ZONE_ID` and `CLOUDFLARE_API_TOKEN`. "aircraft" is its own plural, and FAA, DRS,
|
|
45
|
+
NTSB and XML are acronyms (a constant spelled `Drs` is now `DRS`).
|
|
46
|
+
|
|
47
|
+
* [FEATURE] `NNumber`, `Designation` and `Serial`
|
|
48
|
+
|
|
49
|
+
An N-number as the FAA writes it (`normalize`, `parse`, `valid?`, `display`, `to_param`); a
|
|
50
|
+
designation's and a part number's key (`Designation.key`, `Designation.part_key`), the same as
|
|
51
|
+
the SQL the refinery keys its tables with; a serial number's prefix and number (`Serial.parts`).
|
|
52
|
+
|
|
9
53
|
## 0.5.0 - 2026-10-07
|
|
10
54
|
|
|
11
55
|
* [FEATURE] `RateLimited` no longer limits Claude's, ChatGPT's or Perplexity's search crawler
|
data/README.md
CHANGED
|
@@ -1,20 +1,19 @@
|
|
|
1
1
|
# Drive
|
|
2
2
|
|
|
3
|
-
A Rails engine for
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
- **`
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
- **`
|
|
13
|
-
|
|
14
|
-
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
It needs PostgreSQL.
|
|
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
|
+
|
|
7
|
+
- **`RateLimited`**: `rate_limited` holds an address to 20 pages a minute and 200 a day, or the
|
|
8
|
+
app's own limits, and tells it so in words. A search engine's own crawler has no limit.
|
|
9
|
+
- **`Crawler`**: whether a request comes from a search engine's crawler: Cloudflare vouches for
|
|
10
|
+
it (where `EDGE_KEY` is set), or its address is in the ranges Google, Bing, Apple, Claude,
|
|
11
|
+
ChatGPT or Perplexity publish, fetched once a day.
|
|
12
|
+
- **`Drive::Cloudflare`**: purges pages by tag or everything, and warms pages. Its addresses are
|
|
13
|
+
the app's trusted proxies.
|
|
14
|
+
- **Error pages**: the same page for 400, 404, 422 and 500 on every app, and `/down.html`.
|
|
15
|
+
- **`NNumber`**, **`Designation`**, **`Serial`**: an N-number, a designation's key and a serial
|
|
16
|
+
number, as every app reads them.
|
|
18
17
|
|
|
19
18
|
## How to install
|
|
20
19
|
|
|
@@ -24,55 +23,10 @@ gem install drive
|
|
|
24
23
|
|
|
25
24
|
```ruby
|
|
26
25
|
# Gemfile
|
|
27
|
-
gem 'drive', '~> 0.
|
|
28
|
-
```
|
|
29
|
-
|
|
30
|
-
`~> 0.5.0` takes every 0.5.x and stops short of 0.6, which may break what 0.5 does.
|
|
31
|
-
|
|
32
|
-
Then copy its migrations, of runs, and run them:
|
|
33
|
-
|
|
34
|
-
```bash
|
|
35
|
-
bin/rails drive:install:migrations db:migrate
|
|
36
|
-
```
|
|
37
|
-
|
|
38
|
-
## How to use
|
|
39
|
-
|
|
40
|
-
A mirrored table has the source's fields as text columns, plus `source`, `row_key` and
|
|
41
|
-
`row_hash`, unique on `source` and `row_key`. Its model says which fields tell a row apart:
|
|
42
|
-
|
|
43
|
-
```ruby
|
|
44
|
-
class Engine < ApplicationRecord
|
|
45
|
-
include Mirrored
|
|
46
|
-
|
|
47
|
-
KEY = %w[code].freeze # [] where the source names none: a row is then told apart by all it says
|
|
48
|
-
end
|
|
26
|
+
gem 'drive', '~> 0.7.0'
|
|
49
27
|
```
|
|
50
28
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
```ruby
|
|
54
|
-
Run.record 'engines' do
|
|
55
|
-
staged = AccessFile.new('ENGINE.accdb').stage('ENGINE', Engine.connection)
|
|
56
|
-
Engine.mirror staged, source: 'ENGINE'
|
|
57
|
-
end
|
|
58
|
-
```
|
|
59
|
-
|
|
60
|
-
A table written a record at a time counts each save and delete in the run at hand:
|
|
61
|
-
|
|
62
|
-
```ruby
|
|
63
|
-
class Publication < ApplicationRecord
|
|
64
|
-
include Counted
|
|
65
|
-
end
|
|
66
|
-
```
|
|
67
|
-
|
|
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.
|
|
70
|
-
|
|
71
|
-
In tests, `Drive::TestHelper#stage` stages rows as a source's file would:
|
|
72
|
-
|
|
73
|
-
```ruby
|
|
74
|
-
ActiveSupport.on_load(:active_support_test_case) { include Drive::TestHelper }
|
|
75
|
-
```
|
|
29
|
+
`~> 0.7.0` takes every 0.7.x and stops short of 0.8, which may break what 0.7 does.
|
|
76
30
|
|
|
77
31
|
## Development
|
|
78
32
|
|
|
@@ -1,10 +1,40 @@
|
|
|
1
|
-
# The limits of a page anybody may ask for, by the address asking
|
|
2
|
-
#
|
|
1
|
+
# The limits of a page anybody may ask for, by the address asking, which a search engine's own
|
|
2
|
+
# crawler (Crawler) is never held to, so it can list every page. Refused, a client is told why in
|
|
3
|
+
# words, with status 429, rather than with an error the app would report.
|
|
4
|
+
#
|
|
5
|
+
# rate_limited # 20 a minute and 200 a day
|
|
6
|
+
# rate_limited minute: 120, day: nil, scope: 'public'
|
|
3
7
|
module RateLimited
|
|
4
8
|
extend ActiveSupport::Concern
|
|
5
9
|
|
|
6
10
|
included do
|
|
7
|
-
|
|
8
|
-
|
|
11
|
+
helper_method :crawler? if respond_to? :helper_method
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
class_methods do
|
|
15
|
+
# @param minute [Integer, nil] pages an address may ask for a minute.
|
|
16
|
+
# @param day [Integer, nil] pages an address may ask for a day.
|
|
17
|
+
# @param scope [String, nil] the controllers counted together; each its own if nil.
|
|
18
|
+
def rate_limited(minute: 20, day: 200, scope: nil)
|
|
19
|
+
{ minute: [minute, 1.minute], day: [day, 1.day] }.each do |name, (to, within)|
|
|
20
|
+
next unless to
|
|
21
|
+
|
|
22
|
+
rate_limit(to:, within:, name: name.to_s, scope:, unless: -> { crawler? },
|
|
23
|
+
with: -> { refuse_flood minute:, day: })
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
# Whether a search engine vouches for this client, asked once a request.
|
|
31
|
+
def crawler? = defined?(@crawler) ? @crawler : @crawler = Crawler.verified?(request)
|
|
32
|
+
|
|
33
|
+
# What a client asking too often is told. A host may say it its own way.
|
|
34
|
+
def refuse_flood(minute:, day:)
|
|
35
|
+
limits = { minute:, day: }.compact.map { |per, count| "#{count} a #{per}" }
|
|
36
|
+
render plain: 'Too many requests. Each address may ask for so many pages: ' \
|
|
37
|
+
"#{limits.join ' and '}.\n",
|
|
38
|
+
status: :too_many_requests
|
|
9
39
|
end
|
|
10
40
|
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
module Drive
|
|
2
|
+
# The pages every app shows for 400, 404, 422 and 500 (config.exceptions_app, set by the
|
|
3
|
+
# engine). Plain ActionController::Base, not the host's ApplicationController: a 500 is exactly
|
|
4
|
+
# the moment the database or a session might be what broke, so nothing here may depend on either.
|
|
5
|
+
class ErrorsController < ActionController::Base # rubocop:disable Rails/ApplicationController
|
|
6
|
+
layout 'drive/errors'
|
|
7
|
+
|
|
8
|
+
# Heading and sentence by status.
|
|
9
|
+
MESSAGES = {
|
|
10
|
+
400 => ['Bad request', 'The request could not be understood.'],
|
|
11
|
+
404 => ['Page not found', 'You may have mistyped the address, or the page may have moved.'],
|
|
12
|
+
422 => ['Can’t process that', 'The change was rejected, often because a page had been ' \
|
|
13
|
+
'left open too long. Going back and trying again usually ' \
|
|
14
|
+
'fixes it.',],
|
|
15
|
+
500 => ['Something went wrong', 'We’ve been notified and are looking into it. Try again ' \
|
|
16
|
+
'in a moment.',],
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
# The page for the status the route names, whatever format was asked for.
|
|
20
|
+
def show
|
|
21
|
+
@status = params[:status].to_i
|
|
22
|
+
@heading, @message = MESSAGES.fetch(@status, MESSAGES.fetch(500))
|
|
23
|
+
render status: @status, formats: :html
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# The page shown while the app is down for maintenance.
|
|
27
|
+
def down
|
|
28
|
+
@heading = 'Down for maintenance'
|
|
29
|
+
@message = 'easyFAA is being updated and will be back in a few minutes.'
|
|
30
|
+
render :show, formats: :html
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
data/app/models/crawler.rb
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
require 'ipaddr'
|
|
2
2
|
require 'net/http'
|
|
3
3
|
|
|
4
|
-
# A search engine's own crawler, told apart from a client that only names one in its user agent
|
|
5
|
-
#
|
|
4
|
+
# A search engine's own crawler, told apart from a client that only names one in its user agent.
|
|
5
|
+
# Either Cloudflare vouches for it (a verified bot, in `X-Verified-Bot`, beside the `X-Edge-Key`
|
|
6
|
+
# only Cloudflare and the app know, so a request that skips Cloudflare cannot forge it), or its
|
|
7
|
+
# address is in the ranges its search engine publishes for it.
|
|
6
8
|
module Crawler
|
|
7
9
|
# The ranges each search engine publishes, by the user agents of its crawlers.
|
|
8
10
|
RANGES = {
|
|
@@ -16,13 +18,24 @@ module Crawler
|
|
|
16
18
|
}.freeze
|
|
17
19
|
|
|
18
20
|
# @param request [ActionDispatch::Request] request a page was asked with.
|
|
19
|
-
# @return [Boolean] whether it came from a crawler
|
|
20
|
-
def self.verified?(request)
|
|
21
|
+
# @return [Boolean] whether it came from a crawler.
|
|
22
|
+
def self.verified?(request) = vouched?(request) || published?(request)
|
|
23
|
+
|
|
24
|
+
# @return [Boolean] whether Cloudflare says it verified the bot, where EDGE_KEY is set.
|
|
25
|
+
def self.vouched?(request)
|
|
26
|
+
key = ENV['EDGE_KEY'].presence or return false
|
|
27
|
+
ActiveSupport::SecurityUtils.secure_compare(request.headers['X-Edge-Key'].to_s, key) &&
|
|
28
|
+
request.headers['X-Verified-Bot'] == 'true'
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @return [Boolean] whether the user agent names a crawler whose ranges hold the address.
|
|
32
|
+
def self.published?(request)
|
|
21
33
|
url = RANGES.find { |agent, _| request.user_agent.to_s.match? agent }&.last
|
|
22
34
|
url.present? && ranges(url).any? { it.include? request.remote_ip }
|
|
23
35
|
end
|
|
24
36
|
|
|
25
|
-
|
|
37
|
+
# The ranges at that address, fetched once a day; none while it cannot be reached.
|
|
38
|
+
def self.ranges(url)
|
|
26
39
|
Rails.cache.fetch(['crawler', url], expires_in: 1.day) do
|
|
27
40
|
uri = URI url
|
|
28
41
|
body = Net::HTTP.start(uri.host, use_ssl: true, open_timeout: 2, read_timeout: 2) do |http|
|
|
@@ -30,5 +43,8 @@ module Crawler
|
|
|
30
43
|
end
|
|
31
44
|
JSON.parse(body)['prefixes'].map { IPAddr.new it.values.first }
|
|
32
45
|
end
|
|
46
|
+
rescue Net::OpenTimeout, Net::ReadTimeout, SocketError, SystemCallError, JSON::ParserError
|
|
47
|
+
[]
|
|
33
48
|
end
|
|
49
|
+
private_class_method :vouched?, :published?, :ranges
|
|
34
50
|
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# A model, engine or part number as every app keys it, so one written two ways is found as one:
|
|
2
|
+
# what the refinery writes (models.designation_key, by the same rule in SQL) and the site reads.
|
|
3
|
+
module Designation
|
|
4
|
+
# @param designation [String, nil] as an AD, a data sheet or the registry writes it.
|
|
5
|
+
# @return [String] upcased, nothing after a parenthesis, letters, digits, slashes and pluses
|
|
6
|
+
# only, and a zero after the leading letters read as the letter O: "O320" for "0-320 (A)".
|
|
7
|
+
def self.key(designation)
|
|
8
|
+
designation.to_s.upcase.sub(/\s*\(.*\z/, '').gsub(%r{[^A-Z0-9/+]}, '')
|
|
9
|
+
.sub(/\A([A-Z]*)0(?=\d)/, '\1O')
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# @param part [String, nil] a part number as a shop or an AD writes it.
|
|
13
|
+
# @return [String] upcased, without dashes or spaces: "ST07" for "st-07".
|
|
14
|
+
def self.part_key(part) = part.to_s.upcase.delete('- ')
|
|
15
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# A U.S. registration (an N-number) as text: as the FAA writes one, however it was typed.
|
|
2
|
+
module NNumber
|
|
3
|
+
# One the FAA could assign: N, then one to five characters, a digit 1 to 9 first, up to four
|
|
4
|
+
# more digits, and at most two letters last, never I or O.
|
|
5
|
+
ASSIGNABLE = /\AN(?=.{1,5}\z)[1-9]\d{0,4}[A-HJ-NP-Z]{0,2}\z/
|
|
6
|
+
# One as older records write it: a digit 1 to 9 after the N, then any four letters or digits.
|
|
7
|
+
WRITTEN = /\AN[1-9][A-Z0-9]{0,4}\z/
|
|
8
|
+
|
|
9
|
+
# @param typed [String, nil] as somebody typed it: "n-714ad", "714AD".
|
|
10
|
+
# @return [String] with its N, upcased, letters and digits only: "N714AD".
|
|
11
|
+
def self.normalize(typed) = typed.to_s.upcase.delete('^A-Z0-9').sub(/\A(?!N)/, 'N')
|
|
12
|
+
|
|
13
|
+
# @param typed [String, nil]
|
|
14
|
+
# @return [String, nil] normalized, where it reads as a U.S. registration at all ("A6FKF", another
|
|
15
|
+
# country's, does not).
|
|
16
|
+
def self.parse(typed) = normalize(typed).then { it if it.match? WRITTEN }
|
|
17
|
+
|
|
18
|
+
# @param n_number [String] normalized.
|
|
19
|
+
# @return [Boolean] whether the FAA could assign it.
|
|
20
|
+
def self.valid?(n_number) = n_number.to_s.match?(ASSIGNABLE)
|
|
21
|
+
|
|
22
|
+
# @param n_number [String] as stored: "N714AD" or "N-714AD".
|
|
23
|
+
# @return [String] as a page shows it, the N joined to the rest: "N714AD".
|
|
24
|
+
def self.display(n_number) = n_number.to_s.sub(/\AN-?/, 'N')
|
|
25
|
+
|
|
26
|
+
# @param n_number [String] normalized.
|
|
27
|
+
# @return [String] in an address, without its N: "714AD".
|
|
28
|
+
def self.to_param(n_number) = n_number.to_s.delete_prefix('N')
|
|
29
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# A serial number as the registry or a data sheet writes it, cut into what comes before its last
|
|
2
|
+
# run of digits and that number, so that two are compared as the sheets mean them: "15279406" is
|
|
3
|
+
# within "S/N 15279406 thru 15285594", and "A-100" within "S/N A-100 and up".
|
|
4
|
+
module Serial
|
|
5
|
+
# @param serial [String, nil]
|
|
6
|
+
# @return [Array(String, Integer), Array(nil, nil)] the prefix, dashes dropped, and the number.
|
|
7
|
+
def self.parts(serial)
|
|
8
|
+
match = serial.to_s.upcase.delete(' ').match(/\A(.*?)(\d+)\z/) or return [nil, nil]
|
|
9
|
+
[match[1].delete('-'), match[2].to_i]
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<% content_for :title, "#{@heading} | easyFAA" %>
|
|
2
|
+
<h1>
|
|
3
|
+
<svg viewBox="0 0 64 64" aria-hidden="true">
|
|
4
|
+
<g stroke="currentColor" stroke-width="2.5" stroke-linejoin="round">
|
|
5
|
+
<polygon points="60,8 4,30 25,37" fill="#ffffff"/>
|
|
6
|
+
<polygon points="60,8 25,37 31,57" fill="#cfe0f3"/>
|
|
7
|
+
</g>
|
|
8
|
+
<g stroke="#7a93b3" stroke-width="1.6" stroke-linecap="round">
|
|
9
|
+
<line x1="16" y1="28.5" x2="36" y2="21"/><line x1="21" y1="31.5" x2="44" y2="22.5"/><line x1="27" y1="33" x2="46" y2="25.5"/>
|
|
10
|
+
</g>
|
|
11
|
+
<path d="M10 44 C14 50 18 53 24 55" fill="none" stroke="currentColor" stroke-width="2" stroke-dasharray="3 3" stroke-linecap="round"/>
|
|
12
|
+
</svg>
|
|
13
|
+
<%= @heading %>
|
|
14
|
+
</h1>
|
|
15
|
+
<p><%= @message %></p>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<meta name="color-scheme" content="light dark">
|
|
7
|
+
<meta name="robots" content="noindex">
|
|
8
|
+
<title><%= content_for(:title) || "easyFAA" %></title>
|
|
9
|
+
<style>
|
|
10
|
+
:root{color-scheme:light;--bg:#ffffff;--ink:#15202e;--muted:#50575d;--primary:#1a70d0}
|
|
11
|
+
@media (prefers-color-scheme: dark){:root{color-scheme:dark;--bg:#131415;--ink:#f3f5f8;--muted:#abb2b8;--primary:#689dff}}
|
|
12
|
+
*{box-sizing:border-box}
|
|
13
|
+
body{margin:0;min-height:100svh;display:flex;flex-direction:column;align-items:center;justify-content:center;background:var(--bg);color:var(--ink);font:500 17px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;text-align:center;padding:40px 24px}
|
|
14
|
+
a{color:var(--primary)}
|
|
15
|
+
h1{display:flex;align-items:center;justify-content:center;font-size:clamp(1.5rem,5.5vw,2.25rem);margin:0}
|
|
16
|
+
h1 svg{width:1.1em;height:1.1em;margin-right:0.3em;flex-shrink:0}
|
|
17
|
+
p{color:var(--muted);margin:18px 0 0;max-width:32rem}
|
|
18
|
+
footer{margin-top:40px;font-size:15px;color:var(--muted)}
|
|
19
|
+
footer a{color:inherit;text-decoration:none}
|
|
20
|
+
</style>
|
|
21
|
+
</head>
|
|
22
|
+
<body>
|
|
23
|
+
<%= yield %>
|
|
24
|
+
<footer><a href="https://easyfaa.com/">easyFAA</a></footer>
|
|
25
|
+
</body>
|
|
26
|
+
</html>
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
require 'ipaddr'
|
|
2
|
+
require 'net/http'
|
|
3
|
+
|
|
4
|
+
module Drive
|
|
5
|
+
# The Cloudflare zone in front of every easyFAA app: its addresses, and what it keeps of the
|
|
6
|
+
# pages, forgotten once a night's data has changed them. Nothing is purged where the zone is
|
|
7
|
+
# not named (CLOUDFLARE_ZONE_ID and CLOUDFLARE_API_TOKEN), as in development and test.
|
|
8
|
+
module Cloudflare
|
|
9
|
+
# The addresses Cloudflare reaches an app from, as https://www.cloudflare.com/ips lists them.
|
|
10
|
+
RANGES = %w[
|
|
11
|
+
173.245.48.0/20 103.21.244.0/22 103.22.200.0/22 103.31.4.0/22 141.101.64.0/18
|
|
12
|
+
108.162.192.0/18 190.93.240.0/20 188.114.96.0/20 197.234.240.0/22 198.41.128.0/17
|
|
13
|
+
162.158.0.0/15 104.16.0.0/13 104.24.0.0/14 172.64.0.0/13 131.0.72.0/22
|
|
14
|
+
2400:cb00::/32 2606:4700::/32 2803:f800::/32 2405:b500::/32 2405:8100::/32
|
|
15
|
+
2a06:98c0::/29 2c0f:f248::/32
|
|
16
|
+
].map { IPAddr.new it }.freeze
|
|
17
|
+
# Cloudflare takes up to a hundred tags a request.
|
|
18
|
+
BATCH = 100
|
|
19
|
+
|
|
20
|
+
# @return [Boolean] whether a zone and a token were given.
|
|
21
|
+
def self.configured? = zone.present? && token.present?
|
|
22
|
+
|
|
23
|
+
# Forgets the pages carrying any of these tags, a hundred tags a request.
|
|
24
|
+
# @param tags [Array<String>]
|
|
25
|
+
# @return [void]
|
|
26
|
+
def self.purge(tags) = tags.each_slice(BATCH) { post tags: it }
|
|
27
|
+
|
|
28
|
+
# Forgets every page the zone holds, of every app in it.
|
|
29
|
+
# @return [void]
|
|
30
|
+
def self.purge_everything = post(purge_everything: true)
|
|
31
|
+
|
|
32
|
+
# Asks the zone for each address, which is what makes it keep the answer, so the first reader
|
|
33
|
+
# of the morning is not the one who waits.
|
|
34
|
+
# @param urls [Array<String>]
|
|
35
|
+
# @return [Integer] how many were answered.
|
|
36
|
+
def self.warm(urls)
|
|
37
|
+
urls.count do |url|
|
|
38
|
+
uri = URI url
|
|
39
|
+
get = Net::HTTP::Get.new(uri, 'User-Agent' => 'easyfaa-cache-warmer')
|
|
40
|
+
options = { use_ssl: uri.scheme == 'https', read_timeout: 60 }
|
|
41
|
+
Net::HTTP.start(uri.host, uri.port, **options) { it.request(get).is_a? Net::HTTPSuccess }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Asks Cloudflare to purge, raising when it says no.
|
|
46
|
+
def self.post(body)
|
|
47
|
+
uri = URI "https://api.cloudflare.com/client/v4/zones/#{zone}/purge_cache"
|
|
48
|
+
response = Net::HTTP.post uri, body.to_json, 'Authorization' => "Bearer #{token}",
|
|
49
|
+
'Content-Type' => 'application/json'
|
|
50
|
+
return if response.is_a? Net::HTTPSuccess
|
|
51
|
+
|
|
52
|
+
raise "Cloudflare purge failed: #{response.code} #{response.body}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The zone, from the environment or the credentials.
|
|
56
|
+
def self.zone = setting('CLOUDFLARE_ZONE_ID', :zone_id)
|
|
57
|
+
|
|
58
|
+
# The token, which may purge the zone's cache and nothing else.
|
|
59
|
+
def self.token = setting('CLOUDFLARE_API_TOKEN', :api_token)
|
|
60
|
+
|
|
61
|
+
# A setting from the environment, else from the credentials' `cloudflare:`.
|
|
62
|
+
def self.setting(name, key)
|
|
63
|
+
ENV[name].presence || Rails.application.credentials.dig(:cloudflare, key)
|
|
64
|
+
end
|
|
65
|
+
private_class_method :post, :zone, :token, :setting
|
|
66
|
+
end
|
|
67
|
+
end
|
data/lib/drive/engine.rb
CHANGED
|
@@ -1,7 +1,27 @@
|
|
|
1
1
|
module Drive
|
|
2
|
-
# Lends the host its models (
|
|
3
|
-
#
|
|
2
|
+
# Lends the host its models (Crawler, NNumber, Designation, Serial), RateLimited and the error
|
|
3
|
+
# pages.
|
|
4
4
|
class Engine < ::Rails::Engine
|
|
5
5
|
engine_name 'drive'
|
|
6
|
+
|
|
7
|
+
# Every request reaches the app through Cloudflare: trusted as proxies beside the private
|
|
8
|
+
# ones, its addresses are skipped, and `request.remote_ip` is the visitor's own, for every
|
|
9
|
+
# rate limit and log line.
|
|
10
|
+
initializer 'drive.trusted_proxies', before: :build_middleware_stack do |app|
|
|
11
|
+
app.config.action_dispatch.trusted_proxies ||=
|
|
12
|
+
ActionDispatch::RemoteIp::TRUSTED_PROXIES + Drive::Cloudflare::RANGES
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# The same pages for 400, 404, 422 and 500 on every app (Drive::ErrorsController), at the
|
|
16
|
+
# paths Rails keeps for them, and the page shown while an app is down for maintenance.
|
|
17
|
+
initializer 'drive.errors' do |app|
|
|
18
|
+
app.config.exceptions_app ||= app.routes
|
|
19
|
+
app.routes.prepend do
|
|
20
|
+
%w[400 404 422 500].each do |status|
|
|
21
|
+
get status, to: 'drive/errors#show', defaults: { status: status.to_i }
|
|
22
|
+
end
|
|
23
|
+
get 'down.html', to: 'drive/errors#down', format: false
|
|
24
|
+
end
|
|
25
|
+
end
|
|
6
26
|
end
|
|
7
27
|
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# The words every easyFAA app names alike: more than one aircraft is still "aircraft", whatever
|
|
2
|
+
# precedes it ("NTSB::Aircraft", "accident_aircraft"), and the agencies and formats are the acronyms
|
|
3
|
+
# they are. Set when the gem loads, before any of the app's constants is named.
|
|
4
|
+
ActiveSupport::Inflector.inflections(:en) do |inflect|
|
|
5
|
+
inflect.uncountable 'aircraft'
|
|
6
|
+
inflect.plural(/(aircraft)\z/i, '\1')
|
|
7
|
+
inflect.singular(/(aircraft)\z/i, '\1')
|
|
8
|
+
%w[FAA DRS NTSB XML].each { inflect.acronym it }
|
|
9
|
+
end
|
data/lib/drive/version.rb
CHANGED
data/lib/drive.rb
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
require 'drive/version'
|
|
2
|
+
require 'drive/inflections'
|
|
3
|
+
require 'drive/cloudflare'
|
|
2
4
|
require 'drive/engine'
|
|
3
5
|
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
+
# What every easyFAA app in front of Cloudflare needs alike: Drive::Cloudflare, Crawler,
|
|
7
|
+
# RateLimited, the error pages, NNumber, Designation and Serial.
|
|
6
8
|
module Drive
|
|
7
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.
|
|
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,9 +23,8 @@ dependencies:
|
|
|
51
23
|
- - ">="
|
|
52
24
|
- !ruby/object:Gem::Version
|
|
53
25
|
version: '8.1'
|
|
54
|
-
description: 'A Rails engine for
|
|
55
|
-
|
|
56
|
-
it inserted, updated and deleted.'
|
|
26
|
+
description: 'A Rails engine for the easyFAA apps: each sits behind Cloudflare and
|
|
27
|
+
shows the same error pages, limits, crawlers and N-numbers.'
|
|
57
28
|
email:
|
|
58
29
|
- claudiob@users.noreply.github.com
|
|
59
30
|
executables: []
|
|
@@ -64,17 +35,17 @@ files:
|
|
|
64
35
|
- MIT-LICENSE
|
|
65
36
|
- README.md
|
|
66
37
|
- app/controllers/concerns/rate_limited.rb
|
|
67
|
-
- app/
|
|
68
|
-
- app/models/concerns/counted.rb
|
|
69
|
-
- app/models/concerns/mirrored.rb
|
|
38
|
+
- app/controllers/drive/errors_controller.rb
|
|
70
39
|
- app/models/crawler.rb
|
|
71
|
-
- app/models/
|
|
72
|
-
- app/models/
|
|
73
|
-
-
|
|
74
|
-
-
|
|
40
|
+
- app/models/designation.rb
|
|
41
|
+
- app/models/n_number.rb
|
|
42
|
+
- app/models/serial.rb
|
|
43
|
+
- app/views/drive/errors/show.html.erb
|
|
44
|
+
- app/views/layouts/drive/errors.html.erb
|
|
75
45
|
- lib/drive.rb
|
|
46
|
+
- lib/drive/cloudflare.rb
|
|
76
47
|
- lib/drive/engine.rb
|
|
77
|
-
- lib/drive/
|
|
48
|
+
- lib/drive/inflections.rb
|
|
78
49
|
- lib/drive/version.rb
|
|
79
50
|
homepage: https://github.com/easyfaa/drive
|
|
80
51
|
licenses:
|
|
@@ -97,7 +68,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
97
68
|
- !ruby/object:Gem::Version
|
|
98
69
|
version: '0'
|
|
99
70
|
requirements: []
|
|
100
|
-
rubygems_version: 4.0.
|
|
71
|
+
rubygems_version: 4.0.20
|
|
101
72
|
specification_version: 4
|
|
102
|
-
summary:
|
|
73
|
+
summary: 'What the easyFAA apps share: error pages, Cloudflare, limits and N-numbers.'
|
|
103
74
|
test_files: []
|
data/app/models/access_file.rb
DELETED
|
@@ -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
|
data/app/models/drive/current.rb
DELETED
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
|
data/lib/drive/test_helper.rb
DELETED
|
@@ -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
|