harmonia 0.2.4 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ff38a571aee521e8a4fd4018627f507e8926e3a64418c86bd746a62bab592ca1
4
- data.tar.gz: f6932e0ddcbad20fee54dfa868a964b1048824d97ef55b71a965cb6a9d10e7e9
3
+ metadata.gz: ba24e3fb10ef3510c855bda693d015d4a0b158f0f076af681389d6a26dac6c9e
4
+ data.tar.gz: 69b3c0a459c92772cc179e6e5584ab38762e6e9dab1898d1787b27aaa9640634
5
5
  SHA512:
6
- metadata.gz: 7e7c249cf80c9a00c73120d418f683761fffb4bdbbf78b03e6abe56469ee02bc9ecd9e848f7edaa6bfed3b03396890afe35f2c513df22563ffef8b396dd1b89c
7
- data.tar.gz: 7babd9a72937dfe80c9b3dfe060672c678ac7008c9236f8193e93c37ea435846c9604dd89745a3d4b842505c7ead25451ed03b01a6a0ec652b68dc2e6a7b7f30
6
+ metadata.gz: 3f940e8b3bca9c1ce1cca522051de2af62a0c9b82b4bab54e0001cef86bab7638086408f66bc6e1eaf76d90f090403871310c750070d534668a187fbadb0f454
7
+ data.tar.gz: faeb5432593d35938dac873e4aca8240cabcbac7ceb5f902f9756d121f34d00d9abf200f04e66a5d3232a9c1d1f42038c86935e0d07b32103871238afd56f55d
@@ -23,10 +23,22 @@ module Harmonia
23
23
  copy_file "harmonia_sync.rb", "app/models/harmonia/sync.rb"
24
24
  end
25
25
 
26
+ def copy_concerns
27
+ copy_file "concerns/sync_loggable.rb", "app/syncers/concerns/sync_loggable.rb"
28
+ copy_file "concerns/sync_retryable.rb", "app/syncers/concerns/sync_retryable.rb"
29
+ copy_file "concerns/photo_syncable.rb", "app/syncers/concerns/photo_syncable.rb"
30
+ copy_file "concerns/related_model_validation.rb", "app/syncers/concerns/related_model_validation.rb"
31
+ copy_file "concerns/related_model_resolver.rb", "app/syncers/concerns/related_model_resolver.rb"
32
+ end
33
+
26
34
  def generate_migration
27
35
  migration_template "create_harmonia_syncs.rb", "db/migrate/create_harmonia_syncs.rb"
28
36
  end
29
37
 
38
+ def generate_server_id_migration
39
+ migration_template "add_server_id_to_harmonia_syncs.rb", "db/migrate/add_server_id_to_harmonia_syncs.rb"
40
+ end
41
+
30
42
  def generate_failed_ids_migration
31
43
  migration_template "add_failed_ids_to_harmonia_syncs.rb", "db/migrate/add_failed_ids_to_harmonia_syncs.rb"
32
44
  end
@@ -43,24 +55,28 @@ module Harmonia
43
55
  - config/initializers/trophonius_model_extension.rb
44
56
  - app/models/application_record.rb (with to_fm extension)
45
57
  - app/models/harmonia/sync.rb
58
+ - app/syncers/concerns/sync_loggable.rb
59
+ - app/syncers/concerns/sync_retryable.rb
60
+ - app/syncers/concerns/photo_syncable.rb
61
+ - app/syncers/concerns/related_model_validation.rb
62
+ - app/syncers/concerns/related_model_resolver.rb
46
63
  - db/migrate/..._create_harmonia_syncs.rb
64
+ - db/migrate/..._add_server_id_to_harmonia_syncs.rb
47
65
  - db/migrate/..._add_failed_ids_to_harmonia_syncs.rb
48
66
 
49
67
  Next steps:
50
68
  1. Run migrations: rails db:migrate
51
69
  2. Update database_connector.rb with your FileMaker database name
52
70
  3. Add FileMaker credentials to Rails credentials
53
- 4. Replace all instances of YourTrophoniusModel with your actual model names
54
- 5. Generate syncers:
55
- - rails generate harmonia:sync ModelName (FileMaker -> ActiveRecord)
56
- - rails generate harmonia:reverse_sync ModelName (ActiveRecord -> FileMaker)
71
+ 4. Generate syncers:
72
+ - rails generate harmonia:sync ModelName (FileMaker -> ActiveRecord)
73
+ - rails generate harmonia:reverse_sync ModelName (ActiveRecord -> FileMaker)
57
74
 
58
75
  README
59
76
 
60
77
  say readme_content, :green if behavior == :invoke
61
78
  end
62
79
 
63
- # Required for migration_template to work
64
80
  def self.next_migration_number(dirname)
65
81
  current = Time.now.utc.strftime("%Y%m%d%H%M%S")
66
82
 
@@ -34,7 +34,7 @@ module Harmonia
34
34
  task #{task_name}: :environment do
35
35
  dbc = DatabaseConnector.new
36
36
 
37
- syncer = #{class_name}Syncer.new(dbc)
37
+ syncer = #{class_name}ToFileMakerSyncer.new(dbc)
38
38
  syncer.run
39
39
  end
40
40
  TASK
@@ -61,7 +61,10 @@ module Harmonia
61
61
 
62
62
  desc 'sync #{table_name} from ActiveRecord to FileMaker'
63
63
  task #{task_name}: :environment do
64
- #{class_name}ToFileMakerSyncer.new.sync
64
+ dbc = DatabaseConnector.new
65
+
66
+ syncer = #{class_name}ToFileMakerSyncer.new(dbc)
67
+ syncer.run
65
68
  end
66
69
  TASK
67
70
 
@@ -32,7 +32,11 @@ module Harmonia
32
32
 
33
33
  desc 'sync #{table_name} from FileMaker to ActiveRecord'
34
34
  task #{task_name}: :environment do
35
- #{class_name}Syncer.new.sync
35
+ dbc = DatabaseConnector.new
36
+ server_id = ENV.fetch('SERVER_ID', nil)&.to_i
37
+
38
+ syncer = #{class_name}Syncer.new(dbc, server_id)
39
+ syncer.run
36
40
  end
37
41
  TASK
38
42
 
@@ -59,8 +63,9 @@ module Harmonia
59
63
  desc 'sync #{table_name} from FileMaker to ActiveRecord'
60
64
  task #{task_name}: :environment do
61
65
  dbc = DatabaseConnector.new
66
+ server_id = ENV.fetch('SERVER_ID', nil)&.to_i
62
67
 
63
- syncer = #{class_name}Syncer.new(dbc)
68
+ syncer = #{class_name}Syncer.new(dbc, server_id)
64
69
  syncer.run
65
70
  end
66
71
  TASK
@@ -1,26 +1,29 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class <%= class_name %>ToFileMakerSyncer
4
+ include SyncLoggable
5
+
4
6
  attr_accessor :database_connector
5
7
 
6
8
  def initialize(database_connector)
7
9
  @database_connector = database_connector
8
- @last_synced_on = Harmonia::Sync.last_sync_for('<%= table_name %>', 'ActiveRecord to FileMaker')&.ran_on || (Time.now - 15.years)
10
+ @last_synced_on = Harmonia::Sync.last_sync_for('<%= table_name %>', 'ActiveRecord to FileMaker', nil)&.ran_on || (Time.now - 15.years)
11
+ @failed_fm_ids = {}
12
+ @failed_pg_ids = {}
9
13
  end
10
14
 
11
- # Main sync method
12
- # Executes the sync process for <%= class_name %> records to FileMaker
13
15
  def run
14
16
  raise StandardError, 'No database connector set' if @database_connector.blank?
15
17
 
16
18
  sync_record = create_sync_record
19
+ @sync_started_at = Time.now
20
+ log_sync_start(table: '<%= table_name %>', direction: 'ActiveRecord to FileMaker')
17
21
 
18
- @database_connector.open_database do
19
- sync_record.start!
20
- sync_records(sync_record)
21
- end
22
+ sync_record.start!
23
+ sync_records(sync_record)
22
24
  rescue StandardError => e
23
- sync_record&.fail!(e.message)
25
+ log_sync_error(table: '<%= table_name %>', direction: 'ActiveRecord to FileMaker', error: e)
26
+ sync_record&.fail!(e.message, failed_fm_ids: @failed_fm_ids, failed_pg_ids: @failed_pg_ids)
24
27
  raise
25
28
  end
26
29
 
@@ -31,57 +34,55 @@ class <%= class_name %>ToFileMakerSyncer
31
34
  created_count = create_records
32
35
  delete_records
33
36
 
34
- total_synced = created_count + updated_count
37
+ total_synced = created_count + updated_count
35
38
  total_required = (@total_create_required || 0) + (@total_update_required || 0)
36
39
 
40
+ log_sync_plan(
41
+ table: '<%= table_name %>', direction: 'ActiveRecord to FileMaker',
42
+ to_create: @total_create_required || 0,
43
+ to_update: @total_update_required || 0,
44
+ to_delete: 0
45
+ )
46
+
37
47
  sync_record.finish!(
38
- records_synced: total_synced,
39
- records_required: total_required
48
+ records_synced: total_synced,
49
+ records_required: total_required,
50
+ failed_fm_ids: @failed_fm_ids,
51
+ failed_pg_ids: @failed_pg_ids
52
+ )
53
+
54
+ log_sync_finish(
55
+ table: '<%= table_name %>', direction: 'ActiveRecord to FileMaker',
56
+ created: created_count, updated: updated_count, deleted: 0,
57
+ failed_fm: @failed_fm_ids.size, failed_pg: @failed_pg_ids.size,
58
+ duration: Time.now - @sync_started_at
40
59
  )
41
60
  end
42
61
 
43
- # Returns an array of ActiveRecord records that need to be created in FileMaker
44
- # Use <%= class_name %>.to_fm(record) to convert to FileMaker attributes
45
- # Set @total_create_required to the total number of records that should exist after creation
46
- # @return [Array<<%= class_name %>>] Array of ActiveRecord records
62
+ # Returns ActiveRecord records that need to be created in FileMaker (no filemaker_id yet).
63
+ # Set @total_create_required to the count.
47
64
  def records_to_create
48
- # TODO: Implement logic to fetch records from PostgreSQL that need to be created in FileMaker
49
- # Example:
50
- # pg_records = <%= class_name %>.all
51
- # @total_create_required = pg_records.length
52
- # existing_ids = YourTrophoniusModel.all.map { |r| r.field_data['PostgreSQLID'] }
53
- # pg_records.reject { |record| existing_ids.include?(record.id.to_s) }
65
+ # TODO: implement
66
+ # pg_records = <%= class_name %>.where(filemaker_id: nil, ...)
67
+ # @total_create_required = pg_records.count
68
+ # pg_records
54
69
  @total_create_required = 0
55
70
  []
56
71
  end
57
72
 
58
- # Returns an array of ActiveRecord records that need to be updated in FileMaker
59
- # Use <%= class_name %>.to_fm(record) to convert to FileMaker attributes
60
- # Set @total_update_required to the total number of records that should be updated
61
- # @return [Array<<%= class_name %>>] Array of ActiveRecord records
73
+ # Returns ActiveRecord records that need to be updated in FileMaker.
74
+ # Set @total_update_required to the count.
62
75
  def records_to_update
63
- # TODO: Implement logic to fetch records from PostgreSQL that need to be updated in FileMaker
64
- # Example:
65
- # pg_records = <%= class_name %>.where('updated_at > ?', 1.hour.ago)
66
- # records_needing_update = pg_records.select { |pg_record|
67
- # fm_record = find_filemaker_record(pg_record)
68
- # fm_record && needs_update?(pg_record, fm_record)
69
- # }
70
- # @total_update_required = records_needing_update.length
71
- # records_needing_update
76
+ # TODO: implement
77
+ # pg_records = <%= class_name %>.where('updated_at > ?', @last_synced_on).where.not(filemaker_id: nil)
78
+ # @total_update_required = pg_records.count
79
+ # pg_records
72
80
  @total_update_required = 0
73
81
  []
74
82
  end
75
83
 
76
- # Returns an array of FileMaker record IDs that need to be deleted
77
- # @return [Array] Array of FileMaker record IDs
84
+ # Returns FileMaker record IDs that should be deleted.
78
85
  def records_to_delete
79
- # TODO: Implement logic to determine which FileMaker records should be deleted
80
- # Example:
81
- # pg_ids = <%= class_name %>.pluck(:id).map(&:to_s)
82
- # YourTrophoniusModel.all.select { |fm_record|
83
- # !pg_ids.include?(fm_record.field_data['PostgreSQLID'])
84
- # }.map(&:id)
85
86
  []
86
87
  end
87
88
 
@@ -89,71 +90,71 @@ class <%= class_name %>ToFileMakerSyncer
89
90
  records = records_to_create
90
91
  return 0 if records.empty?
91
92
 
92
- records.each do |pg_record|
93
- fm_attributes = pg_record.to_fm
94
- YourTrophoniusModel.create(fm_attributes)
93
+ success_count = 0
94
+ @database_connector.open_database do
95
+ records.each do |pg_record|
96
+ fm_attributes = pg_record.to_fm.compact_blank
97
+ fm_record = FileMaker::<%= class_name %>.create(fm_attributes)
98
+ pg_record.update!(filemaker_id: fm_record.id)
99
+ success_count += 1
100
+ rescue StandardError => e
101
+ @failed_pg_ids[pg_record.id.to_s] = e.message
102
+ log_create_error(id: pg_record.id, id_type: :pg, error: e)
103
+ end
95
104
  end
96
-
97
- records.size
105
+ success_count
98
106
  end
99
107
 
100
108
  def update_records
101
109
  records = records_to_update
102
110
  return 0 if records.empty?
103
111
 
104
- records.each do |pg_record|
105
- fm_attributes = pg_record.to_fm
106
-
107
- # Find the FileMaker record by PostgreSQL ID or other unique identifier
108
- fm_record = find_filemaker_record(pg_record)
109
- next unless fm_record
110
-
111
- fm_record.update(fm_attributes)
112
+ success_count = 0
113
+ @database_connector.open_database do
114
+ records.each do |pg_record|
115
+ fm_record = find_filemaker_record(pg_record)
116
+ next unless fm_record
117
+
118
+ fm_record.update(pg_record.to_fm.compact_blank)
119
+ success_count += 1
120
+ rescue StandardError => e
121
+ @failed_pg_ids[pg_record.id.to_s] = e.message
122
+ log_update_error(id: pg_record.id, id_type: :pg, error: e)
123
+ end
112
124
  end
113
-
114
- records.size
125
+ success_count
115
126
  end
116
127
 
117
128
  def delete_records
118
129
  ids = records_to_delete
119
130
  return if ids.empty?
120
131
 
121
- ids.each do |id|
122
- fm_record = YourTrophoniusModel.find(id)
123
- fm_record.destroy
124
- rescue Trophonius::RecordNotFoundError
125
- # Record already deleted, skip
126
- next
132
+ @database_connector.open_database do
133
+ ids.each do |id|
134
+ FileMaker::<%= class_name %>.where(id:).first&.destroy
135
+ rescue Trophonius::RecordNotFoundError
136
+ next
137
+ end
127
138
  end
128
139
  end
129
140
 
130
- # Helper method to find a FileMaker record based on a PostgreSQL record
131
- # @param pg_record [ActiveRecord::Base] The PostgreSQL record
132
- # @return [Trophonius::Model, nil] The corresponding FileMaker record or nil
141
+ # Find the FileMaker record corresponding to a PostgreSQL record.
133
142
  def find_filemaker_record(pg_record)
134
- # TODO: Implement logic to find the corresponding FileMaker record
135
- # Example:
136
- # YourTrophoniusModel.find_by_field('PostgreSQLID', pg_record.id.to_s)
137
- nil
143
+ return nil if pg_record.filemaker_id.blank?
144
+
145
+ FileMaker::<%= class_name %>.where(id: pg_record.filemaker_id).first
138
146
  end
139
147
 
140
- # Determine if a FileMaker record needs to be updated based on PostgreSQL record
141
- # @param pg_record [ActiveRecord::Base] The PostgreSQL record
142
- # @param fm_record [Trophonius::Model] The FileMaker record
143
- # @return [Boolean] true if update is needed
144
148
  def needs_update?(pg_record, fm_record)
145
- # TODO: Implement your comparison logic
146
- # Example:
147
- # fm_attributes = pg_record.to_fm
148
- # fm_attributes.any? { |key, value| fm_record.field_data[key.to_s] != value }
149
+ # TODO: implement comparison logic
149
150
  true
150
151
  end
151
152
 
152
153
  def create_sync_record
153
154
  Harmonia::Sync.create!(
154
- table: '<%= table_name %>',
155
- ran_on: Time.now,
156
- status: 'pending',
155
+ table: '<%= table_name %>',
156
+ ran_on: Time.now,
157
+ status: 'pending',
157
158
  direction: 'ActiveRecord to FileMaker'
158
159
  )
159
160
  end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddServerIdToHarmoniaSyncs < ActiveRecord::Migration[<%= Rails::VERSION::MAJOR %>.<%= Rails::VERSION::MINOR %>]
4
+ def change
5
+ unless column_exists?(:harmonia_syncs, :server_id)
6
+ add_column :harmonia_syncs, :server_id, :integer
7
+ add_index :harmonia_syncs, :server_id
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,28 @@
1
+ require 'open-uri'
2
+
3
+ module PhotoSyncable
4
+ def sync_photos(pg_records, fm_records, options: {})
5
+ attachment = options[:attachment] || 'photo'
6
+ container_field = options[:container_field] || 'foto'
7
+ filename_field = options[:filename_field] || 'foto_filename'
8
+ purge_existing = options[:purge_existing]
9
+
10
+ pg_records.each do |record|
11
+ fm_record = fm_records.find { |r| r.id == record.filemaker_id }
12
+ record.send(attachment).purge if record.send(attachment).attached? && purge_existing
13
+ next unless fm_record.send(container_field).present?
14
+
15
+ downloaded_image = URI.parse(fm_record.send(container_field)).open
16
+ record.send(attachment).attach(io: downloaded_image, filename: fm_record.send(filename_field))
17
+ end
18
+ end
19
+
20
+ def sync_photo_to_filemaker(attachment, fm_record, options: {})
21
+ container_field = options[:container_field] || 'foto'
22
+ return unless attachment.blank?
23
+
24
+ attachment.open do |tempfile|
25
+ fm_record.upload(container_name: container_field, file: tempfile)
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RelatedModelResolver
4
+ # FM models extend this and define related_models to declare which FM fields
5
+ # must resolve to a local PG record. Returns a hash of resolved IDs, or nil
6
+ # if any required reference cannot be resolved.
7
+ #
8
+ # Example:
9
+ # def self.related_models
10
+ # [[:sales_region_id, ::SalesRegion], [:administration_id, ::Administration]]
11
+ # end
12
+ def resolve_related_models(record)
13
+ related_models.each_with_object({}) do |(field, model), hash|
14
+ resolved = model.find_by(filemaker_id: record.send(field))&.id
15
+ return nil if resolved.nil?
16
+
17
+ hash[field] = resolved
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RelatedModelValidation
4
+ def validate_related_models(record, fm_model_class)
5
+ references = fm_model_class.related_models.map { |field, model| [record.send(field), model] }
6
+ errors = references.filter_map do |fm_id, model|
7
+ label = model.name.demodulize.underscore
8
+ if fm_id.blank?
9
+ "#{label} is blank in FileMaker"
10
+ elsif model.find_by(filemaker_id: fm_id).nil?
11
+ "#{label} with FileMaker id '#{fm_id}' not found in PostgreSQL"
12
+ end
13
+ end
14
+ "Skipped: #{errors.join(', ')}" if errors.any?
15
+ end
16
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SyncLoggable
4
+ def log_sync_start(table:, direction:, server_id: nil)
5
+ parts = ["[SYNC START] #{table} | #{direction}"]
6
+ parts << "server=#{server_id}" if server_id
7
+ Rails.logger.info(parts.join(' | '))
8
+ end
9
+
10
+ def log_sync_plan(table:, direction:, to_create:, to_update:, to_delete:)
11
+ Rails.logger.info(
12
+ "[SYNC PLAN ] #{table} | #{direction} | " \
13
+ "to_create=#{to_create} to_update=#{to_update} to_delete=#{to_delete}"
14
+ )
15
+ end
16
+
17
+ def log_sync_finish(table:, direction:, created:, updated:, deleted:, failed_fm:, failed_pg:, duration: nil)
18
+ parts = [
19
+ "[SYNC DONE ] #{table} | #{direction}",
20
+ "created=#{created} updated=#{updated} deleted=#{deleted}",
21
+ "failed_fm=#{failed_fm} failed_pg=#{failed_pg}"
22
+ ]
23
+ parts << "duration=#{duration.round(2)}s" if duration
24
+ Rails.logger.info(parts.join(' | '))
25
+ end
26
+
27
+ def log_sync_error(table:, direction:, error:)
28
+ Rails.logger.error("[SYNC ERROR] #{table} | #{direction} | #{error.class}: #{error.message}")
29
+ end
30
+
31
+ def log_create_error(id:, id_type: :fm, error:)
32
+ label = id_type == :fm ? "FileMaker ID" : "PostgreSQL ID"
33
+ Rails.logger.error("[SYNC] Failed to create record from #{label} #{id}: #{error.message}")
34
+ end
35
+
36
+ def log_update_error(id:, id_type: :fm, error:)
37
+ label = id_type == :fm ? "FileMaker ID" : "PostgreSQL ID"
38
+ Rails.logger.error("[SYNC] Failed to update record from #{label} #{id}: #{error.message}")
39
+ end
40
+
41
+ def log_delete_error(id:, id_type: :fm, error:)
42
+ label = id_type == :fm ? "FileMaker ID" : "PostgreSQL ID"
43
+ Rails.logger.error("[SYNC] Failed to delete record with #{label} #{id}: #{error.message}")
44
+ end
45
+
46
+ def log_needs_update_error(id:, id_type: :fm, error:)
47
+ label = id_type == :fm ? "FileMaker ID" : "PostgreSQL ID"
48
+ Rails.logger.error("[SYNC] Failed needs_update? check for #{label} #{id}: #{error.message}")
49
+ end
50
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Adds retry capability for FM IDs that failed in the previous sync run.
4
+ #
5
+ # Usage in a syncer:
6
+ # include SyncRetryable
7
+ #
8
+ # def run
9
+ # ...
10
+ # @database_connector.open_database do
11
+ # sync_record.start!
12
+ # sync_records(sync_record)
13
+ # retry_failed_records(FileMaker::MyModel, table: 'my_models', direction: 'FileMaker to ActiveRecord')
14
+ # end
15
+ # end
16
+ #
17
+ # The concern reads failed_fm_ids from the previous completed/failed sync,
18
+ # fetches those records from FileMaker, then calls retry_create / retry_update.
19
+ # Syncers can override either hook for custom logic (e.g. photo handling).
20
+
21
+ module SyncRetryable
22
+ def retry_failed_records(fm_model_class, table:, direction:)
23
+ previous_sync = Harmonia::Sync
24
+ .where(table:, direction:, server_id: @server_id)
25
+ .where(status: %w[completed failed])
26
+ .order(ran_on: :desc)
27
+ .offset(1) # skip the sync that just completed
28
+ .first
29
+
30
+ return unless previous_sync
31
+
32
+ failed_fm_ids = previous_sync.failed_fm_ids.keys
33
+ return if failed_fm_ids.empty?
34
+
35
+ log_sync_retry_start(table:, direction:, count: failed_fm_ids.size, ids: failed_fm_ids)
36
+
37
+ fm_records = failed_fm_ids.filter_map do |fm_id|
38
+ fm_model_class.where(id: fm_id).first
39
+ rescue StandardError => e
40
+ Rails.logger.warn("[SYNC RETRY] Could not fetch FM record #{fm_id}: #{e.message}")
41
+ nil
42
+ end
43
+
44
+ pg_model = retryable_pg_model
45
+ to_create = fm_records.reject { |r| pg_model.exists?(filemaker_id: r.id) }
46
+ to_update = fm_records.select { |r| pg_model.exists?(filemaker_id: r.id) }
47
+
48
+ created = retry_create(to_create, fm_model_class)
49
+ updated = retry_update(to_update, fm_model_class)
50
+
51
+ log_sync_retry_finish(table:, direction:, created:, updated:)
52
+ end
53
+
54
+ private
55
+
56
+ # Override in the syncer for custom create logic (e.g. photo sync, insert_all).
57
+ def retry_create(fm_records, fm_model_class)
58
+ return 0 if fm_records.empty?
59
+
60
+ pg_model = retryable_pg_model
61
+ success = 0
62
+ fm_records.each do |trophonius_record|
63
+ attributes = fm_model_class.to_pg(trophonius_record).merge(
64
+ created_at: Time.current, updated_at: Time.current
65
+ )
66
+ pg_model.create!(attributes)
67
+ @failed_fm_ids.delete(trophonius_record.id.to_s)
68
+ success += 1
69
+ rescue StandardError => e
70
+ @failed_fm_ids[trophonius_record.id.to_s] = e.message
71
+ log_create_error(id: trophonius_record.id, error: e)
72
+ end
73
+ success
74
+ end
75
+
76
+ # Override in the syncer for custom update logic.
77
+ def retry_update(fm_records, fm_model_class)
78
+ return 0 if fm_records.empty?
79
+
80
+ pg_model = retryable_pg_model
81
+ success = 0
82
+ fm_records.each do |trophonius_record|
83
+ attributes = fm_model_class.to_pg(trophonius_record)
84
+ pg_model.where(filemaker_id: trophonius_record.id).update_all(
85
+ attributes.merge(updated_at: Time.current)
86
+ )
87
+ @failed_fm_ids.delete(trophonius_record.id.to_s)
88
+ success += 1
89
+ rescue StandardError => e
90
+ @failed_fm_ids[trophonius_record.id.to_s] = e.message
91
+ log_update_error(id: trophonius_record.id, error: e)
92
+ end
93
+ success
94
+ end
95
+
96
+ # Infers the PG model from the syncer class name. Override if naming diverges.
97
+ # AssetSyncer → Asset, AssetDefectSyncer → AssetDefect, etc.
98
+ def retryable_pg_model
99
+ self.class.name.delete_suffix('Syncer').constantize
100
+ end
101
+
102
+ def log_sync_retry_start(table:, direction:, count:, ids:)
103
+ Rails.logger.info(
104
+ "[SYNC RETRY] #{table} | #{direction} | retrying #{count} record(s) | ids=#{ids.join(',')}"
105
+ )
106
+ end
107
+
108
+ def log_sync_retry_finish(table:, direction:, created:, updated:)
109
+ Rails.logger.info(
110
+ "[SYNC RETRY] #{table} | #{direction} | created=#{created} updated=#{updated}"
111
+ )
112
+ end
113
+ end
@@ -10,6 +10,7 @@ class CreateHarmoniaSyncs < ActiveRecord::Migration[<%= Rails::VERSION::MAJOR %>
10
10
  t.string :status, default: 'pending'
11
11
  t.string :direction
12
12
  t.text :error_message
13
+ t.integer :server_id
13
14
  t.jsonb :failed_fm_ids, default: {}
14
15
  t.jsonb :failed_pg_ids, default: {}
15
16
 
@@ -18,5 +19,6 @@ class CreateHarmoniaSyncs < ActiveRecord::Migration[<%= Rails::VERSION::MAJOR %>
18
19
 
19
20
  add_index :harmonia_syncs, [:table, :ran_on]
20
21
  add_index :harmonia_syncs, :status
22
+ add_index :harmonia_syncs, :server_id
21
23
  end
22
24
  end
@@ -1,32 +1,38 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class <%= class_name %>Syncer
4
+ include SyncLoggable
5
+ include SyncRetryable
6
+
4
7
  attr_accessor :database_connector
5
8
 
6
- def initialize(database_connector, update_only: false)
9
+ def initialize(database_connector, server_id, update_only: false)
7
10
  @database_connector = database_connector
11
+ @server_id = server_id
8
12
  @last_synced_on = if update_only
9
- Time.now - 100.year
13
+ Time.now - 100.years
10
14
  else
11
- Harmonia::Sync.last_sync_for('<%= table_name %>', 'FileMaker to ActiveRecord')&.ran_on || (Time.now - 15.year)
15
+ Harmonia::Sync.last_sync_for('<%= table_name %>', 'FileMaker to ActiveRecord', server_id)&.ran_on || (Time.now - 15.years)
12
16
  end
13
17
  @update_only = update_only
14
18
  @failed_fm_ids = {}
15
19
  @failed_pg_ids = {}
16
20
  end
17
21
 
18
- # Main sync method
19
- # Executes the sync process for <%= class_name %> records
20
22
  def run
21
23
  raise StandardError, 'No database connector set' if @database_connector.blank?
22
24
 
23
25
  sync_record = create_sync_record
26
+ @sync_started_at = Time.now
27
+ log_sync_start(table: '<%= table_name %>', direction: 'FileMaker to ActiveRecord', server_id: @server_id)
24
28
 
25
29
  @database_connector.open_database do
26
30
  sync_record.start!
27
31
  sync_records(sync_record)
32
+ retry_failed_records(FileMaker::<%= class_name %>, table: '<%= table_name %>', direction: 'FileMaker to ActiveRecord')
28
33
  end
29
34
  rescue StandardError => e
35
+ log_sync_error(table: '<%= table_name %>', direction: 'FileMaker to ActiveRecord', error: e)
30
36
  sync_record&.fail!(e.message, failed_fm_ids: @failed_fm_ids, failed_pg_ids: @failed_pg_ids)
31
37
  raise
32
38
  end
@@ -36,77 +42,76 @@ class <%= class_name %>Syncer
36
42
  def sync_records(sync_record)
37
43
  updated_count = update_records
38
44
  created_count = @update_only ? 0 : create_records
39
- delete_records unless @update_only
45
+ deleted_count = @update_only ? 0 : delete_records.to_i
40
46
 
41
- total_synced = created_count + updated_count
47
+ total_synced = created_count + updated_count
42
48
  total_required = (@total_create_required || 0) + (@total_update_required || 0)
43
49
 
50
+ log_sync_plan(
51
+ table: '<%= table_name %>', direction: 'FileMaker to ActiveRecord',
52
+ to_create: @total_create_required || 0,
53
+ to_update: @total_update_required || 0,
54
+ to_delete: deleted_count
55
+ )
56
+
44
57
  sync_record.finish!(
45
- records_synced: total_synced,
58
+ records_synced: total_synced,
46
59
  records_required: total_required,
47
- failed_fm_ids: @failed_fm_ids,
48
- failed_pg_ids: @failed_pg_ids
60
+ failed_fm_ids: @failed_fm_ids,
61
+ failed_pg_ids: @failed_pg_ids
62
+ )
63
+
64
+ log_sync_finish(
65
+ table: '<%= table_name %>', direction: 'FileMaker to ActiveRecord',
66
+ created: created_count, updated: updated_count, deleted: deleted_count,
67
+ failed_fm: @failed_fm_ids.size, failed_pg: @failed_pg_ids.size,
68
+ duration: Time.now - @sync_started_at
49
69
  )
50
70
  end
51
71
 
52
- # Returns an array of Trophonius records that need to be created
53
- # Use FileMaker::<%= class_name %>.to_pg(record) to convert to PostgreSQL attributes
54
- # Set @total_create_required to the total number of records that should exist after creation
55
- # @return [Array<Trophonius::Record>] Array of Trophonius records
72
+ # Returns FileMaker records that need to be created in PostgreSQL.
73
+ # Set @total_create_required to the count of records that should be created.
56
74
  def records_to_create
57
- # TODO: Implement logic to fetch records from FileMaker that need to be created in PostgreSQL
58
- # Example:
75
+ # TODO: implement
76
+ existing_ids = <%= class_name %>.pluck(:filemaker_id)
59
77
  filemaker_records = FileMaker::<%= class_name %>.where(creation_timestamp: ">= #{@last_synced_on.to_fm}")
78
+ filemaker_records.reject! { |record| existing_ids.include?(record.id) }
60
79
  @total_create_required = filemaker_records.length
61
- existing_ids = <%= class_name %>.pluck(:filemaker_id)
62
- filemaker_records.reject { |record| existing_ids.include?(record.id) }
80
+ filemaker_records
63
81
  end
64
82
 
65
- # Returns an array of Trophonius records that need to be updated
66
- # Use FileMaker::<%= class_name %>.to_pg(record) to convert to PostgreSQL attributes
67
- # Set @total_update_required to the total number of records that should be updated
68
- # @return [Array<Trophonius::Record>] Array of Trophonius records
83
+ # Returns FileMaker records that need to be updated in PostgreSQL.
84
+ # Set @total_update_required to the count of records that should be updated.
69
85
  def records_to_update
70
- # TODO: Implement logic to fetch records from FileMaker that need to be updated in PostgreSQL
71
- # Example:
86
+ # TODO: implement
72
87
  filemaker_records = FileMaker::<%= class_name %>.where(modification_timestamp: ">= #{@last_synced_on.to_fm}")
73
- records_needing_update = filemaker_records.select { |fm_record|
88
+ records_needing_update = filemaker_records.select do |fm_record|
74
89
  pg_record = <%= class_name %>.find_by(filemaker_id: fm_record.id)
75
90
  pg_record && needs_update?(fm_record, pg_record)
76
- }
91
+ end
77
92
  @total_update_required = records_needing_update.length
78
93
  records_needing_update
79
94
  end
80
95
 
81
- # Returns an array of record identifiers that need to be deleted
82
- # @return [Array] Array of record identifiers
96
+ # Returns PostgreSQL IDs of records that have been deleted in FileMaker.
83
97
  def records_to_delete
84
- # Get all modified FileMaker record IDs
85
98
  filemaker_records = FileMaker::<%= class_name %>.where(modification_timestamp: ">= #{@last_synced_on.to_fm}")
86
99
  fm_ids = filemaker_records.map(&:id)
87
100
 
88
- # Find PostgreSQL records whose FileMaker IDs aren't in the modified set
89
- # These might have been deleted in FileMaker
90
101
  fm_ids_no_update_needed = <%= class_name %>.where.not(filemaker_id: fm_ids).pluck(:filemaker_id)
91
102
  return [] if fm_ids_no_update_needed.empty?
92
103
 
93
- # Query FileMaker to check if these records still exist
94
104
  possibly_deleted_query = FileMaker::<%= class_name %>.where(id: fm_ids_no_update_needed.first)
95
105
  fm_ids_no_update_needed.count > 1 && fm_ids_no_update_needed[1..].each do |fm_id|
96
106
  possibly_deleted_query.or(id: fm_id)
97
107
  end
98
108
 
99
- # Find IDs that exist in PostgreSQL but not in FileMaker (truly deleted)
100
109
  deleted_fm_ids = fm_ids_no_update_needed - possibly_deleted_query.map(&:id)
101
-
102
- # Return PostgreSQL IDs for records with these FileMaker IDs
103
110
  <%= class_name %>.where(filemaker_id: deleted_fm_ids).pluck(:id)
104
111
  end
105
112
 
106
113
  def needs_update?(fm_record, pg_record)
107
- pg_attributes = FileMaker::<%= class_name %>.to_pg(fm_record)
108
-
109
- pg_attributes.any? { |key, value| pg_record.send(key) != value }
114
+ FileMaker::<%= class_name %>.to_pg(fm_record).any? { |key, value| pg_record.send(key) != value }
110
115
  end
111
116
 
112
117
  def create_records
@@ -114,21 +119,17 @@ class <%= class_name %>Syncer
114
119
  return 0 if records.empty?
115
120
 
116
121
  success_count = 0
117
-
118
122
  records.each do |trophonius_record|
119
- begin
120
- attributes = FileMaker::<%= class_name %>.to_pg(trophonius_record).merge(
121
- created_at: Time.current,
122
- updated_at: Time.current
123
- )
124
- <%= class_name %>.create!(attributes)
125
- success_count += 1
126
- rescue StandardError => e
127
- @failed_fm_ids[trophonius_record.id.to_s] = e.message
128
- Rails.logger.error("Failed to create record from FileMaker ID #{trophonius_record.id}: #{e.message}")
129
- end
123
+ attributes = FileMaker::<%= class_name %>.to_pg(trophonius_record).merge(
124
+ created_at: Time.current,
125
+ updated_at: Time.current
126
+ )
127
+ <%= class_name %>.create!(attributes)
128
+ success_count += 1
129
+ rescue StandardError => e
130
+ @failed_fm_ids[trophonius_record.id.to_s] = e.message
131
+ log_create_error(id: trophonius_record.id, error: e)
130
132
  end
131
-
132
133
  success_count
133
134
  end
134
135
 
@@ -137,21 +138,16 @@ class <%= class_name %>Syncer
137
138
  return 0 if records.empty?
138
139
 
139
140
  success_count = 0
140
-
141
141
  records.each do |trophonius_record|
142
- begin
143
- pg_attributes = FileMaker::<%= class_name %>.to_pg(trophonius_record)
144
-
145
- <%= class_name %>.where(filemaker_id: trophonius_record.id).update_all(
146
- pg_attributes.merge(updated_at: Time.current)
147
- )
148
- success_count += 1
149
- rescue StandardError => e
150
- @failed_fm_ids[trophonius_record.id.to_s] = e.message
151
- Rails.logger.error("Failed to update record from FileMaker ID #{trophonius_record.id}: #{e.message}")
152
- end
142
+ pg_attributes = FileMaker::<%= class_name %>.to_pg(trophonius_record)
143
+ <%= class_name %>.where(filemaker_id: trophonius_record.id).update_all(
144
+ pg_attributes.merge(updated_at: Time.current)
145
+ )
146
+ success_count += 1
147
+ rescue StandardError => e
148
+ @failed_fm_ids[trophonius_record.id.to_s] = e.message
149
+ log_update_error(id: trophonius_record.id, error: e)
153
150
  end
154
-
155
151
  success_count
156
152
  end
157
153
 
@@ -160,20 +156,19 @@ class <%= class_name %>Syncer
160
156
  return if ids.empty?
161
157
 
162
158
  ids.each do |pg_id|
163
- begin
164
- <%= class_name %>.where(id: pg_id).destroy_all
165
- rescue StandardError => e
166
- @failed_pg_ids[pg_id.to_s] = e.message
167
- Rails.logger.error("Failed to delete record with PostgreSQL ID #{pg_id}: #{e.message}")
168
- end
159
+ <%= class_name %>.where(id: pg_id).destroy_all
160
+ rescue StandardError => e
161
+ @failed_pg_ids[pg_id.to_s] = e.message
162
+ log_delete_error(id: pg_id, id_type: :pg, error: e)
169
163
  end
170
164
  end
171
165
 
172
166
  def create_sync_record
173
167
  Harmonia::Sync.create!(
174
- table: '<%= table_name %>',
175
- ran_on: Time.now,
176
- status: 'pending',
168
+ table: '<%= table_name %>',
169
+ ran_on: Time.now,
170
+ status: 'pending',
171
+ server_id: @server_id,
177
172
  direction: 'FileMaker to ActiveRecord'
178
173
  )
179
174
  end
@@ -8,44 +8,36 @@ module Harmonia
8
8
  validates :ran_on, presence: true
9
9
  validates :status, presence: true, inclusion: { in: %w[pending in_progress completed failed] }
10
10
 
11
- # Scope to get syncs for a specific table
12
11
  scope :for_table, ->(table_name) { where(table: table_name) }
13
- scope :for_direction, ->(direction) {where(direction:)}
14
-
15
- # Scope to get recent syncs
12
+ scope :for_direction, ->(direction) { where(direction:) }
13
+ scope :for_server, ->(server_id) { where(server_id:) }
16
14
  scope :recent, -> { order(ran_on: :desc) }
17
-
18
- # Scope to get syncs by date
19
15
  scope :on_date, ->(date) { where(ran_on: date) }
20
-
21
- # Scope by status
22
16
  scope :pending, -> { where(status: 'pending') }
23
17
  scope :in_progress, -> { where(status: 'in_progress') }
24
18
  scope :completed, -> { where(status: 'completed') }
25
19
  scope :failed, -> { where(status: 'failed') }
26
20
 
27
- # Get the most recent successful sync for a table in a given direction
28
- def self.last_sync_for(table_name, direction)
29
- completed.for_direction(direction).for_table(table_name).recent.first
21
+ def self.last_sync_for(table_name, direction, server_id = nil)
22
+ scope = completed.for_direction(direction).for_table(table_name)
23
+ scope = server_id ? scope.for_server(server_id) : scope.where(server_id: nil)
24
+ scope.recent.first
30
25
  end
31
26
 
32
- # Calculate sync completion percentage
33
27
  def completion_percentage
34
28
  return 0 if records_required.to_i.zero?
29
+
35
30
  ((records_synced.to_f / records_required.to_f) * 100).round(2)
36
31
  end
37
32
 
38
- # Check if sync was complete
39
33
  def complete?
40
34
  status == 'completed' && records_synced == records_required
41
35
  end
42
36
 
43
- # Mark sync as started
44
37
  def start!
45
38
  update!(status: 'in_progress')
46
39
  end
47
40
 
48
- # Mark sync as completed
49
41
  def finish!(records_synced:, records_required:, failed_fm_ids: {}, failed_pg_ids: {})
50
42
  status = records_synced == records_required ? 'completed' : 'failed'
51
43
  update!(
@@ -57,7 +49,6 @@ module Harmonia
57
49
  )
58
50
  end
59
51
 
60
- # Mark sync as failed
61
52
  def fail!(error_message, failed_fm_ids: {}, failed_pg_ids: {})
62
53
  update!(
63
54
  status: 'failed',
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: harmonia
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.4
4
+ version: 0.2.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kempen Automatisering
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-03-27 00:00:00.000000000 Z
11
+ date: 2026-06-05 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: trophonius
@@ -39,7 +39,13 @@ files:
39
39
  - lib/generators/harmonia/templates/activerecord_to_filemaker_syncer_template.rb
40
40
  - lib/generators/harmonia/templates/add_failed_ids_to_harmonia_syncs.rb
41
41
  - lib/generators/harmonia/templates/add_filemaker_id_to_table.rb
42
+ - lib/generators/harmonia/templates/add_server_id_to_harmonia_syncs.rb
42
43
  - lib/generators/harmonia/templates/application_record_extension.rb
44
+ - lib/generators/harmonia/templates/concerns/photo_syncable.rb
45
+ - lib/generators/harmonia/templates/concerns/related_model_resolver.rb
46
+ - lib/generators/harmonia/templates/concerns/related_model_validation.rb
47
+ - lib/generators/harmonia/templates/concerns/sync_loggable.rb
48
+ - lib/generators/harmonia/templates/concerns/sync_retryable.rb
43
49
  - lib/generators/harmonia/templates/create_harmonia_syncs.rb
44
50
  - lib/generators/harmonia/templates/database_connector.rb
45
51
  - lib/generators/harmonia/templates/filemaker_to_activerecord_syncer_template.rb