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 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,3 @@
1
+ Keinaufwand::Sync::Engine.routes.draw do
2
+ post "/", to: "webhooks#receive"
3
+ end
@@ -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,7 @@
1
+ module Keinaufwand
2
+ module Sync
3
+ class Engine < Rails::Engine
4
+ isolate_namespace Keinaufwand::Sync
5
+ end
6
+ end
7
+ 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
@@ -0,0 +1,9 @@
1
+ module Keinaufwand
2
+ module Sync
3
+ class Railtie < Rails::Railtie
4
+ rake_tasks do
5
+ load File.expand_path("tasks/keinaufwand_sync.rake", __dir__)
6
+ end
7
+ end
8
+ end
9
+ end