keinaufwand-sync 0.1.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 +7 -0
- data/README.md +48 -0
- data/config/routes.rb +3 -0
- data/lib/generators/keinaufwand/sync/indexes/indexes_generator.rb +25 -0
- data/lib/generators/keinaufwand/sync/install/install_generator.rb +35 -0
- data/lib/generators/keinaufwand/sync/install/templates/create_keinaufwand_assets.rb +19 -0
- data/lib/generators/keinaufwand/sync/install/templates/create_keinaufwand_sync_tables.rb +24 -0
- data/lib/generators/keinaufwand/sync/install/templates/create_keinaufwand_webhook_receipts.rb +16 -0
- data/lib/keinaufwand/asset.rb +14 -0
- data/lib/keinaufwand/assets_controller.rb +12 -0
- data/lib/keinaufwand/record.rb +32 -0
- data/lib/keinaufwand/sync.rb +30 -0
- data/lib/keinaufwand/sync_core/asset.rb +69 -0
- data/lib/keinaufwand/sync_core/configuration.rb +101 -0
- data/lib/keinaufwand/sync_core/engine.rb +7 -0
- data/lib/keinaufwand/sync_core/index_manager.rb +83 -0
- data/lib/keinaufwand/sync_core/railtie.rb +9 -0
- data/lib/keinaufwand/sync_core/record.rb +410 -0
- data/lib/keinaufwand/sync_core/sync_service.rb +86 -0
- data/lib/keinaufwand/sync_core/sync_state.rb +25 -0
- data/lib/keinaufwand/sync_core/tasks/keinaufwand_sync.rake +19 -0
- data/lib/keinaufwand/sync_core/version.rb +5 -0
- data/lib/keinaufwand/sync_core/webhook_processor.rb +119 -0
- data/lib/keinaufwand/sync_core/webhook_receipt.rb +13 -0
- data/lib/keinaufwand/sync_core/webhooks_controller.rb +34 -0
- data/lib/keinaufwand/sync_state.rb +5 -0
- data/lib/keinaufwand/webhook_receipt.rb +5 -0
- metadata +124 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 63f03abe9ebcc27c8e013e60ed0fe51a076a6228e306deeceea89ed14014668b
|
|
4
|
+
data.tar.gz: 8609e5ea6b0aa0d167efe1b9bc3976b233bcb6b6d84d8543986d590850b3975d
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 6f35691e820d95ddee550dc270a5ea96e6399e3c5568e98db9077ca60abd1c1f0b968b0668ac4601f4a8dafcd207517426aeaead24dd0e293d8069cb867c0b71
|
|
7
|
+
data.tar.gz: f68b41369ba5e2d2022c04ca11d9ad8e954fc6eb2aa54da76f46feca3ca3add11b39b9f9a98df8dfdcf16e7f92f3fc377b3a1d5834e7cb92714670828895dd71
|
data/README.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# keinaufwand-sync
|
|
2
|
+
|
|
3
|
+
Reusable Rails persistence for the Keinaufwand API. The gem supports full/delta API sync, schema-backed Active Record models, SQLite and PostgreSQL JSON queries, local asset mirroring, generated JSON indexes, and signed incremental webhooks.
|
|
4
|
+
|
|
5
|
+
Part of [Keinaufwand](https://keinaufwand.com).
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
gem "keinaufwand"
|
|
11
|
+
gem "keinaufwand-sync"
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
bin/rails generate keinaufwand:sync:install
|
|
16
|
+
bin/rails keinaufwand:indices:generate
|
|
17
|
+
bin/rails db:migrate
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
The install generator creates `keinaufwand_records`, `keinaufwand_sync_states`, `keinaufwand_assets`, and `keinaufwand_webhook_receipts`. It also mounts `/media/:id` and the webhook Engine at `/webhooks/keinaufwand`.
|
|
21
|
+
|
|
22
|
+
## Configuration
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
Keinaufwand::Sync.configure do |config|
|
|
26
|
+
config.client = -> { MyKeinaufwandClient.build }
|
|
27
|
+
config.webhook_secret = -> { Rails.application.credentials.dig(:keinaufwand, :webhook_secret) }
|
|
28
|
+
config.storage_adapter = :sqlite3
|
|
29
|
+
end
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The consumer selects resources and JSON indexes in `config/keinaufwand.yml`. The versioned API schema remains bundled in `keinaufwand`.
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
bin/rails keinaufwand:sync
|
|
36
|
+
bin/rails keinaufwand:resync
|
|
37
|
+
bin/rails keinaufwand:indices:generate
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Application models inherit from `Keinaufwand::Record`. Their `id` is the upstream ID; `id` and STI `type` form the composite primary key. Schema fields and associations use normal Active Record-style `find`, `exists?`, `where`, and `order` calls.
|
|
41
|
+
|
|
42
|
+
## Webhooks
|
|
43
|
+
|
|
44
|
+
The mounted Engine accepts the exact JSON body signed by Keinaufwand in `X-Keinaufwand-Signature`. Processing is synchronous so a `200` response means the SQLite/PostgreSQL transaction committed. Event receipts make retries idempotent and preserve the latest source timestamp so an older delayed update cannot overwrite or resurrect newer data.
|
|
45
|
+
|
|
46
|
+
Regular `keinaufwand:sync` calls request each aggregate root with `updated_since` and apply `_deleted` records returned by widened soft-visibility scopes. Inline associations are stored inside the root JSON and exposed as schema-backed model objects without duplicate STI rows. Retried webhooks carry hard-delete tombstones; `keinaufwand:resync` repairs exceptional drift after a permanently failed delivery.
|
|
47
|
+
|
|
48
|
+
Polling compares source timestamps with both local records and webhook receipts after downloading media, then applies each page in a database transaction. Older polling snapshots cannot overwrite a newer webhook update or resurrect a record deleted by a newer webhook. Index generation skips unchanged indexes and uses distinct migration names for later configuration changes.
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
require "rails/generators"
|
|
2
|
+
require "rails/generators/active_record"
|
|
3
|
+
|
|
4
|
+
module Keinaufwand
|
|
5
|
+
module Sync
|
|
6
|
+
class IndexesGenerator < Rails::Generators::Base
|
|
7
|
+
include ActiveRecord::Generators::Migration
|
|
8
|
+
|
|
9
|
+
def create_index_migration
|
|
10
|
+
manager = Keinaufwand::Sync.configuration.index_manager
|
|
11
|
+
operations = manager.migration_operations
|
|
12
|
+
|
|
13
|
+
if operations.empty?
|
|
14
|
+
say "Keinaufwand sync indexes are already up to date."
|
|
15
|
+
return
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
migration_number = self.class.next_migration_number(File.join(destination_root, "db/migrate"))
|
|
19
|
+
class_name = "UpdateKeinaufwandSyncIndexes#{migration_number}"
|
|
20
|
+
source = manager.migration_source(class_name: class_name)
|
|
21
|
+
create_file File.join("db/migrate", "#{migration_number}_#{class_name.underscore}.rb"), source
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
require "rails/generators"
|
|
2
|
+
require "rails/generators/active_record"
|
|
3
|
+
|
|
4
|
+
module Keinaufwand
|
|
5
|
+
module Sync
|
|
6
|
+
class InstallGenerator < Rails::Generators::Base
|
|
7
|
+
include ActiveRecord::Generators::Migration
|
|
8
|
+
|
|
9
|
+
source_root File.expand_path("templates", __dir__)
|
|
10
|
+
|
|
11
|
+
class_option :media_route, type: :boolean, default: true, desc: "Mount /media/:id for synced Keinaufwand assets"
|
|
12
|
+
class_option :webhook_route, type: :boolean, default: true, desc: "Mount the signed Keinaufwand webhook receiver"
|
|
13
|
+
|
|
14
|
+
def copy_migrations
|
|
15
|
+
migration_template "create_keinaufwand_sync_tables.rb", "db/migrate/create_keinaufwand_sync_tables.rb"
|
|
16
|
+
migration_template "create_keinaufwand_assets.rb", "db/migrate/create_keinaufwand_assets.rb"
|
|
17
|
+
migration_template "create_keinaufwand_webhook_receipts.rb", "db/migrate/create_keinaufwand_webhook_receipts.rb"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def add_webhook_route
|
|
21
|
+
return unless options[:webhook_route]
|
|
22
|
+
|
|
23
|
+
route %(mount Keinaufwand::Sync::Engine => "/webhooks/keinaufwand", as: :keinaufwand_sync)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def add_media_route
|
|
27
|
+
return unless options[:media_route]
|
|
28
|
+
|
|
29
|
+
route %(get "media/:id", to: "keinaufwand/assets#show", as: :keinaufwand_asset)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
class CreateKeinaufwandAssets < ActiveRecord::Migration<%= "[#{ActiveRecord::Migration.current_version}]" %>
|
|
2
|
+
def change
|
|
3
|
+
unless table_exists?(:keinaufwand_assets)
|
|
4
|
+
create_table :keinaufwand_assets, id: false do |t|
|
|
5
|
+
t.string :key, null: false
|
|
6
|
+
t.string :source_url, null: false
|
|
7
|
+
t.string :content_type, null: false
|
|
8
|
+
t.integer :byte_size, null: false
|
|
9
|
+
t.string :checksum, null: false
|
|
10
|
+
t.binary :data, null: false
|
|
11
|
+
t.datetime :synced_at, null: false
|
|
12
|
+
t.timestamps
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
add_index :keinaufwand_assets, :key, unique: true unless index_exists?(:keinaufwand_assets, :key)
|
|
17
|
+
add_index :keinaufwand_assets, :source_url, unique: true unless index_exists?(:keinaufwand_assets, :source_url)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
class CreateKeinaufwandSyncTables < ActiveRecord::Migration<%= "[#{ActiveRecord::Migration.current_version}]" %>
|
|
2
|
+
def change
|
|
3
|
+
unless table_exists?(:keinaufwand_records)
|
|
4
|
+
create_table :keinaufwand_records, primary_key: [:id, :type] do |t|
|
|
5
|
+
t.bigint :id, null: false
|
|
6
|
+
t.string :type, null: false
|
|
7
|
+
t.json :data, null: false, default: {}
|
|
8
|
+
t.timestamps
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
add_index :keinaufwand_records, :type unless index_exists?(:keinaufwand_records, :type)
|
|
13
|
+
|
|
14
|
+
unless table_exists?(:keinaufwand_sync_states)
|
|
15
|
+
create_table :keinaufwand_sync_states, id: false do |t|
|
|
16
|
+
t.string :resource_class, null: false
|
|
17
|
+
t.datetime :last_synced_at
|
|
18
|
+
t.timestamps
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
add_index :keinaufwand_sync_states, :resource_class, unique: true unless index_exists?(:keinaufwand_sync_states, :resource_class)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
class CreateKeinaufwandWebhookReceipts < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
2
|
+
def change
|
|
3
|
+
create_table :keinaufwand_webhook_receipts, id: false do |t|
|
|
4
|
+
t.bigint :event_id, null: false, primary_key: true
|
|
5
|
+
t.string :event_type, null: false
|
|
6
|
+
t.string :api_version, null: false
|
|
7
|
+
t.string :resource_type, null: false
|
|
8
|
+
t.bigint :resource_id, null: false
|
|
9
|
+
t.datetime :source_updated_at
|
|
10
|
+
t.string :result, null: false
|
|
11
|
+
t.datetime :processed_at, null: false
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
add_index :keinaufwand_webhook_receipts, [:resource_type, :resource_id, :source_updated_at], name: "idx_keinaufwand_webhook_receipts_resource"
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
module Keinaufwand
|
|
2
|
+
class Asset < Sync::Asset
|
|
3
|
+
self.abstract_class = false
|
|
4
|
+
self.primary_key = :key
|
|
5
|
+
|
|
6
|
+
def self.local_url_for(source_url)
|
|
7
|
+
return if source_url.blank?
|
|
8
|
+
return source_url unless asset_url?(source_url)
|
|
9
|
+
|
|
10
|
+
key = key_for(source_url)
|
|
11
|
+
exists?(key:) ? "#{Sync.configuration.local_asset_path_prefix}/#{key}" : source_url
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
module Keinaufwand
|
|
2
|
+
class AssetsController < ActionController::Base
|
|
3
|
+
def show
|
|
4
|
+
asset = Asset.find(params[:id])
|
|
5
|
+
|
|
6
|
+
if stale?(etag: asset.checksum, last_modified: asset.synced_at, public: true)
|
|
7
|
+
expires_in 1.year, public: true
|
|
8
|
+
send_data asset.data, type: asset.content_type, disposition: "inline"
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
module Keinaufwand
|
|
2
|
+
class Record < Sync::Record
|
|
3
|
+
self.abstract_class = false
|
|
4
|
+
|
|
5
|
+
def value(key)
|
|
6
|
+
data[key.to_s]
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def [](key)
|
|
10
|
+
key = key.to_s
|
|
11
|
+
return data[key] if data.is_a?(Hash) && data.key?(key)
|
|
12
|
+
|
|
13
|
+
super
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def nested(*keys)
|
|
17
|
+
keys.reduce(data) { |hash, key| hash.is_a?(Hash) ? hash[key.to_s] : nil }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def local_asset_url(source_url)
|
|
21
|
+
Asset.local_url_for(source_url)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def localize_asset_urls(value)
|
|
25
|
+
return value unless value.is_a?(String)
|
|
26
|
+
|
|
27
|
+
Asset.urls_from(value).uniq.reduce(value) do |body, url|
|
|
28
|
+
body.gsub(url, Asset.local_url_for(url) || url)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
require "active_record"
|
|
2
|
+
require "action_controller/railtie"
|
|
3
|
+
require "active_support/core_ext/object/blank"
|
|
4
|
+
require "bigdecimal/util"
|
|
5
|
+
require "cgi"
|
|
6
|
+
require "digest"
|
|
7
|
+
require "net/http"
|
|
8
|
+
require "openssl"
|
|
9
|
+
require "uri"
|
|
10
|
+
require "yaml"
|
|
11
|
+
require "active_support/core_ext/numeric/time"
|
|
12
|
+
require "keinaufwand/api"
|
|
13
|
+
|
|
14
|
+
require_relative "sync_core/version"
|
|
15
|
+
require_relative "sync_core/configuration"
|
|
16
|
+
require_relative "sync_core/asset"
|
|
17
|
+
require_relative "sync_core/record"
|
|
18
|
+
require_relative "sync_core/sync_state"
|
|
19
|
+
require_relative "sync_core/webhook_receipt"
|
|
20
|
+
require_relative "sync_core/sync_service"
|
|
21
|
+
require_relative "sync_core/webhook_processor"
|
|
22
|
+
require_relative "sync_core/webhooks_controller" if defined?(ActionController::API)
|
|
23
|
+
require_relative "sync_core/index_manager"
|
|
24
|
+
require_relative "sync_core/engine" if defined?(Rails::Engine)
|
|
25
|
+
require_relative "sync_core/railtie" if defined?(Rails::Railtie)
|
|
26
|
+
require_relative "record"
|
|
27
|
+
require_relative "asset"
|
|
28
|
+
require_relative "sync_state"
|
|
29
|
+
require_relative "webhook_receipt"
|
|
30
|
+
require_relative "assets_controller" if defined?(ActionController::Base)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
module Keinaufwand
|
|
2
|
+
module Sync
|
|
3
|
+
class Asset < ActiveRecord::Base
|
|
4
|
+
self.abstract_class = true
|
|
5
|
+
self.table_name = "keinaufwand_assets"
|
|
6
|
+
self.primary_key = :key
|
|
7
|
+
|
|
8
|
+
ASSET_URL_PATTERN = %r{\Ahttps?://.+/shrine/(?:production|development)/(?:store|cache)/}.freeze
|
|
9
|
+
URL_PATTERN = %r{https?://[^"'<>\s\\]+}.freeze
|
|
10
|
+
|
|
11
|
+
def self.sync_url(source_url)
|
|
12
|
+
return unless asset_url?(source_url)
|
|
13
|
+
|
|
14
|
+
key = key_for(source_url)
|
|
15
|
+
return find_by(key:) if exists?(key:)
|
|
16
|
+
|
|
17
|
+
response = fetch(source_url)
|
|
18
|
+
body = response.body.to_s.b
|
|
19
|
+
content_type = response["content-type"].to_s.split(";").first
|
|
20
|
+
create!(
|
|
21
|
+
key: key,
|
|
22
|
+
source_url: source_url,
|
|
23
|
+
content_type: content_type.empty? ? "application/octet-stream" : content_type,
|
|
24
|
+
byte_size: body.bytesize,
|
|
25
|
+
checksum: Digest::SHA256.hexdigest(body),
|
|
26
|
+
data: body,
|
|
27
|
+
synced_at: Time.current
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.key_for(source_url)
|
|
32
|
+
Digest::SHA256.hexdigest(source_url.to_s)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.asset_url?(url)
|
|
36
|
+
url.to_s.match?(ASSET_URL_PATTERN)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.urls_from(value, urls = [])
|
|
40
|
+
case value
|
|
41
|
+
when Hash
|
|
42
|
+
value.each_value { |child| urls_from(child, urls) }
|
|
43
|
+
when Array
|
|
44
|
+
value.each { |child| urls_from(child, urls) }
|
|
45
|
+
when String
|
|
46
|
+
CGI.unescapeHTML(value).scan(URL_PATTERN).each do |url|
|
|
47
|
+
urls << url if asset_url?(url)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
urls
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.fetch(url, limit = 3)
|
|
55
|
+
raise ArgumentError, "too many redirects for #{url}" if limit < 0
|
|
56
|
+
|
|
57
|
+
uri = URI(url)
|
|
58
|
+
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
|
|
59
|
+
http.get(uri.request_uri)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
return fetch(response["location"], limit - 1) if response.is_a?(Net::HTTPRedirection)
|
|
63
|
+
raise "Could not fetch #{url}: #{response.code}" unless response.is_a?(Net::HTTPSuccess)
|
|
64
|
+
|
|
65
|
+
response
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
module Keinaufwand
|
|
2
|
+
module Sync
|
|
3
|
+
class Configuration
|
|
4
|
+
attr_accessor :client, :record_class, :sync_state_class, :asset_class, :webhook_receipt_class, :webhook_secret, :resources, :config_path, :local_asset_path_prefix, :storage_adapter
|
|
5
|
+
|
|
6
|
+
def initialize
|
|
7
|
+
@record_class = -> { Keinaufwand::Record }
|
|
8
|
+
@sync_state_class = -> { Keinaufwand::SyncState }
|
|
9
|
+
@asset_class = -> { Keinaufwand::Asset }
|
|
10
|
+
@webhook_receipt_class = -> { Keinaufwand::WebhookReceipt }
|
|
11
|
+
@local_asset_path_prefix = "/media"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def sync_service
|
|
15
|
+
SyncService.new(
|
|
16
|
+
client: fetch(:client),
|
|
17
|
+
record_class: fetch(:record_class),
|
|
18
|
+
sync_state_class: fetch(:sync_state_class),
|
|
19
|
+
receipt_class: resolve(:webhook_receipt_class),
|
|
20
|
+
resources: resources || sync_config.map { |item| item.fetch("model") }.presence || SyncService::DEFAULT_RESOURCES,
|
|
21
|
+
asset_class: resolve(:asset_class)
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def sync_config
|
|
26
|
+
return @sync_config if defined?(@sync_config)
|
|
27
|
+
|
|
28
|
+
@sync_config = Array(app_config.dig(:sync, :resources))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def index_manager
|
|
32
|
+
IndexManager.new(sync_config: sync_config, adapter: storage_adapter || :sqlite3)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def webhook_processor(payload)
|
|
36
|
+
WebhookProcessor.new(
|
|
37
|
+
payload: payload,
|
|
38
|
+
record_class: fetch(:record_class),
|
|
39
|
+
receipt_class: fetch(:webhook_receipt_class),
|
|
40
|
+
resources: resources || sync_config.map { |item| item.fetch("model") }.presence || SyncService::DEFAULT_RESOURCES,
|
|
41
|
+
asset_class: resolve(:asset_class)
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def resolved_webhook_secret
|
|
46
|
+
fetch(:webhook_secret)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def app_config
|
|
50
|
+
return {}.with_indifferent_access unless resolved_config_path && File.exist?(resolved_config_path)
|
|
51
|
+
|
|
52
|
+
YAML.load_file(resolved_config_path).with_indifferent_access
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def clear!
|
|
56
|
+
resolve(:asset_class)&.delete_all
|
|
57
|
+
fetch(:record_class).delete_all
|
|
58
|
+
fetch(:sync_state_class).delete_all
|
|
59
|
+
resolve(:webhook_receipt_class)&.delete_all
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def fetch(name)
|
|
65
|
+
value = resolve(name)
|
|
66
|
+
raise ArgumentError, "Keinaufwand::Sync.#{name} is not configured" unless value
|
|
67
|
+
|
|
68
|
+
value
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def resolve(name)
|
|
72
|
+
value = public_send(name)
|
|
73
|
+
value = value.call if value.respond_to?(:call)
|
|
74
|
+
value
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def resolved_config_path
|
|
78
|
+
resolve(:config_path) || (Rails.root.join("config/keinaufwand.yml") if defined?(Rails))
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
class << self
|
|
83
|
+
def configuration
|
|
84
|
+
@configuration ||= Configuration.new
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def configure
|
|
88
|
+
yield configuration
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def sync!
|
|
92
|
+
configuration.sync_service.perform
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def resync!
|
|
96
|
+
configuration.clear!
|
|
97
|
+
sync!
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
module Keinaufwand
|
|
2
|
+
module Sync
|
|
3
|
+
class IndexManager
|
|
4
|
+
TABLE_NAME = "keinaufwand_records"
|
|
5
|
+
|
|
6
|
+
def initialize(sync_config: [], adapter: :sqlite3)
|
|
7
|
+
@sync_config = sync_config
|
|
8
|
+
@adapter = adapter.to_sym
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def desired_indices
|
|
12
|
+
@sync_config.each_with_object({}) do |item, indices|
|
|
13
|
+
model = item.fetch("model")
|
|
14
|
+
Array(item["indices"]).each do |attribute|
|
|
15
|
+
indices.merge!(build_index(model, attribute))
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def current_index_names(connection = ActiveRecord::Base.connection)
|
|
21
|
+
return [] unless connection.table_exists?(TABLE_NAME)
|
|
22
|
+
|
|
23
|
+
connection.indexes(TABLE_NAME).map(&:name).grep(/\Aidx_keinaufwand_/)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def missing_index_names(connection = ActiveRecord::Base.connection)
|
|
27
|
+
desired_indices.keys - current_index_names(connection)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def obsolete_index_names(connection = ActiveRecord::Base.connection)
|
|
31
|
+
current_index_names(connection) - desired_indices.keys
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def migration_source(class_name: "UpdateKeinaufwandSyncIndexes", mode: :diff)
|
|
35
|
+
operations = migration_operations(mode: mode)
|
|
36
|
+
|
|
37
|
+
<<~RUBY
|
|
38
|
+
class #{class_name} < ActiveRecord::Migration[#{ActiveRecord::Migration.current_version}]
|
|
39
|
+
def change
|
|
40
|
+
#{operations.join("\n")}
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
RUBY
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def migration_operations(connection = ActiveRecord::Base.connection, mode: :diff)
|
|
47
|
+
desired = desired_indices
|
|
48
|
+
removals = obsolete_index_names(connection).map do |name|
|
|
49
|
+
" remove_index :#{TABLE_NAME}, name: #{name.inspect} if index_name_exists?(:#{TABLE_NAME}, #{name.inspect})"
|
|
50
|
+
end
|
|
51
|
+
addition_names = mode.to_sym == :full ? desired.keys : missing_index_names(connection)
|
|
52
|
+
additions = addition_names.map do |name|
|
|
53
|
+
config = desired.fetch(name)
|
|
54
|
+
" add_index :#{TABLE_NAME}, #{config[:expression].inspect}, name: #{name.inspect}, where: #{config[:where].inspect} unless index_name_exists?(:#{TABLE_NAME}, #{name.inspect})"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
removals + additions
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def build_index(model, attribute)
|
|
63
|
+
attribute = attribute.to_s
|
|
64
|
+
name = "idx_keinaufwand_#{model.underscore}_#{attribute.tr("^a-zA-Z0-9_", "_")}"
|
|
65
|
+
|
|
66
|
+
{
|
|
67
|
+
name => {
|
|
68
|
+
expression: json_value_expression(attribute),
|
|
69
|
+
where: "type = '#{model}'"
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def json_value_expression(attribute)
|
|
75
|
+
if @adapter == :postgres || @adapter == :postgresql
|
|
76
|
+
"data #>> '{#{attribute.split(".").join(",")}}'"
|
|
77
|
+
else
|
|
78
|
+
"json_extract(data, '$.#{attribute}')"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|