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,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "tmpdir"
5
+
6
+ class ImportTestItem < Lutaml::Model::Serializable
7
+ attribute :name, :string
8
+ attribute :category, :string
9
+
10
+ key_value do
11
+ map :name, to: :name
12
+ map :category, to: :category
13
+ end
14
+ end
15
+
16
+ RSpec.describe "Lutaml::Store import workflow" do
17
+ let(:tmpdir) { Dir.mktmpdir }
18
+ after { FileUtils.rm_rf(tmpdir) }
19
+
20
+ let(:store) do
21
+ Lutaml::Store.new(
22
+ adapter: :memory,
23
+ models: [{ model: ImportTestItem, key: :name, dir: "items" }]
24
+ )
25
+ end
26
+
27
+ let(:items) do
28
+ [
29
+ ImportTestItem.new(name: "alpha", category: "primary"),
30
+ ImportTestItem.new(name: "beta", category: "secondary"),
31
+ ImportTestItem.new(name: "gamma", category: "primary")
32
+ ]
33
+ end
34
+
35
+ describe "#import_all" do
36
+ before do
37
+ store.save_all(items, path: tmpdir, format: :yaml, layout: :separate)
38
+ end
39
+
40
+ it "loads from directory and stores into key-value backend" do
41
+ fresh_store = Lutaml::Store.new(
42
+ adapter: :memory,
43
+ models: [{ model: ImportTestItem, key: :name, dir: "items" }]
44
+ )
45
+
46
+ loaded = fresh_store.import_all(ImportTestItem, path: tmpdir, format: :yaml, layout: :separate)
47
+ expect(loaded.size).to eq(3)
48
+
49
+ # Now queryable via fetch
50
+ fetched = fresh_store.fetch(model: ImportTestItem, name: "alpha")
51
+ expect(fetched).not_to be_nil
52
+ expect(fetched.name).to eq("alpha")
53
+ expect(fetched.category).to eq("primary")
54
+ end
55
+
56
+ it "supports where queries after import" do
57
+ fresh_store = Lutaml::Store.new(
58
+ adapter: :memory,
59
+ models: [{ model: ImportTestItem, key: :name, dir: "items" }]
60
+ )
61
+
62
+ fresh_store.import_all(ImportTestItem, path: tmpdir, format: :yaml, layout: :separate)
63
+
64
+ primary = fresh_store.where(model: ImportTestItem, category: "primary")
65
+ expect(primary.size).to eq(2)
66
+ expect(primary.map(&:name).sort).to eq(%w[alpha gamma])
67
+ end
68
+
69
+ it "supports count after import" do
70
+ fresh_store = Lutaml::Store.new(
71
+ adapter: :memory,
72
+ models: [{ model: ImportTestItem, key: :name, dir: "items" }]
73
+ )
74
+
75
+ fresh_store.import_all(ImportTestItem, path: tmpdir, format: :yaml, layout: :separate)
76
+ expect(fresh_store.count(model: ImportTestItem)).to eq(3)
77
+ end
78
+
79
+ it "supports exists? after import" do
80
+ fresh_store = Lutaml::Store.new(
81
+ adapter: :memory,
82
+ models: [{ model: ImportTestItem, key: :name, dir: "items" }]
83
+ )
84
+
85
+ fresh_store.import_all(ImportTestItem, path: tmpdir, format: :yaml, layout: :separate)
86
+ expect(fresh_store.exists?(model: ImportTestItem, name: "beta")).to be true
87
+ expect(fresh_store.exists?(model: ImportTestItem, name: "missing")).to be false
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe Lutaml::Store::Integrity do
6
+ describe ".calculate_checksum" do
7
+ let(:test_data) { "Hello, World!" }
8
+
9
+ it "calculates SHA256 checksum by default" do
10
+ checksum = described_class.calculate_checksum(test_data)
11
+ expect(checksum).to eq(Digest::SHA256.hexdigest(test_data))
12
+ end
13
+
14
+ it "calculates MD5 checksum" do
15
+ checksum = described_class.calculate_checksum(test_data, "md5")
16
+ expect(checksum).to eq(Digest::MD5.hexdigest(test_data))
17
+ end
18
+
19
+ it "calculates SHA1 checksum" do
20
+ checksum = described_class.calculate_checksum(test_data, "sha1")
21
+ expect(checksum).to eq(Digest::SHA1.hexdigest(test_data))
22
+ end
23
+
24
+ it "raises error for unsupported algorithm" do
25
+ expect do
26
+ described_class.calculate_checksum(test_data, "unsupported")
27
+ end.to raise_error(ArgumentError, /Unsupported checksum algorithm/)
28
+ end
29
+ end
30
+
31
+ describe ".verify_checksum" do
32
+ let(:test_data) { "Hello, World!" }
33
+ let(:correct_checksum) { Digest::SHA256.hexdigest(test_data) }
34
+ let(:incorrect_checksum) { "incorrect" }
35
+
36
+ it "returns true for correct checksum" do
37
+ expect(described_class.verify_checksum(test_data, correct_checksum)).to be true
38
+ end
39
+
40
+ it "raises ChecksumMismatchError for incorrect checksum" do
41
+ expect do
42
+ described_class.verify_checksum(test_data, incorrect_checksum)
43
+ end.to raise_error(Lutaml::Store::Integrity::ChecksumMismatchError)
44
+ end
45
+ end
46
+
47
+ describe ".create_integrity_metadata" do
48
+ let(:test_data) { "Hello, World!" }
49
+
50
+ it "creates integrity metadata with checksum and size" do
51
+ metadata = described_class.create_integrity_metadata(test_data)
52
+
53
+ expect(metadata).to have_key(:checksum)
54
+ expect(metadata).to have_key(:algorithm)
55
+ expect(metadata).to have_key(:size)
56
+ expect(metadata).to have_key(:created_at)
57
+ expect(metadata).to have_key(:version)
58
+
59
+ expect(metadata[:checksum]).to eq(Digest::SHA256.hexdigest(test_data))
60
+ expect(metadata[:algorithm]).to eq("sha256")
61
+ expect(metadata[:size]).to eq(test_data.bytesize)
62
+ expect(metadata[:version]).to eq("1.0")
63
+ end
64
+
65
+ it "uses specified algorithm" do
66
+ metadata = described_class.create_integrity_metadata(test_data, "md5")
67
+
68
+ expect(metadata[:checksum]).to eq(Digest::MD5.hexdigest(test_data))
69
+ expect(metadata[:algorithm]).to eq("md5")
70
+ end
71
+ end
72
+
73
+ describe ".verify_integrity_metadata" do
74
+ let(:test_data) { "Hello, World!" }
75
+ let(:valid_metadata) do
76
+ {
77
+ checksum: Digest::SHA256.hexdigest(test_data),
78
+ algorithm: "sha256",
79
+ size: test_data.bytesize
80
+ }
81
+ end
82
+
83
+ it "returns true for valid metadata" do
84
+ expect(described_class.verify_integrity_metadata(test_data, valid_metadata)).to be true
85
+ end
86
+
87
+ it "raises CorruptionError for size mismatch" do
88
+ invalid_metadata = valid_metadata.merge(size: 999)
89
+
90
+ expect do
91
+ described_class.verify_integrity_metadata(test_data, invalid_metadata)
92
+ end.to raise_error(Lutaml::Store::Integrity::CorruptionError, /Size mismatch/)
93
+ end
94
+
95
+ it "raises ChecksumMismatchError for checksum mismatch" do
96
+ invalid_metadata = valid_metadata.merge(checksum: "invalid")
97
+
98
+ expect do
99
+ described_class.verify_integrity_metadata(test_data, invalid_metadata)
100
+ end.to raise_error(Lutaml::Store::Integrity::ChecksumMismatchError)
101
+ end
102
+
103
+ it "returns true when no integrity metadata is present" do
104
+ expect(described_class.verify_integrity_metadata(test_data, {})).to be true
105
+ end
106
+ end
107
+
108
+ describe ".repair_data" do
109
+ let(:corrupted_data) { "Hello, Wor\x00ld!" }
110
+ let(:backup_data) { "Hello, World!" }
111
+
112
+ it "returns backup data if available and valid" do
113
+ repaired = described_class.repair_data(corrupted_data, backup_data)
114
+ expect(repaired).to eq(backup_data)
115
+ end
116
+
117
+ it "attempts to clean corrupted data" do
118
+ repaired = described_class.repair_data(corrupted_data)
119
+ expect(repaired).to eq("Hello, World!")
120
+ end
121
+
122
+ it "fixes truncated JSON" do
123
+ corrupted_json = '{"key": "value"'
124
+ repaired = described_class.repair_data(corrupted_json)
125
+ expect(repaired).to eq('{"key": "value"}')
126
+ end
127
+
128
+ it "fixes truncated arrays" do
129
+ corrupted_array = '["item1", "item2"'
130
+ repaired = described_class.repair_data(corrupted_array)
131
+ expect(repaired).to eq('["item1", "item2"]')
132
+ end
133
+ end
134
+
135
+ describe ".valid_data?" do
136
+ it "returns true for valid UTF-8 string" do
137
+ expect(described_class.valid_data?("Hello, World!")).to be true
138
+ end
139
+
140
+ it "returns false for nil data" do
141
+ expect(described_class.valid_data?(nil)).to be false
142
+ end
143
+
144
+ it "returns false for empty data" do
145
+ expect(described_class.valid_data?("")).to be false
146
+ end
147
+
148
+ it "returns false for data with null bytes" do
149
+ expect(described_class.valid_data?("Hello\x00World")).to be false
150
+ end
151
+
152
+ it "returns true for binary data" do
153
+ binary_data = "\xFF\xFE\xFD".dup.force_encoding("ASCII-8BIT")
154
+ expect(described_class.valid_data?(binary_data)).to be true
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # Model with a key collision: both id and code serialize to the same "id" key.
6
+ # Demonstrates the custom serializer pattern for preserving all fields.
7
+ class CollisionItem < Lutaml::Model::Serializable
8
+ attribute :id, :string
9
+ attribute :code, :string
10
+ attribute :label, :string
11
+
12
+ key_value do
13
+ map :id, to: :id
14
+ map :code, with: { to: :code_to_yaml, from: :code_from_yaml }
15
+ map :label, to: :label
16
+ end
17
+
18
+ def code_to_yaml(model, doc)
19
+ doc["id"] = model.code if model.code
20
+ end
21
+
22
+ def code_from_yaml(model, value)
23
+ model.code = value if value
24
+ end
25
+ end
26
+
27
+ class CollisionSerializer < Lutaml::Store::ModelSerializer
28
+ def serialize(model, _registration = nil)
29
+ {
30
+ "_yaml" => model.to_yaml,
31
+ "_id" => model.id,
32
+ "_code" => model.code
33
+ }
34
+ end
35
+
36
+ def deserialize(data, expected_class, _registration = nil)
37
+ model = expected_class.from_yaml(data["_yaml"])
38
+ model.id = data["_id"]
39
+ model.code = data["_code"]
40
+ model
41
+ end
42
+ end
43
+
44
+ RSpec.describe "Custom serializer with key collision workaround" do
45
+ let(:store) do
46
+ Lutaml::Store.new(
47
+ adapter: :memory,
48
+ models: [{
49
+ model: CollisionItem,
50
+ key: :id,
51
+ serializer: CollisionSerializer.new
52
+ }]
53
+ )
54
+ end
55
+
56
+ it "preserves both id and code through save/fetch" do
57
+ item = CollisionItem.new(id: "item-1", code: "CODE-1", label: "First item")
58
+ store.save(item)
59
+
60
+ fetched = store.fetch(model: CollisionItem, id: "item-1")
61
+ expect(fetched.id).to eq("item-1")
62
+ expect(fetched.code).to eq("CODE-1")
63
+ expect(fetched.label).to eq("First item")
64
+ end
65
+
66
+ it "preserves fields through update" do
67
+ item = CollisionItem.new(id: "item-2", code: "CODE-2", label: "Original")
68
+ store.save(item)
69
+
70
+ updated = store.update(model: CollisionItem, id: "item-2", attributes: { label: "Updated" })
71
+ expect(updated.id).to eq("item-2")
72
+ expect(updated.code).to eq("CODE-2")
73
+ expect(updated.label).to eq("Updated")
74
+ end
75
+
76
+ it "preserves fields through where query" do
77
+ store.save(CollisionItem.new(id: "a1", code: "C1", label: "One"))
78
+ store.save(CollisionItem.new(id: "a2", code: "C2", label: "Two"))
79
+
80
+ found = store.where(model: CollisionItem, code: "C1")
81
+ expect(found.size).to eq(1)
82
+ expect(found.first.id).to eq("a1")
83
+ expect(found.first.code).to eq("C1")
84
+ end
85
+
86
+ it "lists all items with correct fields" do
87
+ 3.times do |i|
88
+ store.save(CollisionItem.new(id: "x-#{i}", code: "C-#{i}", label: "Item #{i}"))
89
+ end
90
+
91
+ all = store.all(model: CollisionItem)
92
+ expect(all.size).to eq(3)
93
+ all.each do |item|
94
+ expect(item.id).to start_with("x-")
95
+ expect(item.code).to start_with("C-")
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "tmpdir"
5
+
6
+ # Test model using lutaml-model
7
+ class TestItem < Lutaml::Model::Serializable
8
+ attribute :name, :string
9
+ attribute :value, :string
10
+
11
+ key_value do
12
+ map :name, to: :name
13
+ map :value, to: :value
14
+ end
15
+ end
16
+
17
+ RSpec.describe "Lutaml::Store load_all / save_all" do
18
+ let(:tmpdir) { Dir.mktmpdir }
19
+ after { FileUtils.rm_rf(tmpdir) }
20
+
21
+ let(:store) do
22
+ Lutaml::Store.new(
23
+ adapter: :memory,
24
+ models: [{ model: TestItem, key: :name, dir: "items" }]
25
+ )
26
+ end
27
+
28
+ let(:items) do
29
+ [
30
+ TestItem.new(name: "alpha", value: "first"),
31
+ TestItem.new(name: "beta", value: "second"),
32
+ TestItem.new(name: "gamma", value: "third")
33
+ ]
34
+ end
35
+
36
+ describe "#save_all with :yaml format" do
37
+ it "writes YAML files to the model directory" do
38
+ store.save_all(items, path: tmpdir, format: :yaml, layout: :separate)
39
+
40
+ items_dir = File.join(tmpdir, "items")
41
+ expect(Dir.exist?(items_dir)).to be true
42
+
43
+ files = Dir.glob(File.join(items_dir, "*.yaml")).sort
44
+ expect(files.size).to eq(3)
45
+
46
+ content = File.read(files.first, encoding: "utf-8")
47
+ expect(content).to include("name:")
48
+ expect(content).to include("value:")
49
+ end
50
+ end
51
+
52
+ describe "#load_all with :yaml format" do
53
+ it "round-trips models through YAML files" do
54
+ store.save_all(items, path: tmpdir, format: :yaml, layout: :separate)
55
+
56
+ loaded = store.load_all(TestItem, path: tmpdir, format: :yaml, layout: :separate)
57
+
58
+ expect(loaded.size).to eq(3)
59
+ names = loaded.map(&:name).sort
60
+ expect(names).to eq(%w[alpha beta gamma])
61
+ values = loaded.map(&:value).sort
62
+ expect(values).to eq(%w[first second third])
63
+ end
64
+ end
65
+
66
+ describe "#save_all with :grouped layout" do
67
+ it "writes one file per key" do
68
+ store.save_all(items, path: tmpdir, format: :yaml, layout: :grouped)
69
+
70
+ items_dir = File.join(tmpdir, "items")
71
+ files = Dir.glob(File.join(items_dir, "*.yaml")).sort
72
+ expect(files.size).to eq(3)
73
+ end
74
+ end
75
+
76
+ describe "#save_all with :json format" do
77
+ it "writes JSON files" do
78
+ store.save_all(items, path: tmpdir, format: :json, layout: :separate)
79
+
80
+ items_dir = File.join(tmpdir, "items")
81
+ files = Dir.glob(File.join(items_dir, "*.json")).sort
82
+ expect(files.size).to eq(3)
83
+
84
+ content = File.read(files.first, encoding: "utf-8")
85
+ expect(content).to include('"name"')
86
+ end
87
+ end
88
+
89
+ describe "#export" do
90
+ it "exports all models to a single file" do
91
+ export_path = File.join(tmpdir, "export.jsonl")
92
+ store.export(items, path: export_path, format: :jsonl)
93
+
94
+ expect(File.exist?(export_path)).to be true
95
+ content = File.read(export_path, encoding: "utf-8")
96
+ lines = content.lines.reject(&:empty?)
97
+ expect(lines.size).to eq(3)
98
+ end
99
+ end
100
+
101
+ describe "empty model list" do
102
+ it "returns empty array for save_all" do
103
+ result = store.save_all([], path: tmpdir, format: :yaml, layout: :separate)
104
+ expect(result).to eq([])
105
+ end
106
+ end
107
+ end