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,288 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "digest"
5
+ require "json"
6
+
7
+ module Lutaml
8
+ module Store
9
+ module Adapter
10
+ class FileSystem < Base
11
+ DEFAULT_EXTENSION = ".data"
12
+ METADATA_EXTENSION = ".meta"
13
+
14
+ def initialize(config = {})
15
+ super
16
+ @root_path = @config[:path] || raise(ConfigurationError, "FileSystem adapter requires :path config")
17
+ @extension = @config[:extension] || DEFAULT_EXTENSION
18
+ @create_directories = @config.fetch(:create_directories, true)
19
+ @integrity_enabled = @config.fetch(:integrity_checks, true)
20
+ @integrity_algorithm = @config.fetch(:integrity_algorithm, "sha256")
21
+
22
+ setup_directory_structure
23
+ end
24
+
25
+ def get(key)
26
+ file_path = path_for_key(key)
27
+ return nil unless File.exist?(file_path)
28
+
29
+ data = file_safe_read(file_path)
30
+ metadata = read_metadata(key)
31
+
32
+ begin
33
+ verify_data_integrity(data, metadata)
34
+ rescue Integrity::IntegrityError => e
35
+ repaired_data = repair_corruption(key)
36
+ return repaired_data if repaired_data
37
+
38
+ raise e
39
+ end
40
+
41
+ data
42
+ end
43
+
44
+ def set(key, value)
45
+ file_path = path_for_key(key)
46
+ ensure_directory_exists(File.dirname(file_path))
47
+
48
+ full_metadata = wrap_with_integrity(value)
49
+ file_safe_write(file_path, value)
50
+ write_metadata(key, full_metadata) if full_metadata.any?
51
+ value
52
+ end
53
+
54
+ def delete(key)
55
+ file_path = path_for_key(key)
56
+ metadata_path = metadata_path_for_key(key)
57
+
58
+ return nil unless File.exist?(file_path)
59
+
60
+ value = file_safe_read(file_path)
61
+ File.delete(file_path)
62
+ File.delete(metadata_path) if File.exist?(metadata_path)
63
+
64
+ cleanup_empty_directories(File.dirname(file_path))
65
+
66
+ value
67
+ end
68
+
69
+ def exists?(key)
70
+ File.exist?(path_for_key(key))
71
+ end
72
+
73
+ def keys
74
+ return [] unless Dir.exist?(@root_path)
75
+
76
+ Dir.glob(File.join(@root_path, "**", "*#{@extension}")).filter_map do |file_path|
77
+ key_from_path(file_path) if File.file?(file_path)
78
+ end
79
+ end
80
+
81
+ def all
82
+ result = {}
83
+
84
+ Dir.glob(File.join(@root_path, "**", "*#{@extension}")).each do |file_path|
85
+ next unless File.file?(file_path)
86
+
87
+ key = key_from_path(file_path)
88
+ value = get(key)
89
+ result[key] = value if value
90
+ end
91
+
92
+ result
93
+ end
94
+
95
+ def clear
96
+ return 0 unless Dir.exist?(@root_path)
97
+
98
+ count = 0
99
+ Dir.glob(File.join(@root_path, "**", "*#{@extension}")).each do |file_path|
100
+ if File.file?(file_path)
101
+ File.delete(file_path)
102
+ count += 1
103
+ end
104
+ end
105
+
106
+ Dir.glob(File.join(@root_path, "**", "*#{METADATA_EXTENSION}")).each do |file_path|
107
+ File.delete(file_path) if File.file?(file_path)
108
+ end
109
+
110
+ cleanup_empty_directories(@root_path, preserve_root: true)
111
+
112
+ count
113
+ end
114
+
115
+ def size
116
+ return 0 unless Dir.exist?(@root_path)
117
+
118
+ Dir.glob(File.join(@root_path, "**", "*#{@extension}")).count { |path| File.file?(path) }
119
+ end
120
+
121
+ def close; end
122
+
123
+ def verify_integrity
124
+ corrupted_keys = []
125
+
126
+ keys.each do |key|
127
+ get(key)
128
+ rescue Integrity::IntegrityError
129
+ corrupted_keys << key
130
+ end
131
+
132
+ {
133
+ total_keys: keys.size,
134
+ corrupted_keys: corrupted_keys,
135
+ integrity_ok: corrupted_keys.empty?
136
+ }
137
+ end
138
+
139
+ def repair_corruption(key, backup_data = nil)
140
+ file_path = path_for_key(key)
141
+ return nil unless File.exist?(file_path)
142
+
143
+ corrupted_data = file_safe_read(file_path)
144
+ repaired_data = Integrity.repair_data(corrupted_data, backup_data)
145
+
146
+ if Integrity.valid_data?(repaired_data)
147
+ set(key, repaired_data)
148
+ return repaired_data
149
+ end
150
+
151
+ nil
152
+ end
153
+
154
+ def stats
155
+ super.merge(
156
+ root_path: @root_path,
157
+ extension: @extension,
158
+ disk_usage: calculate_disk_usage
159
+ )
160
+ end
161
+
162
+ private
163
+
164
+ def setup_directory_structure
165
+ return unless @create_directories
166
+
167
+ FileUtils.mkdir_p(@root_path) unless Dir.exist?(@root_path)
168
+ end
169
+
170
+ def path_for_key(key)
171
+ safe_key = sanitize_key(key)
172
+
173
+ if safe_key.length >= 2
174
+ subdir = safe_key[0, 2]
175
+ File.join(@root_path, subdir, "#{safe_key}#{@extension}")
176
+ else
177
+ File.join(@root_path, "#{safe_key}#{@extension}")
178
+ end
179
+ end
180
+
181
+ def metadata_path_for_key(key)
182
+ data_path = path_for_key(key)
183
+ data_path.sub(@extension, METADATA_EXTENSION)
184
+ end
185
+
186
+ def key_from_path(file_path)
187
+ relative_path = file_path.sub(@root_path, "").sub(%r{^/}, "")
188
+ File.basename(relative_path, @extension)
189
+ end
190
+
191
+ def sanitize_key(key)
192
+ key.to_s.gsub(/[^a-zA-Z0-9._-]/, "_")
193
+ end
194
+
195
+ def create_integrity_metadata(data)
196
+ return {} unless @integrity_enabled
197
+
198
+ Integrity.create_integrity_metadata(data, @integrity_algorithm)
199
+ end
200
+
201
+ def verify_data_integrity(data, metadata)
202
+ return true unless @integrity_enabled
203
+ return true unless metadata.is_a?(Hash) && metadata[:integrity]
204
+
205
+ Integrity.verify_integrity_metadata(data, metadata[:integrity])
206
+ end
207
+
208
+ def wrap_with_integrity(data, user_metadata = {})
209
+ metadata = user_metadata.dup
210
+ metadata[:integrity] = create_integrity_metadata(data) if @integrity_enabled
211
+ metadata
212
+ end
213
+
214
+ def write_metadata(key, metadata)
215
+ return unless metadata.any?
216
+
217
+ metadata_path = metadata_path_for_key(key)
218
+ ensure_directory_exists(File.dirname(metadata_path))
219
+
220
+ File.write(metadata_path, JSON.generate(metadata))
221
+ end
222
+
223
+ def read_metadata(key)
224
+ metadata_path = metadata_path_for_key(key)
225
+ return {} unless File.exist?(metadata_path)
226
+
227
+ begin
228
+ JSON.parse(File.read(metadata_path), symbolize_names: true)
229
+ rescue JSON::ParserError
230
+ {}
231
+ end
232
+ end
233
+
234
+ def ensure_directory_exists(dir_path)
235
+ return if Dir.exist?(dir_path)
236
+
237
+ FileUtils.mkdir_p(dir_path)
238
+ end
239
+
240
+ def cleanup_empty_directories(dir_path, preserve_root: false)
241
+ return if preserve_root && dir_path == @root_path
242
+ return unless Dir.exist?(dir_path)
243
+ return unless Dir.empty?(dir_path)
244
+
245
+ Dir.rmdir(dir_path)
246
+
247
+ parent_dir = File.dirname(dir_path)
248
+ cleanup_empty_directories(parent_dir, preserve_root: preserve_root) if parent_dir != dir_path
249
+ end
250
+
251
+ def file_safe_read(file_path)
252
+ File.open(file_path, "rb") do |file|
253
+ file.flock(File::LOCK_SH)
254
+ file.read
255
+ end
256
+ rescue StandardError => e
257
+ raise BackendError, "Failed to read file #{file_path}: #{e.message}"
258
+ end
259
+
260
+ def file_safe_write(file_path, content)
261
+ temp_path = "#{file_path}.tmp.#{Process.pid}"
262
+
263
+ File.open(temp_path, "wb") do |file|
264
+ file.flock(File::LOCK_EX)
265
+ file.write(content)
266
+ file.fsync
267
+ end
268
+
269
+ File.rename(temp_path, file_path)
270
+ rescue StandardError => e
271
+ File.delete(temp_path) if File.exist?(temp_path)
272
+ raise BackendError, "Failed to write file #{file_path}: #{e.message}"
273
+ end
274
+
275
+ def calculate_disk_usage
276
+ return 0 unless Dir.exist?(@root_path)
277
+
278
+ total_size = 0
279
+ Dir.glob(File.join(@root_path, "**", "*#{@extension}")).each do |file_path|
280
+ total_size += File.size(file_path) if File.file?(file_path)
281
+ end
282
+
283
+ total_size
284
+ end
285
+ end
286
+ end
287
+ end
288
+ end
@@ -0,0 +1,225 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Store
5
+ module Adapter
6
+ class Memory < Base
7
+ def initialize(config = {})
8
+ super
9
+ @data = {}
10
+ @mutex = Mutex.new
11
+ @ttl_enabled = @config.fetch(:ttl_enabled, false)
12
+ @ttl_data = {} if @ttl_enabled
13
+ @default_ttl = @config[:default_ttl] || 3600
14
+ end
15
+
16
+ def get(key)
17
+ @mutex.synchronize do
18
+ return nil unless @data.key?(key)
19
+
20
+ if @ttl_enabled && expired?(key)
21
+ @data.delete(key)
22
+ @ttl_data.delete(key)
23
+ return nil
24
+ end
25
+
26
+ @data[key]
27
+ end
28
+ end
29
+
30
+ def set(key, value, metadata = {})
31
+ @mutex.synchronize do
32
+ @data[key] = value
33
+
34
+ if @ttl_enabled
35
+ ttl_value = metadata[:ttl] || @default_ttl
36
+ @ttl_data[key] = Time.now + ttl_value if ttl_value
37
+ end
38
+
39
+ value
40
+ end
41
+ end
42
+
43
+ def delete(key)
44
+ @mutex.synchronize do
45
+ existed = @data.key?(key)
46
+ @data.delete(key)
47
+ @ttl_data.delete(key) if @ttl_enabled
48
+ existed
49
+ end
50
+ end
51
+
52
+ def exists?(key)
53
+ @mutex.synchronize do
54
+ return false unless @data.key?(key)
55
+
56
+ if @ttl_enabled && expired?(key)
57
+ @data.delete(key)
58
+ @ttl_data.delete(key)
59
+ return false
60
+ end
61
+
62
+ true
63
+ end
64
+ end
65
+
66
+ def all
67
+ @mutex.synchronize do
68
+ cleanup_expired if @ttl_enabled
69
+ @data.dup
70
+ end
71
+ end
72
+
73
+ def clear
74
+ @mutex.synchronize do
75
+ count = @data.size
76
+ @data.clear
77
+ @ttl_data.clear if @ttl_enabled
78
+ count
79
+ end
80
+ end
81
+
82
+ def size
83
+ @mutex.synchronize do
84
+ cleanup_expired if @ttl_enabled
85
+ @data.size
86
+ end
87
+ end
88
+
89
+ def keys
90
+ @mutex.synchronize do
91
+ cleanup_expired if @ttl_enabled
92
+ @data.keys
93
+ end
94
+ end
95
+
96
+ def close
97
+ @mutex.synchronize do
98
+ @data.clear
99
+ @ttl_data.clear if @ttl_enabled
100
+ end
101
+ end
102
+
103
+ def stats
104
+ @mutex.synchronize do
105
+ cleanup_expired if @ttl_enabled
106
+ super.merge(
107
+ size: @data.size,
108
+ ttl_enabled: @ttl_enabled,
109
+ expired_keys: @ttl_enabled ? count_expired_keys : 0
110
+ )
111
+ end
112
+ end
113
+
114
+ def cleanup_expired
115
+ return unless @ttl_enabled
116
+
117
+ @mutex.synchronize do
118
+ expired_keys = []
119
+ @ttl_data.each do |key, expiry_time|
120
+ expired_keys << key if Time.now > expiry_time
121
+ end
122
+
123
+ expired_keys.each do |key|
124
+ @data.delete(key)
125
+ @ttl_data.delete(key)
126
+ end
127
+
128
+ expired_keys.size
129
+ end
130
+ end
131
+
132
+ def set_ttl(key, ttl)
133
+ return false unless @ttl_enabled
134
+
135
+ @mutex.synchronize do
136
+ return false unless @data.key?(key)
137
+
138
+ if ttl
139
+ @ttl_data[key] = Time.now + ttl
140
+ else
141
+ @ttl_data.delete(key)
142
+ end
143
+
144
+ true
145
+ end
146
+ end
147
+
148
+ def get_ttl(key)
149
+ return nil unless @ttl_enabled
150
+
151
+ @mutex.synchronize do
152
+ return nil unless @ttl_data.key?(key)
153
+
154
+ expiry_time = @ttl_data[key]
155
+ remaining = expiry_time - Time.now
156
+ remaining.positive? ? remaining : nil
157
+ end
158
+ end
159
+
160
+ def bulk_set(key_value_pairs, ttl: nil)
161
+ @mutex.synchronize do
162
+ key_value_pairs.each do |key, value|
163
+ @data[key] = value
164
+
165
+ if @ttl_enabled
166
+ ttl_value = ttl || @default_ttl
167
+ @ttl_data[key] = Time.now + ttl_value if ttl_value
168
+ end
169
+ end
170
+ end
171
+ end
172
+
173
+ def bulk_get(keys)
174
+ @mutex.synchronize do
175
+ result = {}
176
+ keys.each do |key|
177
+ if @data.key?(key)
178
+ if @ttl_enabled && expired?(key)
179
+ @data.delete(key)
180
+ @ttl_data.delete(key)
181
+ result[key] = nil
182
+ else
183
+ result[key] = @data[key]
184
+ end
185
+ else
186
+ result[key] = nil
187
+ end
188
+ end
189
+ result
190
+ end
191
+ end
192
+
193
+ def bulk_delete(keys)
194
+ @mutex.synchronize do
195
+ result = {}
196
+ keys.each do |key|
197
+ result[key] = @data.delete(key)
198
+ @ttl_data.delete(key) if @ttl_enabled
199
+ end
200
+ result
201
+ end
202
+ end
203
+
204
+ private
205
+
206
+ def expired?(key)
207
+ return false unless @ttl_enabled
208
+ return false unless @ttl_data.key?(key)
209
+
210
+ Time.now > @ttl_data[key]
211
+ end
212
+
213
+ def count_expired_keys
214
+ return 0 unless @ttl_enabled
215
+
216
+ count = 0
217
+ @ttl_data.each_value do |expiry_time|
218
+ count += 1 if Time.now > expiry_time
219
+ end
220
+ count
221
+ end
222
+ end
223
+ end
224
+ end
225
+ end
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Lutaml
6
+ module Store
7
+ module Adapter
8
+ class Sqlite < Base
9
+ DEFAULT_TABLE_NAME = "lutaml_store"
10
+
11
+ def initialize(config = {})
12
+ super
13
+ begin
14
+ require "sqlite3"
15
+ rescue LoadError
16
+ raise ConfigurationError,
17
+ "sqlite3 gem is required for the SQLite adapter. Add it to your Gemfile."
18
+ end
19
+ @db_path = @config[:path] || raise(ConfigurationError, "SQLite adapter requires :path config")
20
+ @table_name = @config[:table_name] || DEFAULT_TABLE_NAME
21
+ @timeout = @config[:timeout] || 30_000
22
+
23
+ setup_database
24
+ end
25
+
26
+ def get(key)
27
+ result = nil
28
+ execute_query("SELECT value FROM #{@table_name} WHERE key = ?", [key]) do |row|
29
+ value = row[0]
30
+ begin
31
+ result = JSON.parse(value)
32
+ rescue JSON::ParserError
33
+ result = value
34
+ end
35
+ end
36
+ result
37
+ end
38
+
39
+ def set(key, value)
40
+ serialized_value = value.is_a?(String) ? value : JSON.generate(value)
41
+
42
+ execute_statement(
43
+ "INSERT OR REPLACE INTO #{@table_name} (key, value, updated_at) VALUES (?, ?, ?)",
44
+ [key, serialized_value, Time.now.to_f]
45
+ )
46
+ value
47
+ end
48
+
49
+ def delete(key)
50
+ value = get(key)
51
+ return nil unless value
52
+
53
+ execute_statement("DELETE FROM #{@table_name} WHERE key = ?", [key])
54
+ value
55
+ end
56
+
57
+ def exists?(key)
58
+ execute_query("SELECT 1 FROM #{@table_name} WHERE key = ? LIMIT 1", [key]) do |_row|
59
+ return true
60
+ end
61
+ false
62
+ end
63
+
64
+ def all
65
+ result = {}
66
+ execute_query("SELECT key, value FROM #{@table_name}") do |row|
67
+ result[row[0]] = row[1]
68
+ end
69
+ result
70
+ end
71
+
72
+ def clear
73
+ count = size
74
+ execute_statement("DELETE FROM #{@table_name}")
75
+ count
76
+ end
77
+
78
+ def size
79
+ execute_query("SELECT COUNT(*) FROM #{@table_name}") do |row|
80
+ return row[0]
81
+ end
82
+ 0
83
+ end
84
+
85
+ def keys
86
+ result = []
87
+ execute_query("SELECT key FROM #{@table_name}") do |row|
88
+ result << row[0]
89
+ end
90
+ result
91
+ end
92
+
93
+ def close
94
+ @db&.close
95
+ @db = nil
96
+ end
97
+
98
+ def stats
99
+ super.merge(
100
+ db_path: @db_path,
101
+ table_name: @table_name,
102
+ database_size: calculate_database_size,
103
+ schema_version: get_schema_version
104
+ )
105
+ end
106
+
107
+ def bulk_set(key_value_pairs)
108
+ @db.transaction do
109
+ key_value_pairs.each do |key, value|
110
+ serialized_value = value.is_a?(String) ? value : JSON.generate(value)
111
+ execute_statement(
112
+ "INSERT OR REPLACE INTO #{@table_name} (key, value, updated_at) VALUES (?, ?, ?)",
113
+ [key, serialized_value, Time.now.to_f]
114
+ )
115
+ end
116
+ end
117
+ end
118
+
119
+ def bulk_delete(keys)
120
+ result = {}
121
+ @db.transaction do
122
+ keys.each do |key|
123
+ value = get(key)
124
+ if value
125
+ execute_statement("DELETE FROM #{@table_name} WHERE key = ?", [key])
126
+ result[key] = value
127
+ else
128
+ result[key] = nil
129
+ end
130
+ end
131
+ end
132
+ result
133
+ end
134
+
135
+ private
136
+
137
+ def setup_database
138
+ @db = SQLite3::Database.new(@db_path)
139
+ @db.busy_timeout = @timeout
140
+
141
+ @db.execute("PRAGMA journal_mode=WAL")
142
+ @db.execute("PRAGMA synchronous=NORMAL")
143
+ @db.execute("PRAGMA cache_size=10000")
144
+ @db.execute("PRAGMA temp_store=memory")
145
+
146
+ create_table_if_not_exists
147
+ create_indexes
148
+ end
149
+
150
+ def create_table_if_not_exists
151
+ @db.execute(<<~SQL)
152
+ CREATE TABLE IF NOT EXISTS #{@table_name} (
153
+ key TEXT PRIMARY KEY,
154
+ value BLOB NOT NULL,
155
+ updated_at REAL NOT NULL DEFAULT (julianday('now'))
156
+ )
157
+ SQL
158
+ end
159
+
160
+ def create_indexes
161
+ @db.execute("CREATE INDEX IF NOT EXISTS idx_#{@table_name}_updated_at ON #{@table_name} (updated_at)")
162
+ end
163
+
164
+ def execute_query(sql, params = [])
165
+ @db.execute(sql, params) do |row|
166
+ yield row if block_given?
167
+ end
168
+ rescue SQLite3::Exception => e
169
+ raise BackendError, "SQLite query failed: #{e.message}"
170
+ end
171
+
172
+ def execute_statement(sql, params = [])
173
+ @db.execute(sql, params)
174
+ rescue SQLite3::Exception => e
175
+ raise BackendError, "SQLite statement failed: #{e.message}"
176
+ end
177
+
178
+ def calculate_database_size
179
+ return 0 unless File.exist?(@db_path)
180
+
181
+ File.size(@db_path)
182
+ end
183
+
184
+ def get_schema_version
185
+ execute_query("PRAGMA user_version") do |row|
186
+ return row[0]
187
+ end
188
+ 0
189
+ end
190
+ end
191
+ end
192
+ end
193
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Store
5
+ module Adapter
6
+ autoload :Base, "lutaml/store/adapter/base"
7
+ autoload :Memory, "lutaml/store/adapter/memory"
8
+ autoload :FileSystem, "lutaml/store/adapter/filesystem"
9
+ autoload :Sqlite, "lutaml/store/adapter/sqlite"
10
+ end
11
+ end
12
+ end