harmonia 0.2.3 → 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: 6855db28daa85553fa8479d6dd6217c43ae5c008be01c0b5f92b3154ce183f9c
4
- data.tar.gz: 9b44c5dd79d2a5626053541bcc0a869025e37ecee53c97018eae853a4004d440
3
+ metadata.gz: ba24e3fb10ef3510c855bda693d015d4a0b158f0f076af681389d6a26dac6c9e
4
+ data.tar.gz: 69b3c0a459c92772cc179e6e5584ab38762e6e9dab1898d1787b27aaa9640634
5
5
  SHA512:
6
- metadata.gz: 03056f3bafcbae9a2b69251fe7de63af424fe8bc216c5f18c228b390f47a6baaef8f041b9f1f66ed51f0f0be8ccf1c6e5653649ff55312be10feca45cf71f0c4
7
- data.tar.gz: bf9bde1fd412e5af7b04a5e583fef2441e12ffb20bd55822d33e41f8bfa9e59afce719e5435d6732ee9885492aca376321e9028224c436d6004af7f726337255
6
+ metadata.gz: 3f940e8b3bca9c1ce1cca522051de2af62a0c9b82b4bab54e0001cef86bab7638086408f66bc6e1eaf76d90f090403871310c750070d534668a187fbadb0f454
7
+ data.tar.gz: faeb5432593d35938dac873e4aca8240cabcbac7ceb5f902f9756d121f34d00d9abf200f04e66a5d3232a9c1d1f42038c86935e0d07b32103871238afd56f55d
data/README.md CHANGED
@@ -169,14 +169,14 @@ class ProductSyncer
169
169
  @total_create_required = filemaker_records.length
170
170
 
171
171
  existing_ids = Product.pluck(:filemaker_id)
172
- filemaker_records.reject { |record| existing_ids.include?(record.record_id) }
172
+ filemaker_records.reject { |record| existing_ids.include?(record.id) }
173
173
  end
174
174
 
175
175
  def records_to_update
176
176
  filemaker_records = Trophonius::Product.all
177
177
 
178
178
  records_needing_update = filemaker_records.select { |fm_record|
179
- pg_record = Product.find_by(filemaker_id: fm_record.record_id)
179
+ pg_record = Product.find_by(filemaker_id: fm_record.id)
180
180
  pg_record && needs_update?(fm_record, pg_record)
181
181
  }
182
182
 
@@ -185,7 +185,7 @@ class ProductSyncer
185
185
  end
186
186
 
187
187
  def records_to_delete
188
- filemaker_ids = Trophonius::Product.all.map(&:record_id)
188
+ filemaker_ids = Trophonius::Product.all.map(&:id)
189
189
  Product.where.not(filemaker_id: filemaker_ids).pluck(:id)
190
190
  end
191
191
 
@@ -393,7 +393,7 @@ module Trophonius
393
393
  # Converts FileMaker record to PostgreSQL attributes
394
394
  def self.to_pg(record)
395
395
  {
396
- filemaker_id: record.record_id,
396
+ filemaker_id: record.id,
397
397
  name: record.field_data['ProductName'],
398
398
  price: record.field_data['Price'].to_f,
399
399
  # ... map other fields
@@ -432,8 +432,8 @@ add_index :products, :filemaker_id, unique: true
432
432
 
433
433
  This column serves as the bridge between your ActiveRecord records and FileMaker records:
434
434
 
435
- - **FileMaker to ActiveRecord**: Stores the FileMaker `record_id` to identify which FileMaker record corresponds to each Rails record
436
- - **ActiveRecord to FileMaker**: Can store the FileMaker `record_id` after creation, or you can use a separate field in FileMaker (like `PostgreSQLID`) to maintain the relationship
435
+ - **FileMaker to ActiveRecord**: Stores the FileMaker `id` to identify which FileMaker record corresponds to each Rails record
436
+ - **ActiveRecord to FileMaker**: Can store the FileMaker `id` after creation, or you can use a separate field in FileMaker (like `PostgreSQLID`) to maintain the relationship
437
437
 
438
438
  **Important Notes**:
439
439
  - The column is indexed with a unique constraint for performance and data integrity
@@ -601,15 +601,15 @@ RSpec.describe ProductSyncer do
601
601
  it 'returns records not in PostgreSQL' do
602
602
  # Mock FileMaker records
603
603
  allow(Trophonius::Product).to receive(:all).and_return([
604
- double(record_id: 1),
605
- double(record_id: 2)
604
+ double(id: 1),
605
+ double(id: 2)
606
606
  ])
607
607
 
608
608
  # Mock existing PostgreSQL records
609
609
  allow(Product).to receive(:pluck).with(:filemaker_id).and_return([1])
610
610
 
611
611
  result = syncer.send(:records_to_create)
612
- expect(result.map(&:record_id)).to eq([2])
612
+ expect(result.map(&:id)).to eq([2])
613
613
  end
614
614
  end
615
615
  end
@@ -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(&:record_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
- record_ids = records_to_delete
119
- return if record_ids.empty?
120
-
121
- record_ids.each do |record_id|
122
- fm_record = YourTrophoniusModel.find(record_id)
123
- fm_record.destroy
124
- rescue Trophonius::RecordNotFoundError
125
- # Record already deleted, skip
126
- next
129
+ ids = records_to_delete
130
+ return if ids.empty?
131
+
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,27 +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)
9
+ def initialize(database_connector, server_id, update_only: false)
7
10
  @database_connector = database_connector
8
- @last_synced_on = Harmonia::Sync.last_sync_for('<%= table_name %>', 'FileMaker to ActiveRecord')&.ran_on || (Time.now - 15.year)
11
+ @server_id = server_id
12
+ @last_synced_on = if update_only
13
+ Time.now - 100.years
14
+ else
15
+ Harmonia::Sync.last_sync_for('<%= table_name %>', 'FileMaker to ActiveRecord', server_id)&.ran_on || (Time.now - 15.years)
16
+ end
17
+ @update_only = update_only
9
18
  @failed_fm_ids = {}
10
19
  @failed_pg_ids = {}
11
20
  end
12
21
 
13
- # Main sync method
14
- # Executes the sync process for <%= class_name %> records
15
22
  def run
16
23
  raise StandardError, 'No database connector set' if @database_connector.blank?
17
24
 
18
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)
19
28
 
20
29
  @database_connector.open_database do
21
30
  sync_record.start!
22
31
  sync_records(sync_record)
32
+ retry_failed_records(FileMaker::<%= class_name %>, table: '<%= table_name %>', direction: 'FileMaker to ActiveRecord')
23
33
  end
24
34
  rescue StandardError => e
35
+ log_sync_error(table: '<%= table_name %>', direction: 'FileMaker to ActiveRecord', error: e)
25
36
  sync_record&.fail!(e.message, failed_fm_ids: @failed_fm_ids, failed_pg_ids: @failed_pg_ids)
26
37
  raise
27
38
  end
@@ -30,78 +41,77 @@ class <%= class_name %>Syncer
30
41
 
31
42
  def sync_records(sync_record)
32
43
  updated_count = update_records
33
- created_count = create_records
34
- delete_records
44
+ created_count = @update_only ? 0 : create_records
45
+ deleted_count = @update_only ? 0 : delete_records.to_i
35
46
 
36
- total_synced = created_count + updated_count
47
+ total_synced = created_count + updated_count
37
48
  total_required = (@total_create_required || 0) + (@total_update_required || 0)
38
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
+
39
57
  sync_record.finish!(
40
- records_synced: total_synced,
58
+ records_synced: total_synced,
41
59
  records_required: total_required,
42
- failed_fm_ids: @failed_fm_ids,
43
- 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
44
69
  )
45
70
  end
46
71
 
47
- # Returns an array of Trophonius records that need to be created
48
- # Use FileMaker::<%= class_name %>.to_pg(record) to convert to PostgreSQL attributes
49
- # Set @total_create_required to the total number of records that should exist after creation
50
- # @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.
51
74
  def records_to_create
52
- # TODO: Implement logic to fetch records from FileMaker that need to be created in PostgreSQL
53
- # Example:
54
- filemaker_records = FileMaker::<%= class_name %>.where(creation_timestamp: ">= #{@last_synced_on.to_fm}").not
55
- @total_create_required = filemaker_records.length
75
+ # TODO: implement
56
76
  existing_ids = <%= class_name %>.pluck(:filemaker_id)
57
- filemaker_records.reject { |record| existing_ids.include?(record.id) }
77
+ filemaker_records = FileMaker::<%= class_name %>.where(creation_timestamp: ">= #{@last_synced_on.to_fm}")
78
+ filemaker_records.reject! { |record| existing_ids.include?(record.id) }
79
+ @total_create_required = filemaker_records.length
80
+ filemaker_records
58
81
  end
59
82
 
60
- # Returns an array of Trophonius records that need to be updated
61
- # Use FileMaker::<%= class_name %>.to_pg(record) to convert to PostgreSQL attributes
62
- # Set @total_update_required to the total number of records that should be updated
63
- # @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.
64
85
  def records_to_update
65
- # TODO: Implement logic to fetch records from FileMaker that need to be updated in PostgreSQL
66
- # Example:
86
+ # TODO: implement
67
87
  filemaker_records = FileMaker::<%= class_name %>.where(modification_timestamp: ">= #{@last_synced_on.to_fm}")
68
- records_needing_update = filemaker_records.select { |fm_record|
69
- pg_record = <%= class_name %>.find_by(filemaker_id: fm_record.record_id)
88
+ records_needing_update = filemaker_records.select do |fm_record|
89
+ pg_record = <%= class_name %>.find_by(filemaker_id: fm_record.id)
70
90
  pg_record && needs_update?(fm_record, pg_record)
71
- }
91
+ end
72
92
  @total_update_required = records_needing_update.length
73
93
  records_needing_update
74
94
  end
75
95
 
76
- # Returns an array of record identifiers that need to be deleted
77
- # @return [Array] Array of record identifiers
96
+ # Returns PostgreSQL IDs of records that have been deleted in FileMaker.
78
97
  def records_to_delete
79
- # Get all modified FileMaker record IDs
80
98
  filemaker_records = FileMaker::<%= class_name %>.where(modification_timestamp: ">= #{@last_synced_on.to_fm}")
81
- fm_ids = filemaker_records.map(&:record_id)
99
+ fm_ids = filemaker_records.map(&:id)
82
100
 
83
- # Find PostgreSQL records whose FileMaker IDs aren't in the modified set
84
- # These might have been deleted in FileMaker
85
101
  fm_ids_no_update_needed = <%= class_name %>.where.not(filemaker_id: fm_ids).pluck(:filemaker_id)
86
102
  return [] if fm_ids_no_update_needed.empty?
87
103
 
88
- # Query FileMaker to check if these records still exist
89
- possibly_deleted_query = FileMaker::<%= class_name %>.where(record_id: fm_ids_no_update_needed.first)
104
+ possibly_deleted_query = FileMaker::<%= class_name %>.where(id: fm_ids_no_update_needed.first)
90
105
  fm_ids_no_update_needed.count > 1 && fm_ids_no_update_needed[1..].each do |fm_id|
91
- possibly_deleted_query.or(record_id: fm_id)
106
+ possibly_deleted_query.or(id: fm_id)
92
107
  end
93
108
 
94
- # Find IDs that exist in PostgreSQL but not in FileMaker (truly deleted)
95
- deleted_fm_ids = fm_ids_no_update_needed - possibly_deleted_query.map(&:record_id)
96
-
97
- # Return PostgreSQL IDs for records with these FileMaker IDs
109
+ deleted_fm_ids = fm_ids_no_update_needed - possibly_deleted_query.map(&:id)
98
110
  <%= class_name %>.where(filemaker_id: deleted_fm_ids).pluck(:id)
99
111
  end
100
112
 
101
113
  def needs_update?(fm_record, pg_record)
102
- pg_attributes = FileMaker::<%= class_name %>.to_pg(fm_record)
103
-
104
- 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 }
105
115
  end
106
116
 
107
117
  def create_records
@@ -109,21 +119,17 @@ class <%= class_name %>Syncer
109
119
  return 0 if records.empty?
110
120
 
111
121
  success_count = 0
112
-
113
122
  records.each do |trophonius_record|
114
- begin
115
- attributes = FileMaker::<%= class_name %>.to_pg(trophonius_record).merge(
116
- created_at: Time.current,
117
- updated_at: Time.current
118
- )
119
- <%= class_name %>.create!(attributes)
120
- success_count += 1
121
- rescue StandardError => e
122
- @failed_fm_ids[trophonius_record.record_id.to_s] = e.message
123
- Rails.logger.error("Failed to create record from FileMaker ID #{trophonius_record.record_id}: #{e.message}")
124
- 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)
125
132
  end
126
-
127
133
  success_count
128
134
  end
129
135
 
@@ -132,21 +138,16 @@ class <%= class_name %>Syncer
132
138
  return 0 if records.empty?
133
139
 
134
140
  success_count = 0
135
-
136
141
  records.each do |trophonius_record|
137
- begin
138
- pg_attributes = FileMaker::<%= class_name %>.to_pg(trophonius_record)
139
-
140
- <%= class_name %>.where(filemaker_id: trophonius_record.record_id).update_all(
141
- pg_attributes.merge(updated_at: Time.current)
142
- )
143
- success_count += 1
144
- rescue StandardError => e
145
- @failed_fm_ids[trophonius_record.record_id.to_s] = e.message
146
- Rails.logger.error("Failed to update record from FileMaker ID #{trophonius_record.record_id}: #{e.message}")
147
- 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)
148
150
  end
149
-
150
151
  success_count
151
152
  end
152
153
 
@@ -155,20 +156,19 @@ class <%= class_name %>Syncer
155
156
  return if ids.empty?
156
157
 
157
158
  ids.each do |pg_id|
158
- begin
159
- <%= class_name %>.where(id: pg_id).destroy_all
160
- rescue StandardError => e
161
- @failed_pg_ids[pg_id.to_s] = e.message
162
- Rails.logger.error("Failed to delete record with PostgreSQL ID #{pg_id}: #{e.message}")
163
- 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)
164
163
  end
165
164
  end
166
165
 
167
166
  def create_sync_record
168
167
  Harmonia::Sync.create!(
169
- table: '<%= table_name %>',
170
- ran_on: Time.now,
171
- status: 'pending',
168
+ table: '<%= table_name %>',
169
+ ran_on: Time.now,
170
+ status: 'pending',
171
+ server_id: @server_id,
172
172
  direction: 'FileMaker to ActiveRecord'
173
173
  )
174
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.3
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-17 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