lutaml-store 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +22 -13
  3. data/README.adoc +56 -0
  4. data/docs/astro.config.mjs +12 -0
  5. data/docs/package-lock.json +6098 -0
  6. data/docs/package.json +18 -0
  7. data/docs/public/favicon.svg +1 -0
  8. data/docs/public/lutaml-logo_logo-full-dark.svg +1 -0
  9. data/docs/public/lutaml-logo_logo-full-light.svg +1 -0
  10. data/docs/src/layouts/Base.astro +44 -0
  11. data/docs/src/layouts/Docs.astro +41 -0
  12. data/docs/src/pages/adapters.md +46 -0
  13. data/docs/src/pages/architecture.md +68 -0
  14. data/docs/src/pages/cloud-contract.md +48 -0
  15. data/docs/src/pages/formats.md +54 -0
  16. data/docs/src/pages/http-cache.md +43 -0
  17. data/docs/src/pages/index.md +82 -0
  18. data/docs/src/pages/quick-start.md +109 -0
  19. data/docs/src/pages/sources.md +83 -0
  20. data/docs/src/pages/stores.md +110 -0
  21. data/docs/src/styles/global.css +10 -0
  22. data/lib/lutaml/store/adapter/base.rb +28 -0
  23. data/lib/lutaml/store/adapter/memory.rb +213 -121
  24. data/lib/lutaml/store/adapter/sqlite.rb +181 -7
  25. data/lib/lutaml/store/database_store.rb +302 -49
  26. data/lib/lutaml/store/format.rb +13 -0
  27. data/lib/lutaml/store/manifest.rb +85 -0
  28. data/lib/lutaml/store/mirror.rb +94 -0
  29. data/lib/lutaml/store/predicate.rb +248 -0
  30. data/lib/lutaml/store/query.rb +259 -0
  31. data/lib/lutaml/store/repository.rb +178 -0
  32. data/lib/lutaml/store/source/base.rb +137 -0
  33. data/lib/lutaml/store/source/directory.rb +49 -0
  34. data/lib/lutaml/store/source/https.rb +109 -0
  35. data/lib/lutaml/store/source/rest.rb +61 -0
  36. data/lib/lutaml/store/source/zip.rb +52 -0
  37. data/lib/lutaml/store/source.rb +55 -0
  38. data/lib/lutaml/store/version.rb +1 -1
  39. data/lib/lutaml/store.rb +7 -0
  40. metadata +40 -8
@@ -2,9 +2,10 @@
2
2
 
3
3
  module Lutaml
4
4
  module Store
5
- # Store-centric API with model registry and database-style operations
5
+ # Store-centric API with model registry and database-style operations.
6
+ # Provides CRUD, querying, file I/O, transactions, and events.
6
7
  class DatabaseStore
7
- attr_reader :store, :registry, :composite_handler, :attribute_updater
8
+ attr_reader :store, :registry, :composite_handler, :attribute_updater, :serializer
8
9
 
9
10
  def initialize(adapter:, models: [], **options)
10
11
  @store = BasicStore.new(adapter_type: adapter, **options)
@@ -12,11 +13,13 @@ module Lutaml
12
13
  @serializer = ModelSerializer.new
13
14
  @composite_handler = CompositeModelHandler.new(@registry, @store, self, serializer: @serializer)
14
15
  @attribute_updater = AttributeUpdater.new(@registry, @composite_handler)
16
+ @scopes = {}
15
17
 
16
18
  validate_configuration!
17
19
  end
18
20
 
19
- # Save single model or array of models
21
+ # ── CRUD ──
22
+
20
23
  def save(models)
21
24
  models_array = Array(models)
22
25
  saved_models = models_array.map { |model| save_single_model(model) }
@@ -25,7 +28,6 @@ module Lutaml
25
28
  models.is_a?(Array) ? saved_models : saved_models.first
26
29
  end
27
30
 
28
- # Fetch model by class and key field
29
31
  def fetch(model:, **key_params)
30
32
  registration = @registry.registration_for(model)
31
33
  key_field = registration.key_field
@@ -35,20 +37,12 @@ module Lutaml
35
37
  stored_data = find_stored_data(registration, model, key_value)
36
38
  return nil unless stored_data
37
39
 
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
40
+ model_instance = deserialize_record(stored_data, model, registration)
46
41
 
47
42
  @store.emit_event(:model_fetch, model: model_instance, key: key_value, source: :backend)
48
43
  model_instance
49
44
  end
50
45
 
51
- # Update model with attributes array or block
52
46
  def update(model:, attributes: nil, **key_params, &block)
53
47
  current_model = fetch(model: model, **key_params)
54
48
  raise ModelNotRegisteredError, "Model not found" unless current_model
@@ -75,7 +69,6 @@ module Lutaml
75
69
  updated_model
76
70
  end
77
71
 
78
- # Destroy model by class and key field
79
72
  def destroy(model:, **key_params)
80
73
  registration = @registry.registration_for(model)
81
74
  key_field = registration.key_field
@@ -94,54 +87,96 @@ module Lutaml
94
87
  deleted
95
88
  end
96
89
 
97
- # Query operations
90
+ # ── Query API ──
91
+
92
+ def query(model_class)
93
+ Query.new(self, model_class)
94
+ end
95
+
98
96
  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
97
+ query(model).where(**conditions)
102
98
  end
103
99
 
104
- # Get all models of a specific type
105
100
  def all(model:)
106
- registration = @registry.registration_for(model)
101
+ query(model)
102
+ end
107
103
 
108
- models = []
109
- @store.each_key do |storage_key|
110
- parsed = StorageKey.parse(storage_key.to_s)
111
- next unless parsed.class_name == model.name
104
+ def count(model:)
105
+ query(model).count
106
+ end
112
107
 
113
- stored_data = @store.get(storage_key)
114
- next unless stored_data
108
+ def exists?(model:, **key_params)
109
+ !fetch(model: model, **key_params).nil?
110
+ end
115
111
 
116
- begin
117
- model_instance = @serializer.deserialize(stored_data, model, registration)
112
+ def find_by(model:, **conditions)
113
+ query(model).find_by(**conditions)
114
+ end
118
115
 
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
116
+ def find_by!(model:, **conditions)
117
+ query(model).find_by!(**conditions)
118
+ end
125
119
 
126
- models << model_instance
127
- rescue StandardError => e
128
- @store.emit_event(:deserialization_error, key: storage_key, error: e)
129
- end
120
+ # ── Transactions ──
121
+
122
+ def transaction(&block)
123
+ adapter.transaction(&block)
124
+ end
125
+
126
+ # ── Scopes ──
127
+
128
+ def scope(name, body)
129
+ @scopes[name.to_sym] = body
130
+ end
131
+
132
+ def scope_for(name)
133
+ @scopes[name.to_sym] || raise(ArgumentError, "Unknown scope: #{name}")
134
+ end
135
+
136
+ # ── Query execution (called by Query) ──
137
+
138
+ def execute_query(query)
139
+ registration = @registry.registration_for(query.model_class)
140
+
141
+ if custom_serializer?(registration)
142
+ execute_with_post_filter(query, registration)
143
+ else
144
+ execute_with_pre_filter(query, registration)
130
145
  end
146
+ end
131
147
 
132
- models
148
+ def count_query(query)
149
+ registration = @registry.registration_for(query.model_class)
150
+
151
+ if custom_serializer?(registration)
152
+ execute_with_post_filter(query, registration).size
153
+ else
154
+ count_with_pre_filter(query, registration)
155
+ end
133
156
  end
134
157
 
135
- def exists?(model:, **key_params)
136
- !fetch(model: model, **key_params).nil?
158
+ def fetch_batch(query, after: nil, limit: 1000)
159
+ scan_query = Query.new(self, query.model_class)
160
+ raw_batch = adapter.batch_query(scan_query, after: after, limit: limit)
161
+ if raw_batch
162
+ models = deserialize_results(raw_batch, query.model_class)
163
+ query.predicates.empty? ? models : models.select { |m| query.predicates.all? { |p| p.match?(m) } }
164
+ else
165
+ fallback_batch(query, after: after, limit: limit)
166
+ end
137
167
  end
138
168
 
139
- def count(model:)
140
- all(model: model).size
169
+ def last_storage_key_from(models, model_class)
170
+ return nil if models.empty?
171
+
172
+ registration = @registry.registration_for(model_class)
173
+ last_model = models.last
174
+ key_value = registration.extract_key(last_model)
175
+ registration.generate_storage_key_from_value(key_value).to_s
141
176
  end
142
177
 
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.
178
+ # ── File I/O ──
179
+
145
180
  def load_all(model_class, path: nil, format: :yaml, layout: :separate)
146
181
  fmt = Format.resolve(format)
147
182
  dir = resolve_model_dir(model_class, path)
@@ -161,8 +196,6 @@ module Lutaml
161
196
  end
162
197
  end
163
198
 
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
199
  def import_all(model_class, path: nil, format: :yaml, layout: :separate)
167
200
  models = load_all(model_class, path: path, format: format, layout: layout)
168
201
  models.each { |model| save(model) }
@@ -170,7 +203,6 @@ module Lutaml
170
203
  models
171
204
  end
172
205
 
173
- # Save all models to a directory using format-specific serialization.
174
206
  def save_all(models, path: nil, format: :yaml, layout: :separate)
175
207
  fmt = Format.resolve(format)
176
208
  models_array = Array(models)
@@ -198,7 +230,6 @@ module Lutaml
198
230
  saved
199
231
  end
200
232
 
201
- # Export models to a single file or directory.
202
233
  def export(models, path:, format: :yaml)
203
234
  fmt = Format.resolve(format)
204
235
  models_array = Array(models)
@@ -212,6 +243,8 @@ module Lutaml
212
243
  path
213
244
  end
214
245
 
246
+ # ── Events ──
247
+
215
248
  def on(event, &block)
216
249
  @store.on(event, &block)
217
250
  end
@@ -235,6 +268,226 @@ module Lutaml
235
268
 
236
269
  private
237
270
 
271
+ def adapter
272
+ @store.adapter
273
+ end
274
+
275
+ def custom_serializer?(registration)
276
+ registration&.serializer
277
+ end
278
+
279
+ # Pre-deserialization filtering: predicates evaluated on raw hash data.
280
+ # Used when the serializer stores model fields as direct hash keys.
281
+ def execute_with_pre_filter(query, registration)
282
+ adapter_results = adapter.execute_query(query)
283
+
284
+ if adapter_results
285
+ deserialize_results(adapter_results, query.model_class)
286
+ else
287
+ fallback_execute(query, registration)
288
+ end
289
+ end
290
+
291
+ def count_with_pre_filter(query, registration)
292
+ adapter_count = adapter.count_query(query)
293
+ return adapter_count unless adapter_count.nil?
294
+
295
+ adapter_results = adapter.execute_query(query)
296
+ if adapter_results
297
+ adapter_results.size
298
+ else
299
+ fallback_count(query, registration)
300
+ end
301
+ end
302
+
303
+ # Post-deserialization filtering: scan by class name only, deserialize
304
+ # everything, then filter predicates on model instances.
305
+ # Used when custom serializers store data in non-standard hash format.
306
+ def execute_with_post_filter(query, _registration)
307
+ scan_query = Query.new(self, query.model_class)
308
+ adapter_results = adapter.execute_query(scan_query)
309
+
310
+ raw_results = adapter_results || scan_all_by_class(query.model_class)
311
+
312
+ models = deserialize_results(raw_results, query.model_class)
313
+ results = apply_model_predicates(models, query.predicates)
314
+ results = apply_model_sort(results, query.orders)
315
+ apply_model_pagination(results, query.limit_value, query.offset_value)
316
+ end
317
+
318
+ def scan_all_by_class(model_class)
319
+ results = []
320
+ @store.each_key do |storage_key|
321
+ parsed = StorageKey.parse(storage_key.to_s)
322
+ next unless parsed.class_name == model_class.name
323
+
324
+ stored_data = @store.get(storage_key)
325
+ results << [storage_key, stored_data] if stored_data
326
+ rescue StandardError
327
+ next
328
+ end
329
+ results
330
+ end
331
+
332
+ def apply_model_predicates(models, predicates)
333
+ return models if predicates.empty?
334
+
335
+ models.select { |m| predicates.all? { |p| p.match?(m) } }
336
+ end
337
+
338
+ def apply_model_sort(models, orders)
339
+ return models if orders.empty?
340
+
341
+ models.sort do |a, b|
342
+ orders.reduce(0) do |cmp, o|
343
+ break cmp unless cmp.zero?
344
+
345
+ va = a.public_send(o.field)
346
+ vb = b.public_send(o.field)
347
+
348
+ cmp_val = if va.nil? && vb.nil?
349
+ 0
350
+ elsif va.nil?
351
+ 1
352
+ elsif vb.nil?
353
+ -1
354
+ else
355
+ va <=> vb || 0
356
+ end
357
+
358
+ o.direction == :desc ? -cmp_val : cmp_val
359
+ end
360
+ end
361
+ end
362
+
363
+ def apply_model_pagination(models, limit, offset)
364
+ start = offset || 0
365
+ models = models[start..] || []
366
+ models = models.first(limit) if limit
367
+ models
368
+ end
369
+
370
+ def deserialize_record(hash_data, model_class, registration)
371
+ model_instance = @serializer.deserialize(hash_data, model_class, registration)
372
+
373
+ composite_references = hash_data["_composite_models"]
374
+ if composite_references
375
+ model_instance = @composite_handler.restore_composite_models(
376
+ model_instance, composite_references
377
+ )
378
+ end
379
+
380
+ model_instance
381
+ end
382
+
383
+ def deserialize_results(raw_results, model_class)
384
+ registration = @registry.registration_for(model_class)
385
+
386
+ raw_results.filter_map do |(storage_key, hash_data)|
387
+ deserialize_record(hash_data, model_class, registration)
388
+ rescue StandardError => e
389
+ @store.emit_event(:deserialization_error, key: storage_key, error: e)
390
+ nil
391
+ end
392
+ end
393
+
394
+ # ── Fallback query execution (no adapter query support) ──
395
+
396
+ def fallback_execute(query, _registration)
397
+ results = []
398
+
399
+ @store.each_key do |storage_key|
400
+ parsed = StorageKey.parse(storage_key.to_s)
401
+ next unless parsed.class_name == query.model_class.name
402
+
403
+ stored_data = @store.get(storage_key)
404
+ next unless stored_data
405
+ next unless query.predicates.all? { |p| p.match?(stored_data) }
406
+
407
+ results << [storage_key, stored_data]
408
+ rescue StandardError
409
+ next
410
+ end
411
+
412
+ results = apply_sort(results, query.orders)
413
+ results = apply_pagination(results, query.limit_value, query.offset_value)
414
+ deserialize_results(results, query.model_class)
415
+ end
416
+
417
+ def fallback_count(query, _registration)
418
+ count = 0
419
+
420
+ @store.each_key do |storage_key|
421
+ parsed = StorageKey.parse(storage_key.to_s)
422
+ next unless parsed.class_name == query.model_class.name
423
+
424
+ stored_data = @store.get(storage_key)
425
+ next unless stored_data
426
+ next unless query.predicates.all? { |p| p.match?(stored_data) }
427
+
428
+ count += 1
429
+ rescue StandardError
430
+ next
431
+ end
432
+
433
+ count
434
+ end
435
+
436
+ def fallback_batch(query, after: nil, limit: 1000)
437
+ results = []
438
+
439
+ @store.each_key do |storage_key|
440
+ parsed = StorageKey.parse(storage_key.to_s)
441
+ next unless parsed.class_name == query.model_class.name
442
+ next if after && storage_key.to_s <= after
443
+
444
+ stored_data = @store.get(storage_key)
445
+ next unless stored_data
446
+ next unless query.predicates.all? { |p| p.match?(stored_data) }
447
+
448
+ results << [storage_key, stored_data]
449
+ break if results.size >= limit
450
+ rescue StandardError
451
+ next
452
+ end
453
+
454
+ deserialize_results(results, query.model_class)
455
+ end
456
+
457
+ def apply_sort(results, orders)
458
+ return results if orders.empty?
459
+
460
+ results.sort do |a, b|
461
+ orders.reduce(0) do |cmp, o|
462
+ break cmp unless cmp.zero?
463
+
464
+ va = a.last.is_a?(Hash) ? a.last[o.field.to_s] : nil
465
+ vb = b.last.is_a?(Hash) ? b.last[o.field.to_s] : nil
466
+
467
+ cmp_val = if va.nil? && vb.nil?
468
+ 0
469
+ elsif va.nil?
470
+ 1
471
+ elsif vb.nil?
472
+ -1
473
+ else
474
+ va <=> vb || 0
475
+ end
476
+
477
+ o.direction == :desc ? -cmp_val : cmp_val
478
+ end
479
+ end
480
+ end
481
+
482
+ def apply_pagination(results, limit, offset)
483
+ start = offset || 0
484
+ results = results[start..] || []
485
+ results = results.first(limit) if limit
486
+ results
487
+ end
488
+
489
+ # ── CRUD internals ──
490
+
238
491
  def find_stored_data(registration, model, key_value)
239
492
  if registration.polymorphic?
240
493
  find_polymorphic_data(model, key_value)
@@ -31,6 +31,19 @@ module Lutaml
31
31
  const_get(entry).new
32
32
  end
33
33
 
34
+ # Self-describing content: XML documents start with "<?xml" or "<",
35
+ # JSON with "{" or "["; everything else is YAML, the ecosystem's
36
+ # default. Extensionless records (object-storage keys carry no
37
+ # extension) declare their format this way instead of by guesswork
38
+ # out of band.
39
+ def self.guess(data)
40
+ stripped = data.to_s.lstrip
41
+ return :xml if stripped.start_with?("<?xml", "<")
42
+ return :json if stripped.start_with?("{", "[")
43
+
44
+ :yaml
45
+ end
46
+
34
47
  def self.for_extension(ext)
35
48
  extension_map[ext] || extension_map[".#{ext.to_s.sub(/\A\./, "")}"]
36
49
  end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lutaml/model"
4
+ require "digest"
5
+
6
+ module Lutaml
7
+ module Store
8
+ # The enumeration document of a LutaML data repository: which keys
9
+ # exist, where each one lives relative to the source root, and the
10
+ # sha256 digest to verify it against.
11
+ #
12
+ # Sharding metadata (`shards`, `Entry#shard`) is carried but never
13
+ # interpreted: computing "which shard holds this key" is domain logic
14
+ # (e.g. relaton's crc32 over the pubid root number). The store only
15
+ # enumerates.
16
+ class Manifest
17
+ include Lutaml::Model::Serialize
18
+
19
+ VERSION = 1
20
+
21
+ class Entry
22
+ include Lutaml::Model::Serialize
23
+
24
+ attribute :key, :string
25
+ attribute :location, :string
26
+ attribute :digest, :string
27
+ attribute :shard, :integer
28
+ attribute :metadata, :hash, default: {}
29
+
30
+ def digest_for(body)
31
+ "sha256:#{Digest::SHA256.hexdigest(body)}"
32
+ end
33
+
34
+ def matches?(body)
35
+ return true unless digest
36
+
37
+ digest == digest_for(body)
38
+ end
39
+ end
40
+
41
+ attribute :version, :integer, default: VERSION
42
+ attribute :generated, :string
43
+ attribute :count, :integer, default: 0
44
+ attribute :shards, :integer, default: 0
45
+ attribute :entries, Manifest::Entry, collection: true, initialize_empty: true
46
+
47
+ # @param text [String]
48
+ # @param format [Symbol] :json or :yaml
49
+ # @return [Manifest]
50
+ def self.parse(text, format: :json)
51
+ case format.to_sym
52
+ when :json then from_json(text)
53
+ when :yaml then from_yaml(text)
54
+ else raise ConfigurationError, "unsupported manifest format: #{format}"
55
+ end
56
+ end
57
+
58
+ def self.build(entries, generated: nil, shards: 0, version: VERSION)
59
+ new(
60
+ version: version,
61
+ generated: (generated || Time.now.utc).iso8601,
62
+ count: entries.size,
63
+ shards: shards,
64
+ entries: entries
65
+ )
66
+ end
67
+
68
+ def keys
69
+ entries.map(&:key)
70
+ end
71
+
72
+ def entry_for(key)
73
+ entries.find { |e| e.key == key }
74
+ end
75
+
76
+ def key?(key)
77
+ !entry_for(key).nil?
78
+ end
79
+
80
+ def shard_of_declared?
81
+ shards.positive?
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+ require "fileutils"
5
+ require "json"
6
+
7
+ module Lutaml
8
+ module Store
9
+ # Pulls a source into a local package directory — the GCR-style shape:
10
+ #
11
+ # <into>[/<collection>]/
12
+ # manifest.json
13
+ # entries/<percent-encoded-key>
14
+ #
15
+ # The result is readable by Source::Directory, is the local-cache layout
16
+ # Repository uses, and is byte-compatible with the TypeScript
17
+ # implementation's LocalStore (conformance fixtures pin this).
18
+ #
19
+ # Pulls are incremental and digest-verified: entries whose local sha256
20
+ # already matches are not rewritten; a mismatched local entry is
21
+ # re-fetched. Digest mismatches against the source manifest are hard
22
+ # errors — never silently accepted.
23
+ module Mirror
24
+ class IntegrityError < Error; end
25
+
26
+ class << self
27
+ # @param source [Source::Base]
28
+ # @param into [String] directory to place the package in
29
+ # @param collection [String, nil] optional subdirectory of +into+
30
+ # @param force [Boolean] re-write entries even when digests match
31
+ # @return [Source::Directory] a source over the written package
32
+ def pull(source, into:, collection: nil, force: false)
33
+ manifest = source.manifest
34
+ root = collection ? ::File.join(into, collection) : into
35
+ entries_dir = ::File.join(root, "entries")
36
+ FileUtils.mkdir_p(entries_dir)
37
+
38
+ pulled = 0
39
+ entries = manifest.entries.map do |entry|
40
+ body = source.read(entry.key)
41
+ unless entry.matches?(body)
42
+ raise IntegrityError,
43
+ "digest mismatch for #{entry.key.inspect}: manifest declares " \
44
+ "#{entry.digest}, source returned #{entry.digest_for(body)}"
45
+ end
46
+
47
+ location = entry.location || "entries/#{Source.encode_key(entry.key)}"
48
+ path = ::File.join(root, location)
49
+ write_if_changed(path, body, force)
50
+ pulled += 1
51
+ Manifest::Entry.new(
52
+ key: entry.key, location: location,
53
+ digest: entry.digest_for(body), shard: entry.shard,
54
+ metadata: entry.metadata
55
+ )
56
+ end
57
+
58
+ local = Manifest.build(entries, shards: manifest.shards)
59
+ ::File.write(::File.join(root, "manifest.json"), JSON.pretty_generate(local.to_hash))
60
+
61
+ Source.for(:directory, path: root)
62
+ end
63
+
64
+ # Packs an existing package directory (as written by .pull) into a
65
+ # distributable .zip — the "downloaded official package" artifact.
66
+ # Source::Zip reads it back with no unpacking step.
67
+ #
68
+ # @param package_dir [String] a directory containing manifest.json + entries/
69
+ # @param to [String] the .zip path to write
70
+ # @return [String] the path written
71
+ def pack(package_dir, to:)
72
+ require "zip"
73
+ FileUtils.mkdir_p(::File.dirname(to))
74
+ ::Zip::File.open(to, create: true) do |zip|
75
+ Dir["#{package_dir}/**/*"].sort.each do |path|
76
+ next if ::File.directory?(path)
77
+
78
+ zip.add(path.delete_prefix("#{package_dir}/"), path)
79
+ end
80
+ end
81
+ to
82
+ end
83
+
84
+ private
85
+
86
+ def write_if_changed(path, body, force)
87
+ return if !force && ::File.file?(path) && ::File.binread(path) == body
88
+
89
+ ::File.binwrite(path, body)
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end