lutaml-store 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/main.yml +27 -0
  3. data/.gitignore +12 -0
  4. data/.rspec +3 -0
  5. data/.rubocop.yml +10 -0
  6. data/.rubocop_todo.yml +450 -0
  7. data/CLAUDE.md +57 -0
  8. data/CODE_OF_CONDUCT.md +132 -0
  9. data/CORRECTED_HTTP_CACHE_IMPLEMENTATION.md +209 -0
  10. data/CORRECTED_HTTP_CACHE_PLAN.md +164 -0
  11. data/Gemfile +15 -0
  12. data/Gemfile.lock +220 -0
  13. data/README.adoc +1430 -0
  14. data/Rakefile +12 -0
  15. data/TODO.impl/0-lutaml-store-self-quality.md +112 -0
  16. data/TODO.impl/1-lutaml-hal-migration.md +60 -0
  17. data/TODO.impl/2-glossarist-migration.md +359 -0
  18. data/TODO.impl/3-lutaml-jsonschema-migration.md +273 -0
  19. data/bin/console +11 -0
  20. data/bin/setup +8 -0
  21. data/demo/Gemfile +15 -0
  22. data/demo/Gemfile.lock +61 -0
  23. data/demo/README.adoc +301 -0
  24. data/demo/data/vcards/co/contact_10_thompson.data +1 -0
  25. data/demo/data/vcards/co/contact_10_thompson.meta +1 -0
  26. data/demo/data/vcards/co/contact_1_doe.data +1 -0
  27. data/demo/data/vcards/co/contact_1_doe.meta +1 -0
  28. data/demo/data/vcards/co/contact_2_smith.data +1 -0
  29. data/demo/data/vcards/co/contact_2_smith.meta +1 -0
  30. data/demo/data/vcards/co/contact_3_johnson.data +1 -0
  31. data/demo/data/vcards/co/contact_3_johnson.meta +1 -0
  32. data/demo/data/vcards/co/contact_4_garcia.data +1 -0
  33. data/demo/data/vcards/co/contact_4_garcia.meta +1 -0
  34. data/demo/data/vcards/co/contact_5_wilson.data +1 -0
  35. data/demo/data/vcards/co/contact_5_wilson.meta +1 -0
  36. data/demo/data/vcards/co/contact_6_brown.data +1 -0
  37. data/demo/data/vcards/co/contact_6_brown.meta +1 -0
  38. data/demo/data/vcards/co/contact_7_davis.data +1 -0
  39. data/demo/data/vcards/co/contact_7_davis.meta +1 -0
  40. data/demo/data/vcards/co/contact_8_anderson.data +1 -0
  41. data/demo/data/vcards/co/contact_8_anderson.meta +1 -0
  42. data/demo/data/vcards/co/contact_9_taylor.data +1 -0
  43. data/demo/data/vcards/co/contact_9_taylor.meta +1 -0
  44. data/demo/data/vcards.db +0 -0
  45. data/demo/pottery_class_demo.rb +164 -0
  46. data/demo/vcard_models.rb +140 -0
  47. data/demo/vcard_store_demo.rb +526 -0
  48. data/lib/lutaml/store/adapter/base.rb +65 -0
  49. data/lib/lutaml/store/adapter/filesystem.rb +288 -0
  50. data/lib/lutaml/store/adapter/memory.rb +225 -0
  51. data/lib/lutaml/store/adapter/sqlite.rb +193 -0
  52. data/lib/lutaml/store/adapter.rb +12 -0
  53. data/lib/lutaml/store/attribute_updater.rb +198 -0
  54. data/lib/lutaml/store/basic_store.rb +190 -0
  55. data/lib/lutaml/store/cache.rb +108 -0
  56. data/lib/lutaml/store/cache_store.rb +282 -0
  57. data/lib/lutaml/store/composite_model_handler.rb +169 -0
  58. data/lib/lutaml/store/compression.rb +137 -0
  59. data/lib/lutaml/store/config.rb +178 -0
  60. data/lib/lutaml/store/database_store.rb +425 -0
  61. data/lib/lutaml/store/events.rb +92 -0
  62. data/lib/lutaml/store/format/base.rb +33 -0
  63. data/lib/lutaml/store/format/json.rb +25 -0
  64. data/lib/lutaml/store/format/jsonl.rb +37 -0
  65. data/lib/lutaml/store/format/marshal_format.rb +37 -0
  66. data/lib/lutaml/store/format/yaml.rb +29 -0
  67. data/lib/lutaml/store/format/yamls.rb +35 -0
  68. data/lib/lutaml/store/format.rb +33 -0
  69. data/lib/lutaml/store/http_cache.rb +279 -0
  70. data/lib/lutaml/store/http_cache_config.rb +53 -0
  71. data/lib/lutaml/store/http_cache_entry.rb +69 -0
  72. data/lib/lutaml/store/http_header_processor.rb +175 -0
  73. data/lib/lutaml/store/integrity.rb +102 -0
  74. data/lib/lutaml/store/model_registration.rb +75 -0
  75. data/lib/lutaml/store/model_registry.rb +123 -0
  76. data/lib/lutaml/store/model_serializer.rb +69 -0
  77. data/lib/lutaml/store/monitor.rb +192 -0
  78. data/lib/lutaml/store/storage_key.rb +40 -0
  79. data/lib/lutaml/store/version.rb +7 -0
  80. data/lib/lutaml/store.rb +41 -0
  81. data/lutaml-store.gemspec +35 -0
  82. data/plan.adoc +606 -0
  83. data/sig/lutaml/store.rbs +6 -0
  84. data/spec/lutaml/store/adapter_interface_spec.rb +89 -0
  85. data/spec/lutaml/store/anti_pattern_guard_spec.rb +35 -0
  86. data/spec/lutaml/store/anti_pattern_spec.rb +78 -0
  87. data/spec/lutaml/store/autoload_spec.rb +34 -0
  88. data/spec/lutaml/store/cache_store_spec.rb +271 -0
  89. data/spec/lutaml/store/compression_spec.rb +78 -0
  90. data/spec/lutaml/store/config_enhanced_spec.rb +158 -0
  91. data/spec/lutaml/store/corrected_http_cache_integration_spec.rb +336 -0
  92. data/spec/lutaml/store/custom_serializer_spec.rb +108 -0
  93. data/spec/lutaml/store/database_store_spec.rb +279 -0
  94. data/spec/lutaml/store/file_io_spec.rb +219 -0
  95. data/spec/lutaml/store/format_round_trip_spec.rb +110 -0
  96. data/spec/lutaml/store/format_spec.rb +70 -0
  97. data/spec/lutaml/store/http_cache_entry_spec.rb +203 -0
  98. data/spec/lutaml/store/http_cache_hal_integration_spec.rb +404 -0
  99. data/spec/lutaml/store/http_cache_spec.rb +422 -0
  100. data/spec/lutaml/store/http_header_processor_spec.rb +290 -0
  101. data/spec/lutaml/store/import_spec.rb +90 -0
  102. data/spec/lutaml/store/integrity_spec.rb +157 -0
  103. data/spec/lutaml/store/key_collision_serializer_spec.rb +98 -0
  104. data/spec/lutaml/store/load_save_spec.rb +107 -0
  105. data/spec/lutaml/store/lutaml_model_integration_spec.rb +291 -0
  106. data/spec/lutaml/store/model_serializer_spec.rb +140 -0
  107. data/spec/lutaml/store/store_spec.rb +182 -0
  108. data/spec/lutaml/store_spec.rb +21 -0
  109. data/spec/spec_helper.rb +16 -0
  110. metadata +166 -0
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Lutaml
6
+ module Store
7
+ class Config
8
+ attr_reader :adapter_type, :adapter_options, :cache_enabled, :cache_max_size,
9
+ :cache_ttl, :monitoring_enabled, :async_events, :compression_enabled,
10
+ :compression_algorithm, :compression_level, :serialization_formats,
11
+ :validate_on_write
12
+
13
+ def initialize(adapter_type: :memory, adapter_options: {},
14
+ cache: {}, monitoring: {}, events: {},
15
+ compression: {}, serialization: {}, **)
16
+ @adapter_type = normalize_adapter_type(adapter_type)
17
+ @adapter_options = symbolize_keys(adapter_options)
18
+
19
+ cache_config = symbolize_keys(cache)
20
+ @cache_enabled = cache_config.fetch(:enabled, true)
21
+ @cache_max_size = cache_config.fetch(:max_size, 1000)
22
+ @cache_ttl = cache_config.fetch(:ttl, nil)
23
+
24
+ monitoring_config = symbolize_keys(monitoring)
25
+ @monitoring_enabled = monitoring_config.fetch(:enabled, false)
26
+
27
+ events_config = symbolize_keys(events)
28
+ @async_events = events_config.fetch(:async, false)
29
+
30
+ compression_config = symbolize_keys(compression)
31
+ @compression_enabled = compression_config.fetch(:enabled, false)
32
+ @compression_algorithm = compression_config.fetch(:algorithm, "gzip")
33
+ @compression_level = compression_config.fetch(:level, 6)
34
+
35
+ serialization_config = symbolize_keys(serialization)
36
+ @serialization_formats = serialization_config.fetch(:formats, %w[marshal hash json yaml xml toml])
37
+ @validate_on_write = serialization_config.fetch(:validate_on_write, false)
38
+ end
39
+
40
+ def self.from_file(file_path)
41
+ config_data = YAML.load_file(file_path)
42
+ lutaml_config = config_data["lutaml_store"] || config_data
43
+ from_hash(lutaml_config)
44
+ rescue Errno::ENOENT
45
+ raise ConfigurationError, "Configuration file not found: #{file_path}"
46
+ rescue Psych::SyntaxError => e
47
+ raise ConfigurationError, "Invalid YAML in configuration file: #{e.message}"
48
+ end
49
+
50
+ def self.from_yaml(yaml_string)
51
+ config_data = YAML.safe_load(yaml_string)
52
+ lutaml_config = config_data["lutaml_store"] || config_data
53
+ from_hash(lutaml_config)
54
+ rescue Psych::SyntaxError => e
55
+ raise ConfigurationError, "Invalid YAML configuration: #{e.message}"
56
+ end
57
+
58
+ def cache_enabled?
59
+ @cache_enabled
60
+ end
61
+
62
+ def monitoring_enabled?
63
+ @monitoring_enabled
64
+ end
65
+
66
+ def async_events?
67
+ @async_events
68
+ end
69
+
70
+ def compression_enabled?
71
+ @compression_enabled
72
+ end
73
+
74
+ def validate_on_write?
75
+ @validate_on_write
76
+ end
77
+
78
+ def validate!
79
+ validate_adapter_config
80
+ validate_cache_config
81
+ validate_monitoring_config
82
+ validate_events_config
83
+ end
84
+
85
+ def to_h
86
+ {
87
+ adapter: { type: @adapter_type, options: @adapter_options },
88
+ cache: { enabled: @cache_enabled, max_size: @cache_max_size, ttl: @cache_ttl },
89
+ monitoring: { enabled: @monitoring_enabled },
90
+ events: { async: @async_events },
91
+ compression: { enabled: @compression_enabled, algorithm: @compression_algorithm, level: @compression_level },
92
+ serialization: { formats: @serialization_formats, validate_on_write: @validate_on_write }
93
+ }
94
+ end
95
+
96
+ class << self
97
+ def from_hash(hash)
98
+ symbolized = symbolize_keys(hash)
99
+ adapter_config = symbolized[:adapter] || {}
100
+ new(
101
+ adapter_type: adapter_config[:type],
102
+ adapter_options: adapter_config[:options] || {},
103
+ cache: symbolized[:cache] || {},
104
+ monitoring: symbolized[:monitoring] || {},
105
+ events: symbolized[:events] || {},
106
+ compression: symbolized[:compression] || {},
107
+ serialization: symbolized[:serialization] || {}
108
+ )
109
+ end
110
+
111
+ private :from_hash
112
+
113
+ def symbolize_keys(hash)
114
+ return hash unless hash.is_a?(Hash)
115
+
116
+ hash.each_with_object({}) do |(key, value), result|
117
+ new_key = key.to_sym
118
+ new_value = value.is_a?(Hash) ? symbolize_keys(value) : value
119
+ result[new_key] = new_value
120
+ end
121
+ end
122
+ end
123
+
124
+ private
125
+
126
+ def normalize_adapter_type(type)
127
+ case type
128
+ when Symbol then type
129
+ when String then type.to_sym
130
+ when Hash
131
+ type[:type]&.to_sym || type["type"]&.to_sym || :memory
132
+ else
133
+ :memory
134
+ end
135
+ end
136
+
137
+ def validate_adapter_config
138
+ valid_adapters = %i[memory filesystem sqlite]
139
+ unless valid_adapters.include?(@adapter_type)
140
+ raise ConfigurationError,
141
+ "Invalid adapter type: #{@adapter_type}. " \
142
+ "Valid types: #{valid_adapters.join(", ")}"
143
+ end
144
+
145
+ case @adapter_type
146
+ when :filesystem
147
+ raise ConfigurationError, "FileSystem adapter requires 'path' option" unless @adapter_options[:path]
148
+ when :sqlite
149
+ raise ConfigurationError, "SQLite adapter requires 'path' option" unless @adapter_options[:path]
150
+ end
151
+ end
152
+
153
+ def validate_cache_config
154
+ raise ConfigurationError, "Cache max_size must be positive" if @cache_max_size && @cache_max_size <= 0
155
+
156
+ return unless @cache_ttl && @cache_ttl <= 0
157
+
158
+ raise ConfigurationError, "Cache TTL must be positive"
159
+ end
160
+
161
+ def validate_monitoring_config
162
+ return if [true, false].include?(@monitoring_enabled)
163
+
164
+ raise ConfigurationError, "Monitoring enabled must be boolean"
165
+ end
166
+
167
+ def validate_events_config
168
+ return if [true, false].include?(@async_events)
169
+
170
+ raise ConfigurationError, "Events async must be boolean"
171
+ end
172
+
173
+ def symbolize_keys(hash)
174
+ self.class.symbolize_keys(hash)
175
+ end
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,425 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Store
5
+ # Store-centric API with model registry and database-style operations
6
+ class DatabaseStore
7
+ attr_reader :store, :registry, :composite_handler, :attribute_updater
8
+
9
+ def initialize(adapter:, models: [], **options)
10
+ @store = BasicStore.new(adapter_type: adapter, **options)
11
+ @registry = ModelRegistry.new(models)
12
+ @serializer = ModelSerializer.new
13
+ @composite_handler = CompositeModelHandler.new(@registry, @store, self, serializer: @serializer)
14
+ @attribute_updater = AttributeUpdater.new(@registry, @composite_handler)
15
+
16
+ validate_configuration!
17
+ end
18
+
19
+ # Save single model or array of models
20
+ def save(models)
21
+ models_array = Array(models)
22
+ saved_models = models_array.map { |model| save_single_model(model) }
23
+
24
+ @store.emit_event(:model_save, models: saved_models, count: saved_models.size)
25
+ models.is_a?(Array) ? saved_models : saved_models.first
26
+ end
27
+
28
+ # Fetch model by class and key field
29
+ def fetch(model:, **key_params)
30
+ registration = @registry.registration_for(model)
31
+ key_field = registration.key_field
32
+ key_value = key_params[key_field]
33
+ raise InvalidKeyError, "Key field '#{key_field}' not provided" if key_value.nil?
34
+
35
+ stored_data = find_stored_data(registration, model, key_value)
36
+ return nil unless stored_data
37
+
38
+ model_instance = @serializer.deserialize(stored_data, model, registration)
39
+
40
+ composite_references = stored_data["_composite_models"]
41
+ if composite_references
42
+ model_instance = @composite_handler.restore_composite_models(
43
+ model_instance, composite_references
44
+ )
45
+ end
46
+
47
+ @store.emit_event(:model_fetch, model: model_instance, key: key_value, source: :backend)
48
+ model_instance
49
+ end
50
+
51
+ # Update model with attributes array or block
52
+ def update(model:, attributes: nil, **key_params, &block)
53
+ current_model = fetch(model: model, **key_params)
54
+ raise ModelNotRegisteredError, "Model not found" unless current_model
55
+
56
+ updated_model = if block_given?
57
+ @attribute_updater.update_with_block(current_model, &block)
58
+ elsif attributes
59
+ if attributes.is_a?(Hash)
60
+ @attribute_updater.update_with_hash(current_model, attributes)
61
+ else
62
+ @attribute_updater.update_with_attributes(current_model, attributes)
63
+ end
64
+ else
65
+ raise ArgumentError, "Either attributes or block must be provided"
66
+ end
67
+
68
+ save(updated_model)
69
+
70
+ @store.emit_event(:model_update,
71
+ model: updated_model,
72
+ key: key_params,
73
+ changes: extract_changes(current_model, updated_model))
74
+
75
+ updated_model
76
+ end
77
+
78
+ # Destroy model by class and key field
79
+ def destroy(model:, **key_params)
80
+ registration = @registry.registration_for(model)
81
+ key_field = registration.key_field
82
+ key_value = key_params[key_field]
83
+ raise InvalidKeyError, "Key field '#{key_field}' not provided" if key_value.nil?
84
+
85
+ model_instance = fetch(model: model, **key_params)
86
+ return false unless model_instance
87
+
88
+ @composite_handler.delete_composite_models(model_instance)
89
+
90
+ storage_key = registration.generate_storage_key_from_value(key_value)
91
+ deleted = @store.delete(storage_key)
92
+
93
+ @store.emit_event(:model_destroy, model: model, key: key_value, deleted: deleted)
94
+ deleted
95
+ end
96
+
97
+ # Query operations
98
+ def where(model:, **conditions)
99
+ all(model: model).select do |model_instance|
100
+ conditions.all? { |field, value| model_instance.public_send(field) == value }
101
+ end
102
+ end
103
+
104
+ # Get all models of a specific type
105
+ def all(model:)
106
+ registration = @registry.registration_for(model)
107
+
108
+ models = []
109
+ @store.keys.each do |storage_key|
110
+ parsed = StorageKey.parse(storage_key.to_s)
111
+ next unless parsed.class_name == model.name
112
+
113
+ stored_data = @store.get(storage_key)
114
+ next unless stored_data
115
+
116
+ begin
117
+ model_instance = @serializer.deserialize(stored_data, model, registration)
118
+
119
+ composite_references = stored_data["_composite_models"]
120
+ if composite_references
121
+ model_instance = @composite_handler.restore_composite_models(
122
+ model_instance, composite_references
123
+ )
124
+ end
125
+
126
+ models << model_instance
127
+ rescue StandardError => e
128
+ @store.emit_event(:deserialization_error, key: storage_key, error: e)
129
+ end
130
+ end
131
+
132
+ models
133
+ end
134
+
135
+ def exists?(model:, **key_params)
136
+ !fetch(model: model, **key_params).nil?
137
+ end
138
+
139
+ def count(model:)
140
+ all(model: model).size
141
+ end
142
+
143
+ # Load all models of a type from a directory using format-specific serialization.
144
+ # Bypasses the key-value layer and reads files directly using the format handler.
145
+ def load_all(model_class, path: nil, format: :yaml, layout: :separate)
146
+ fmt = Format.resolve(format)
147
+ dir = resolve_model_dir(model_class, path)
148
+
149
+ raise BackendError, "No directory specified for load_all" unless dir
150
+ raise BackendError, "Directory not found: #{dir}" unless Dir.exist?(dir)
151
+
152
+ case layout
153
+ when :separate
154
+ load_separate(dir, model_class, fmt)
155
+ when :grouped
156
+ load_grouped(dir, model_class, fmt)
157
+ when :flat
158
+ load_flat(dir, model_class, fmt)
159
+ else
160
+ raise ConfigurationError, "Unknown layout: #{layout}"
161
+ end
162
+ end
163
+
164
+ # Load from directory and store into the key-value backend.
165
+ # Returns the loaded models and makes them available via fetch/where/all.
166
+ def import_all(model_class, path: nil, format: :yaml, layout: :separate)
167
+ models = load_all(model_class, path: path, format: format, layout: layout)
168
+ models.each { |model| save(model) }
169
+ @store.emit_event(:model_import, count: models.size, path: path)
170
+ models
171
+ end
172
+
173
+ # Save all models to a directory using format-specific serialization.
174
+ def save_all(models, path: nil, format: :yaml, layout: :separate)
175
+ fmt = Format.resolve(format)
176
+ models_array = Array(models)
177
+ return [] if models_array.empty?
178
+
179
+ model_class = models_array.first.class
180
+ dir = resolve_model_dir(model_class, path)
181
+
182
+ raise BackendError, "No directory specified for save_all" unless dir
183
+
184
+ FileUtils.mkdir_p(dir)
185
+
186
+ saved = case layout
187
+ when :separate
188
+ save_separate(models_array, dir, fmt)
189
+ when :grouped
190
+ save_grouped(models_array, dir, fmt, model_class)
191
+ when :flat
192
+ save_flat(models_array, dir, fmt)
193
+ else
194
+ raise ConfigurationError, "Unknown layout: #{layout}"
195
+ end
196
+
197
+ @store.emit_event(:model_save_all, models: saved, count: saved.size, path: dir)
198
+ saved
199
+ end
200
+
201
+ # Export models to a single file or directory.
202
+ def export(models, path:, format: :yaml)
203
+ fmt = Format.resolve(format)
204
+ models_array = Array(models)
205
+
206
+ FileUtils.mkdir_p(File.dirname(path))
207
+
208
+ content = fmt.serialize_many(models_array)
209
+
210
+ File.write(path, content, encoding: "utf-8")
211
+ @store.emit_event(:model_export, count: models_array.size, path: path)
212
+ path
213
+ end
214
+
215
+ def on(event, &block)
216
+ @store.on(event, &block)
217
+ end
218
+
219
+ def off(event, listener)
220
+ @store.off(event, listener)
221
+ end
222
+
223
+ def stats
224
+ base_stats = @store.stats
225
+ base_stats.merge(
226
+ models_registered: @registry.count,
227
+ registered_models: @registry.registered_models.map(&:name),
228
+ total_models: total_model_count
229
+ )
230
+ end
231
+
232
+ def close
233
+ @store.close
234
+ end
235
+
236
+ private
237
+
238
+ def find_stored_data(registration, model, key_value)
239
+ if registration.polymorphic?
240
+ find_polymorphic_data(model, key_value)
241
+ else
242
+ storage_key = registration.generate_storage_key_from_value(key_value)
243
+ @store.get(storage_key)
244
+ end
245
+ end
246
+
247
+ def find_polymorphic_data(model, key_value)
248
+ candidates = @store.keys
249
+ .filter_map do |key|
250
+ parsed = StorageKey.parse(key.to_s)
251
+ next unless parsed.key_value == key_value.to_s
252
+
253
+ data = @store.get(key)
254
+ next unless data&.key?("_class")
255
+
256
+ stored_class = Object.const_get(data["_class"])
257
+ next unless stored_class <= model
258
+
259
+ { key: key, data: data, klass: stored_class }
260
+ end
261
+
262
+ candidates.max_by { |c| polymorphic_depth(c[:klass], model) }&.dig(:data)
263
+ end
264
+
265
+ def polymorphic_depth(klass, base)
266
+ depth = 0
267
+ current = klass
268
+ while current < base && current.superclass
269
+ current = current.superclass
270
+ depth += 1
271
+ end
272
+ depth
273
+ end
274
+
275
+ def save_single_model(model)
276
+ registration = @registry.registration_for(model.class)
277
+ composite_references = @composite_handler.process_composite_models(model)
278
+ serialized_data = @serializer.serialize(model, registration)
279
+
280
+ serialized_data["_composite_models"] = composite_references unless composite_references.empty?
281
+
282
+ storage_key = registration.generate_storage_key(model)
283
+ @store.set(storage_key, serialized_data)
284
+
285
+ unless composite_references.empty?
286
+ @store.emit_event(:composite_model_stored,
287
+ model: model,
288
+ composite_count: composite_references.size)
289
+ end
290
+
291
+ model
292
+ end
293
+
294
+ def extract_changes(old_model, new_model)
295
+ registration = @registry.registration_for(old_model.class)
296
+ old_attrs = @serializer.serialize(old_model, registration)
297
+ new_attrs = @serializer.serialize(new_model, registration)
298
+
299
+ new_attrs.each_with_object({}) do |(key, new_value), changes|
300
+ old_value = old_attrs[key]
301
+ changes[key] = { from: old_value, to: new_value } if old_value != new_value
302
+ end
303
+ end
304
+
305
+ def total_model_count
306
+ @registry.registered_models.sum { |model_class| count(model: model_class) }
307
+ end
308
+
309
+ def validate_configuration!
310
+ return unless @registry.empty?
311
+
312
+ raise ConfigurationError,
313
+ "No models registered. Provide models: [{ model: YourModel, key: :key_field }]"
314
+ end
315
+
316
+ # ── Layout helpers ──
317
+
318
+ def resolve_model_dir(model_class, base_path)
319
+ registration = @registry.find_registration(model_class)
320
+ return base_path unless registration
321
+
322
+ dir = registration.dir
323
+ return base_path unless dir
324
+
325
+ base_path ? File.join(base_path, dir) : nil
326
+ end
327
+
328
+ def load_separate(dir, model_class, fmt)
329
+ models = []
330
+ glob = File.join(dir, fmt.glob_pattern)
331
+ Dir.glob(glob).sort.each do |file_path|
332
+ next unless File.file?(file_path)
333
+
334
+ raw = File.read(file_path, encoding: "utf-8")
335
+ next if raw.strip.empty?
336
+
337
+ begin
338
+ model = fmt.deserialize(raw, model_class)
339
+ set_model_key_from_filename(model, file_path, fmt)
340
+ models << model
341
+ rescue StandardError => e
342
+ @store.emit_event(:load_error, file: file_path, error: e)
343
+ end
344
+ end
345
+ models
346
+ end
347
+
348
+ def load_grouped(dir, model_class, fmt)
349
+ models = []
350
+ glob = File.join(dir, fmt.glob_pattern)
351
+ Dir.glob(glob).sort.each do |file_path|
352
+ next unless File.file?(file_path)
353
+
354
+ raw = File.read(file_path, encoding: "utf-8")
355
+ next if raw.strip.empty?
356
+
357
+ begin
358
+ loaded = fmt.deserialize_many(raw, model_class)
359
+ loaded = [loaded] unless loaded.is_a?(Array)
360
+ loaded.each { |m| set_model_key_from_filename(m, file_path, fmt) }
361
+ models.concat(loaded)
362
+ rescue StandardError => e
363
+ @store.emit_event(:load_error, file: file_path, error: e)
364
+ end
365
+ end
366
+ models
367
+ end
368
+
369
+ def load_flat(dir, model_class, fmt)
370
+ load_separate(dir, model_class, fmt)
371
+ end
372
+
373
+ def save_separate(models, dir, fmt)
374
+ models.map do |model|
375
+ key = extract_model_key(model)
376
+ filename = key || model.class.name.to_s.gsub("::", "_")
377
+ file_path = File.join(dir, "#{filename}#{fmt.extension}")
378
+ content = fmt.serialize(model)
379
+ File.write(file_path, content, encoding: "utf-8")
380
+ model
381
+ end
382
+ end
383
+
384
+ def save_grouped(models, dir, fmt, _model_class)
385
+ grouped = {}
386
+ models.each do |model|
387
+ key = extract_model_key(model) || model.class.name.to_s.gsub("::", "_")
388
+ grouped[key] ||= []
389
+ grouped[key] << model
390
+ end
391
+
392
+ grouped.map do |key, group|
393
+ file_path = File.join(dir, "#{key}#{fmt.extension}")
394
+ content = fmt.serialize_many(group)
395
+ File.write(file_path, content, encoding: "utf-8")
396
+ group
397
+ end.flatten
398
+ end
399
+
400
+ def save_flat(models, dir, fmt)
401
+ save_separate(models, dir, fmt)
402
+ end
403
+
404
+ def extract_model_key(model)
405
+ registration = @registry.find_registration(model.class)
406
+ return nil unless registration
407
+
408
+ key_value = model.public_send(registration.key_field)
409
+ key_value&.to_s
410
+ rescue StandardError
411
+ nil
412
+ end
413
+
414
+ def set_model_key_from_filename(model, file_path, _fmt)
415
+ return if extract_model_key(model)
416
+
417
+ registration = @registry.find_registration(model.class)
418
+ return unless registration
419
+
420
+ basename = File.basename(file_path, ".*")
421
+ model.public_send(:"#{registration.key_field}=", basename)
422
+ end
423
+ end
424
+ end
425
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Store
5
+ class Events
6
+ def initialize(async: false)
7
+ @listeners = Hash.new { |h, k| h[k] = [] }
8
+ @mutex = Mutex.new
9
+ @async = async
10
+ @queue = async ? Queue.new : nil
11
+ @worker_thread = async ? start_worker_thread : nil
12
+ end
13
+
14
+ def on(event, callable = nil, &block)
15
+ listener = callable || block
16
+ raise ArgumentError, "No listener provided" unless listener
17
+ raise ArgumentError, "Event must be a Symbol" unless event.is_a?(Symbol)
18
+
19
+ @mutex.synchronize do
20
+ @listeners[event] << listener
21
+ end
22
+ end
23
+
24
+ def off(event, listener)
25
+ @mutex.synchronize do
26
+ @listeners[event].delete(listener)
27
+ end
28
+ end
29
+
30
+ def emit(event, data = {})
31
+ listeners = @mutex.synchronize { @listeners[event].dup }
32
+ return if listeners.empty?
33
+
34
+ event_data = {
35
+ event: event,
36
+ timestamp: Time.now,
37
+ **data
38
+ }
39
+
40
+ if @async
41
+ @queue << [listeners, event_data]
42
+ else
43
+ notify_listeners(listeners, event_data)
44
+ end
45
+ end
46
+
47
+ def clear_listeners(event = nil)
48
+ @mutex.synchronize do
49
+ if event
50
+ @listeners[event].clear
51
+ else
52
+ @listeners.clear
53
+ end
54
+ end
55
+ end
56
+
57
+ def listener_count(event)
58
+ @mutex.synchronize { @listeners[event].size }
59
+ end
60
+
61
+ def stop
62
+ return unless @async && @worker_thread
63
+
64
+ @queue << :stop
65
+ @worker_thread.join
66
+ @worker_thread = nil
67
+ end
68
+
69
+ private
70
+
71
+ def notify_listeners(listeners, event_data)
72
+ listeners.each do |listener|
73
+ listener.call(event_data)
74
+ rescue StandardError => e
75
+ warn "Event listener error: #{e.message}"
76
+ end
77
+ end
78
+
79
+ def start_worker_thread
80
+ Thread.new do
81
+ loop do
82
+ item = @queue.pop
83
+ break if item == :stop
84
+
85
+ listeners, event_data = item
86
+ notify_listeners(listeners, event_data)
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end