drive 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +13 -3
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +68 -48
  5. data/app/controllers/concerns/rate_limited.rb +9 -0
  6. data/app/controllers/feeds_controller.rb +25 -0
  7. data/app/models/access_file.rb +47 -0
  8. data/app/models/change.rb +19 -0
  9. data/app/models/concerns/logged.rb +26 -0
  10. data/app/models/concerns/mirrored.rb +99 -0
  11. data/app/models/drive/current.rb +7 -0
  12. data/app/models/run.rb +36 -0
  13. data/db/migrate/20261005100000_create_runs_and_changes.rb +28 -0
  14. data/lib/drive/engine.rb +7 -0
  15. data/lib/drive/test_helper.rb +20 -0
  16. data/lib/drive/version.rb +4 -0
  17. data/lib/drive.rb +7 -3
  18. metadata +31 -72
  19. data/CLAUDE.md +0 -690
  20. data/LICENSE.txt +0 -21
  21. data/Rakefile +0 -32
  22. data/STYLE.md +0 -435
  23. data/app/controllers/recourses_controller.rb +0 -96
  24. data/app/javascript/recourse/phone_controller.js +0 -33
  25. data/app/views/layouts/application.html.erb +0 -69
  26. data/app/views/recourses/_breadcrumb.html.erb +0 -18
  27. data/app/views/recourses/_combobox.html.erb +0 -23
  28. data/app/views/recourses/_fields.html.erb +0 -3
  29. data/app/views/recourses/_flash.html.erb +0 -13
  30. data/app/views/recourses/_form.html.erb +0 -9
  31. data/app/views/recourses/_none.html.erb +0 -1
  32. data/app/views/recourses/_row.html.erb +0 -4
  33. data/app/views/recourses/_sidebar.html.erb +0 -10
  34. data/app/views/recourses/_table.html.erb +0 -30
  35. data/app/views/recourses/edit.html.erb +0 -3
  36. data/app/views/recourses/index.html.erb +0 -14
  37. data/app/views/recourses/new.html.erb +0 -3
  38. data/lib/recourse/controllers.rb +0 -13
  39. data/lib/recourse/engine.rb +0 -27
  40. data/lib/recourse/helpers/cells.rb +0 -44
  41. data/lib/recourse/helpers/comboboxes.rb +0 -33
  42. data/lib/recourse/helpers/constraints.rb +0 -94
  43. data/lib/recourse/helpers/examples.rb +0 -35
  44. data/lib/recourse/helpers/fields.rb +0 -54
  45. data/lib/recourse/helpers/navigation.rb +0 -68
  46. data/lib/recourse/helpers/references.rb +0 -89
  47. data/lib/recourse/helpers.rb +0 -60
  48. data/lib/recourse/icons.rb +0 -21
  49. data/lib/recourse/recoursive.rb +0 -19
  50. data/lib/recourse/routes.rb +0 -14
  51. data/lib/recourse/version.rb +0 -4
  52. data/lib/recourse.rb +0 -31
  53. data/vendor/recourse/bootstrap-icons.min.css +0 -5
  54. data/vendor/recourse/bootstrap.bundle.min.js +0 -9
  55. data/vendor/recourse/bootstrap.min.css +0 -2
  56. data/vendor/recourse/fonts/bootstrap-icons.woff +0 -0
  57. data/vendor/recourse/fonts/bootstrap-icons.woff2 +0 -0
  58. data/vendor/recourse/stimulus.js +0 -2563
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0a766ae9bb01009e7d8804c036f35fa6b16099c6196d8314d44c6adb166e8460
4
- data.tar.gz: 70f366312bbf67d552358624d3ea7cff27da2cbfb45a1dcf3153269c7fb3d7d6
3
+ metadata.gz: 5bcf5e0361ac44acb57bedda1f651a948abd3ec6bc43cd214080556a142d4b72
4
+ data.tar.gz: 8c0e23b04b48848ee631f8d08a8f2c97cf15263a4ac5c4c6f58c14654de6fe1a
5
5
  SHA512:
6
- metadata.gz: 102ef91b1ccbf539b7c0afb3f34e0835ea200ffd0958da6f529045b33af3aef742a495d8a0918b649b95cb2940c38ebec742a7b242d7e434ae2f8f021ba5647a
7
- data.tar.gz: 53feb3772e468b24c7c4dc4fb53f3beee4e18cc2c4e152c1dac29e0097221524ee6af4c0718853e29aa469882e2d9413f96f570f52d4c087274494819a99b5b5
6
+ metadata.gz: 7a671f1e9f52a5a4422cbef14c40ab75d968be2bfe3423b9fccf3c225b6fd0227b5bfe8c301d9acd49036433b4e8efbf389a8b3068492f918aaff9b8139c15bd
7
+ data.tar.gz: de0351f9c2748aa969ef3fb1e2fbbf027dec38d689b733911f366f4d6e5b1e1f87ec6c7beaf365fa1cd2ab24bbd72f8e7ff42563964e37ebbbabeefd71b780cb
data/CHANGELOG.md CHANGED
@@ -1,6 +1,16 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ For more information about changelogs, check [Keep a Changelog](http://keepachangelog.com).
6
+
1
7
  ## [Unreleased]
2
8
 
3
- ## [0.1.0] - 2026-08-04
9
+ ## 0.2.0 - 2026-10-05
10
+
11
+ * [BREAKING] `drive` is a new gem: a Rails engine for apps that mirror what a source publishes
4
12
 
5
- - Initial release: one `recourses` line in `config/routes.rb` serves a model's
6
- index, new and edit screens, each of them overridable by the host app.
13
+ The name was once a gem of resource screens, which went on as `recourse`; nothing of it is
14
+ left. This one keeps a copy of a source's tables (`Mirrored`, `Logged`), records each refresh
15
+ (`Run`) and every row it changed (`Change`), and answers them as a feed (`FeedsController`).
16
+ `AccessFile` names an Access table's own `ID` column `access_id`, apart from the row's id.
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright 2026 Claudio Baccigalupo
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md CHANGED
@@ -1,77 +1,97 @@
1
- # Recourse
1
+ # Drive
2
2
 
3
- A `routes.rb` DSL that mounts ready-made resource screens.
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
5
 
5
- Add one line to `config/routes.rb` and Recourse draws the routes and serves the
6
- controllers and views needed to browse a resource. Nothing is written into your
7
- app — and when you want to customize a screen, you eject it.
6
+ - **`Mirrored`**: a table copying one of the source's tables field for field, as text. A refresh
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.
12
+ - **`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.
8
14
 
9
- > **Status:** early development. `index`, `new`, `create`, `edit` and `update`
10
- > work; `show`, `destroy` and the eject generator are not implemented yet.
15
+ It needs PostgreSQL.
11
16
 
12
- ## Installation
17
+ ## How to install
13
18
 
14
- Add the gem to your Gemfile:
19
+ ```bash
20
+ gem install drive
21
+ ```
15
22
 
16
23
  ```ruby
17
- gem 'drive'
24
+ # Gemfile
25
+ gem 'drive', '~> 0.2.0'
26
+ ```
27
+
28
+ `~> 0.2.0` takes every 0.2.x and stops short of 0.3, which may break what 0.2 does.
29
+
30
+ Then copy its migration, of runs and changes, and run it:
31
+
32
+ ```bash
33
+ bin/rails drive:install:migrations db:migrate
18
34
  ```
19
35
 
20
- Then run `bundle install`.
36
+ ## How to use
21
37
 
22
- ## Usage
38
+ A mirrored table has the source's fields as text columns, plus `source`, `row_key` and
39
+ `row_hash`, unique on `source` and `row_key`. Its model says which fields tell a row apart:
23
40
 
24
41
  ```ruby
25
- # config/routes.rb
26
- Rails.application.routes.draw do
27
- recourses :contacts, only: :index
42
+ class Engine < ApplicationRecord
43
+ include Mirrored
44
+
45
+ KEY = %w[code].freeze # [] where the source names none: a row is then told apart by all it says
28
46
  end
29
47
  ```
30
48
 
31
- With no `ContactsController` and no templates in your app, `/contacts` now lists
32
- the id of every `Contact`. Recourse supplies both the controller and the view.
49
+ A refresh stages the source's table and mirrors it, inside a run:
50
+
51
+ ```ruby
52
+ Run.record 'engines' do
53
+ staged = AccessFile.new('ENGINE.accdb').stage('ENGINE', Engine.connection)
54
+ Engine.mirror staged, source: 'ENGINE'
55
+ end
56
+ ```
33
57
 
34
- Anything you write yourself wins. Add `app/controllers/contacts_controller.rb`
35
- and Recourse leaves it alone; add `app/views/contacts/index.html.erb` and Rails
36
- renders yours instead of the one the gem ships.
58
+ A table written a record at a time names its key column and where its records come from:
37
59
 
38
- The usual thing to override is a single row. Add
39
- `app/views/contacts/_row.html.erb` and Recourse's table renders yours for
40
- `/contacts` while every other resource keeps the default:
60
+ ```ruby
61
+ class Publication < ApplicationRecord
62
+ include Logged
41
63
 
42
- ```erb
43
- <%# locals: (recourse: nil, heading: false) -%>
44
- <% if heading %>
45
- <th scope='col'>Contact</th>
46
- <% else %>
47
- <td data-cell='Contact'><%= recourse.name %></td>
48
- <% end %>
64
+ KEY = 'document_number'.freeze
65
+ SOURCE = 'federal_register'.freeze
66
+ end
49
67
  ```
50
68
 
51
- The partial is rendered once for the header row with `heading: true` and no
52
- record, then once per record with `heading: false`. It builds the cells only —
53
- the table, the pagination and the layout stay Recourse's.
69
+ The feed is one line in `config/routes.rb`:
54
70
 
55
- Recourse's controllers inherit from your `ApplicationController`, so its pages
56
- render inside `app/views/layouts/application.html.erb` alongside the rest of your
57
- app, and go through whatever that base class already does. Each page sets its
58
- title with `content_for :title`, so put `yield :title` in that layout's `<title>`
59
- to see it.
71
+ ```ruby
72
+ resource :feed, only: :show
73
+ ```
60
74
 
61
- ## Development
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.
62
77
 
63
- After checking out the repo, run `bin/setup` to install dependencies. Then run
64
- `rake test` to run the tests, or `rake` to run the tests and RuboCop. You can
65
- also run `bin/console` for an interactive prompt.
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.
66
81
 
67
- To install this gem onto your local machine, run `bundle exec rake install`.
82
+ In tests, `Drive::TestHelper#stage` stages rows as a source's file would:
68
83
 
69
- ## Contributing
84
+ ```ruby
85
+ ActiveSupport.on_load(:active_support_test_case) { include Drive::TestHelper }
86
+ ```
70
87
 
71
- Bug reports and pull requests are welcome on GitHub at
72
- https://github.com/claudiob/recourse.
88
+ ## Development
89
+
90
+ ```bash
91
+ bin/setup # the bundle, and the dummy app's databases
92
+ bundle exec rake # the suite at 100% line coverage, and RuboCop
93
+ ```
73
94
 
74
95
  ## License
75
96
 
76
- The gem is available as open source under the terms of the
77
- [MIT License](https://opensource.org/licenses/MIT).
97
+ [MIT](MIT-LICENSE).
@@ -0,0 +1,9 @@
1
+ # The limits of a page anybody may ask for, by the address asking: 20 a minute, 200 a day.
2
+ module RateLimited
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ rate_limit to: 20, within: 1.minute, name: 'minute'
7
+ rate_limit to: 200, within: 1.day, name: 'day'
8
+ end
9
+ end
@@ -0,0 +1,25 @@
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
@@ -0,0 +1,47 @@
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
@@ -0,0 +1,19 @@
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
@@ -0,0 +1,26 @@
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
@@ -0,0 +1,99 @@
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 = {
21
+ inserted: log_inserted(source), updated: log_updated(source), deleted: log_deleted(source),
22
+ }
23
+ apply source
24
+ counts.tap { Run.current.tally(**it) }
25
+ end
26
+
27
+ private
28
+
29
+ def fields = column_names - OWN
30
+
31
+ def stage_incoming(staged)
32
+ present = connection.columns(staged).map(&:name)
33
+ picked = fields.map { |field| present.include?(field) ? %(s."#{field}") : 'NULL::text' }
34
+ hash = "md5(ROW(#{picked.join ', '})::text)"
35
+ keys = self::KEY.map { %(s."#{it}") }
36
+ connection.execute 'DROP TABLE IF EXISTS mirror_incoming'
37
+ connection.execute <<~SQL.squish
38
+ CREATE TEMP TABLE mirror_incoming ON COMMIT DROP AS
39
+ SELECT DISTINCT ON (row_key) * FROM (
40
+ SELECT #{picked.zip(fields).map { |value, field| %(#{value} AS "#{field}") }.join ', '},
41
+ #{keys.any? ? "concat_ws('|', #{keys.join ', '})" : hash} AS row_key,
42
+ #{hash} AS row_hash
43
+ FROM #{staged} s
44
+ ) rows ORDER BY row_key, row_hash
45
+ SQL
46
+ connection.execute 'CREATE INDEX ON mirror_incoming (row_key)'
47
+ connection.execute 'ANALYZE mirror_incoming'
48
+ end
49
+
50
+ def log_inserted(source)
51
+ log "'upsert'", "to_jsonb(i) - 'row_key' - 'row_hash'", source, <<~SQL.squish
52
+ FROM mirror_incoming i
53
+ WHERE NOT EXISTS (SELECT 1 FROM #{quoted_table_name} t
54
+ WHERE t.source = #{connection.quote source} AND t.row_key = i.row_key)
55
+ SQL
56
+ end
57
+
58
+ def log_updated(source)
59
+ log "'upsert'", "to_jsonb(i) - 'row_key' - 'row_hash'", source, <<~SQL.squish
60
+ FROM mirror_incoming i JOIN #{quoted_table_name} t USING (row_key)
61
+ WHERE t.source = #{connection.quote source} AND t.row_hash <> i.row_hash
62
+ SQL
63
+ end
64
+
65
+ def log_deleted(source)
66
+ log "'delete'", 'NULL', source, <<~SQL.squish
67
+ FROM #{quoted_table_name} i
68
+ WHERE i.source = #{connection.quote source}
69
+ AND NOT EXISTS (SELECT 1 FROM mirror_incoming n WHERE n.row_key = i.row_key)
70
+ SQL
71
+ end
72
+
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
81
+
82
+ def apply(source)
83
+ quoted = fields.map { connection.quote_column_name it }
84
+ connection.delete <<~SQL.squish
85
+ DELETE FROM #{quoted_table_name} t WHERE t.source = #{connection.quote source}
86
+ AND NOT EXISTS (SELECT 1 FROM mirror_incoming n WHERE n.row_key = t.row_key)
87
+ SQL
88
+ connection.execute <<~SQL.squish
89
+ INSERT INTO #{quoted_table_name} AS t
90
+ (source, row_key, row_hash, #{quoted.join ', '}, created_at, updated_at)
91
+ SELECT #{connection.quote source}, row_key, row_hash, #{quoted.join ', '}, now(), now()
92
+ FROM mirror_incoming
93
+ ON CONFLICT (source, row_key) DO UPDATE SET row_hash = excluded.row_hash,
94
+ #{quoted.map { "#{it} = excluded.#{it}" }.join ', '}, updated_at = now()
95
+ WHERE t.row_hash <> excluded.row_hash
96
+ SQL
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,7 @@
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 ADDED
@@ -0,0 +1,36 @@
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 and their changes in id order.
4
+ LOCK = 1_729_001
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
+ # Refreshes a source inside one transaction, the only one running, recording what it changed.
10
+ # @param source [String] what is refreshed, such as "registry".
11
+ # @return [Run] finished run.
12
+ def self.record(source)
13
+ transaction do
14
+ connection.execute "SELECT pg_advisory_xact_lock(#{LOCK})"
15
+ run = create! source:, started_at: Time.current
16
+ Drive::Current.set(run:) { yield run }
17
+ run.tap { it.update! finished_at: Time.current }
18
+ end
19
+ end
20
+
21
+ # @return [Run] run of the refresh at hand.
22
+ def self.current = Drive::Current.run
23
+
24
+ # Adds what a table's refresh did to this run's counts.
25
+ # @param inserted [Integer] rows inserted.
26
+ # @param updated [Integer] rows updated.
27
+ # @param deleted [Integer] rows deleted.
28
+ def tally(inserted: 0, updated: 0, deleted: 0)
29
+ self.inserted += inserted
30
+ self.updated += updated
31
+ self.deleted += deleted
32
+ end
33
+
34
+ # @return [String] source and start, as the runs' table names a run.
35
+ def to_s = "#{source} #{started_at.to_fs :db}"
36
+ end
@@ -0,0 +1,28 @@
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
@@ -0,0 +1,7 @@
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`).
4
+ class Engine < ::Rails::Engine
5
+ engine_name 'drive'
6
+ end
7
+ end
@@ -0,0 +1,20 @@
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
@@ -0,0 +1,4 @@
1
+ module Drive
2
+ # Version of the gem, read by the gemspec.
3
+ VERSION = '0.2.0'.freeze
4
+ end
data/lib/drive.rb CHANGED
@@ -1,3 +1,7 @@
1
- # Entry point named after the gem, so `require 'drive'` reaches the library that
2
- # still calls itself Recourse — which is what Bundler does for a gem of this name.
3
- require_relative 'recourse'
1
+ require 'drive/version'
2
+ require 'drive/engine'
3
+
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).
6
+ module Drive
7
+ end