lutaml-store 0.2.4 → 0.3.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.
- checksums.yaml +4 -4
- data/CLAUDE.md +6 -0
- data/README.adoc +100 -3
- data/docs/astro.config.mjs +12 -0
- data/docs/package-lock.json +6098 -0
- data/docs/package.json +18 -0
- data/docs/public/favicon.svg +1 -0
- data/docs/public/lutaml-logo_logo-full-dark.svg +1 -0
- data/docs/public/lutaml-logo_logo-full-light.svg +1 -0
- data/docs/src/layouts/Base.astro +44 -0
- data/docs/src/layouts/Docs.astro +41 -0
- data/docs/src/pages/adapters.md +46 -0
- data/docs/src/pages/architecture.md +68 -0
- data/docs/src/pages/cloud-contract.md +48 -0
- data/docs/src/pages/formats.md +54 -0
- data/docs/src/pages/http-cache.md +43 -0
- data/docs/src/pages/index.md +82 -0
- data/docs/src/pages/quick-start.md +109 -0
- data/docs/src/pages/sources.md +83 -0
- data/docs/src/pages/stores.md +110 -0
- data/docs/src/styles/global.css +10 -0
- data/lib/lutaml/store/adapter/base.rb +14 -1
- data/lib/lutaml/store/adapter/filesystem.rb +283 -108
- data/lib/lutaml/store/adapter/memory.rb +16 -1
- data/lib/lutaml/store/adapter/sqlite.rb +26 -1
- data/lib/lutaml/store/basic_store.rb +11 -0
- data/lib/lutaml/store/cache_store.rb +104 -71
- data/lib/lutaml/store/config.rb +13 -1
- data/lib/lutaml/store/format.rb +13 -0
- data/lib/lutaml/store/manifest.rb +85 -0
- data/lib/lutaml/store/mirror.rb +94 -0
- data/lib/lutaml/store/repository.rb +178 -0
- data/lib/lutaml/store/source/base.rb +137 -0
- data/lib/lutaml/store/source/directory.rb +49 -0
- data/lib/lutaml/store/source/https.rb +109 -0
- data/lib/lutaml/store/source/rest.rb +61 -0
- data/lib/lutaml/store/source/zip.rb +52 -0
- data/lib/lutaml/store/source.rb +55 -0
- data/lib/lutaml/store/version.rb +1 -1
- data/lib/lutaml/store.rb +5 -0
- metadata +29 -2
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
+
require "monitor"
|
|
4
5
|
require "time"
|
|
5
6
|
|
|
6
7
|
module Lutaml
|
|
7
8
|
module Store
|
|
8
9
|
# TTL-aware cache store with LRU eviction. Wraps a storage adapter directly.
|
|
10
|
+
#
|
|
11
|
+
# All public operations run under one re-entrant lock, so the LRU and
|
|
12
|
+
# cleanup bookkeeping is safe across threads. Across processes only the
|
|
13
|
+
# adapter's own guarantees apply.
|
|
9
14
|
class CacheStore
|
|
10
15
|
class CacheEntry
|
|
11
16
|
attr_reader :value, :created_at, :ttl, :metadata
|
|
@@ -58,66 +63,75 @@ module Lutaml
|
|
|
58
63
|
@cleanup_interval = config[:cleanup_interval] || 300
|
|
59
64
|
@last_cleanup = Time.now
|
|
60
65
|
@access_times = {}
|
|
66
|
+
@lock = ::Monitor.new
|
|
61
67
|
end
|
|
62
68
|
|
|
63
69
|
def get(key)
|
|
64
|
-
|
|
70
|
+
@lock.synchronize do
|
|
71
|
+
cleanup_if_due
|
|
65
72
|
|
|
66
|
-
|
|
67
|
-
|
|
73
|
+
entry_data = @adapter.get(key)
|
|
74
|
+
next nil unless entry_data
|
|
68
75
|
|
|
69
|
-
|
|
70
|
-
|
|
76
|
+
begin
|
|
77
|
+
entry = deserialize_entry(entry_data)
|
|
78
|
+
|
|
79
|
+
if entry.expired?
|
|
80
|
+
delete(key)
|
|
81
|
+
next nil
|
|
82
|
+
end
|
|
71
83
|
|
|
72
|
-
|
|
84
|
+
@access_times[key] = Time.now
|
|
85
|
+
entry.value
|
|
86
|
+
rescue StandardError
|
|
73
87
|
delete(key)
|
|
74
|
-
|
|
88
|
+
nil
|
|
75
89
|
end
|
|
76
|
-
|
|
77
|
-
@access_times[key] = Time.now
|
|
78
|
-
entry.value
|
|
79
|
-
rescue StandardError
|
|
80
|
-
delete(key)
|
|
81
|
-
nil
|
|
82
90
|
end
|
|
83
91
|
end
|
|
84
92
|
|
|
85
93
|
def set(key, value, ttl: :default, metadata: {})
|
|
86
|
-
|
|
87
|
-
|
|
94
|
+
@lock.synchronize do
|
|
95
|
+
cleanup_if_due
|
|
96
|
+
evict_if_needed
|
|
88
97
|
|
|
89
|
-
|
|
90
|
-
|
|
98
|
+
effective_ttl = ttl == :default ? @default_ttl : ttl
|
|
99
|
+
entry = CacheEntry.new(value, ttl: effective_ttl, metadata: metadata)
|
|
91
100
|
|
|
92
|
-
|
|
93
|
-
|
|
101
|
+
serialized_entry = serialize_entry(entry)
|
|
102
|
+
@adapter.set(key, serialized_entry)
|
|
94
103
|
|
|
95
|
-
|
|
96
|
-
|
|
104
|
+
@access_times[key] = Time.now
|
|
105
|
+
value
|
|
106
|
+
end
|
|
97
107
|
end
|
|
98
108
|
|
|
99
109
|
def delete(key)
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
110
|
+
@lock.synchronize do
|
|
111
|
+
value = nil
|
|
112
|
+
entry_data = @adapter.get(key)
|
|
113
|
+
if entry_data
|
|
114
|
+
begin
|
|
115
|
+
entry = deserialize_entry(entry_data)
|
|
116
|
+
value = entry.value unless entry.expired?
|
|
117
|
+
rescue StandardError
|
|
118
|
+
# If we can't deserialize, treat as nil
|
|
119
|
+
end
|
|
108
120
|
end
|
|
109
|
-
end
|
|
110
121
|
|
|
111
|
-
|
|
122
|
+
@access_times.delete(key)
|
|
112
123
|
|
|
113
|
-
|
|
124
|
+
deleted = @adapter.delete(key)
|
|
114
125
|
|
|
115
|
-
|
|
126
|
+
deleted ? value : nil
|
|
127
|
+
end
|
|
116
128
|
end
|
|
117
129
|
|
|
118
130
|
def clear
|
|
119
|
-
@
|
|
120
|
-
|
|
131
|
+
@lock.synchronize do
|
|
132
|
+
@access_times.clear
|
|
133
|
+
@adapter.clear
|
|
134
|
+
end
|
|
121
135
|
end
|
|
122
136
|
|
|
123
137
|
def exists?(key)
|
|
@@ -135,12 +149,13 @@ module Lutaml
|
|
|
135
149
|
end
|
|
136
150
|
|
|
137
151
|
def keys
|
|
138
|
-
|
|
139
|
-
|
|
152
|
+
@lock.synchronize do
|
|
153
|
+
cleanup_if_due
|
|
154
|
+
@adapter.keys.select { |key| exists?(key) }
|
|
155
|
+
end
|
|
140
156
|
end
|
|
141
157
|
|
|
142
158
|
def size
|
|
143
|
-
cleanup_expired if should_cleanup?
|
|
144
159
|
keys.size
|
|
145
160
|
end
|
|
146
161
|
|
|
@@ -169,22 +184,7 @@ module Lutaml
|
|
|
169
184
|
end
|
|
170
185
|
|
|
171
186
|
def cleanup_expired
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
@adapter.each_key do |key|
|
|
175
|
-
entry_data = @adapter.get(key)
|
|
176
|
-
next unless entry_data
|
|
177
|
-
|
|
178
|
-
entry = deserialize_entry(entry_data)
|
|
179
|
-
expired_keys << key if entry.expired?
|
|
180
|
-
rescue StandardError
|
|
181
|
-
expired_keys << key
|
|
182
|
-
end
|
|
183
|
-
|
|
184
|
-
expired_keys.each { |key| delete(key) }
|
|
185
|
-
@last_cleanup = Time.now
|
|
186
|
-
|
|
187
|
-
expired_keys.size
|
|
187
|
+
@lock.synchronize { cleanup_expired_entries }
|
|
188
188
|
end
|
|
189
189
|
|
|
190
190
|
def cache_info
|
|
@@ -203,23 +203,25 @@ module Lutaml
|
|
|
203
203
|
end
|
|
204
204
|
|
|
205
205
|
def touch(key, ttl: nil)
|
|
206
|
-
|
|
207
|
-
|
|
206
|
+
@lock.synchronize do
|
|
207
|
+
entry_data = @adapter.get(key)
|
|
208
|
+
next false unless entry_data
|
|
208
209
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
210
|
+
begin
|
|
211
|
+
entry = deserialize_entry(entry_data)
|
|
212
|
+
next false if entry.expired?
|
|
212
213
|
|
|
213
|
-
|
|
214
|
-
|
|
214
|
+
new_ttl = ttl || entry.ttl
|
|
215
|
+
new_entry = CacheEntry.new(entry.value, ttl: new_ttl, metadata: entry.metadata)
|
|
215
216
|
|
|
216
|
-
|
|
217
|
-
|
|
217
|
+
serialized_entry = serialize_entry(new_entry)
|
|
218
|
+
@adapter.set(key, serialized_entry)
|
|
218
219
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
220
|
+
@access_times[key] = Time.now
|
|
221
|
+
true
|
|
222
|
+
rescue StandardError
|
|
223
|
+
false
|
|
224
|
+
end
|
|
223
225
|
end
|
|
224
226
|
end
|
|
225
227
|
|
|
@@ -263,14 +265,45 @@ module Lutaml
|
|
|
263
265
|
Time.now - @last_cleanup > @cleanup_interval
|
|
264
266
|
end
|
|
265
267
|
|
|
268
|
+
# Caller holds @lock.
|
|
269
|
+
def cleanup_if_due
|
|
270
|
+
cleanup_expired_entries if should_cleanup?
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Caller holds @lock. @last_cleanup is set first, so a nested call
|
|
274
|
+
# (through #delete) does not start a second scan.
|
|
275
|
+
def cleanup_expired_entries
|
|
276
|
+
@last_cleanup = Time.now
|
|
277
|
+
expired_keys = []
|
|
278
|
+
|
|
279
|
+
@adapter.each_key do |key|
|
|
280
|
+
entry_data = @adapter.get(key)
|
|
281
|
+
next unless entry_data
|
|
282
|
+
|
|
283
|
+
entry = deserialize_entry(entry_data)
|
|
284
|
+
expired_keys << key if entry.expired?
|
|
285
|
+
rescue StandardError
|
|
286
|
+
expired_keys << key
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
expired_keys.each { |key| delete(key) }
|
|
290
|
+
|
|
291
|
+
expired_keys.size
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Caller holds @lock. Keys this process never touched count as the
|
|
295
|
+
# least recently used.
|
|
266
296
|
def evict_if_needed
|
|
267
297
|
return unless @max_size
|
|
268
|
-
return if size < @max_size
|
|
269
298
|
|
|
270
|
-
|
|
271
|
-
|
|
299
|
+
stored_keys = @adapter.keys
|
|
300
|
+
overflow = stored_keys.size - @max_size + 1
|
|
301
|
+
return unless overflow.positive?
|
|
302
|
+
|
|
303
|
+
untouched = stored_keys.reject { |key| @access_times.key?(key) }
|
|
304
|
+
by_access = @access_times.sort_by { |_, time| time }.map(&:first)
|
|
272
305
|
|
|
273
|
-
|
|
306
|
+
(untouched + by_access).first(overflow).each { |key| delete(key) }
|
|
274
307
|
end
|
|
275
308
|
end
|
|
276
309
|
end
|
data/lib/lutaml/store/config.rb
CHANGED
|
@@ -14,7 +14,7 @@ module Lutaml
|
|
|
14
14
|
cache: {}, monitoring: {}, events: {},
|
|
15
15
|
compression: {}, serialization: {}, **)
|
|
16
16
|
@adapter_type = normalize_adapter_type(adapter_type)
|
|
17
|
-
@adapter_options = symbolize_keys(adapter_options)
|
|
17
|
+
@adapter_options = inline_adapter_options(adapter_type).merge(symbolize_keys(adapter_options))
|
|
18
18
|
|
|
19
19
|
cache_config = symbolize_keys(cache)
|
|
20
20
|
@cache_enabled = cache_config.fetch(:enabled, true)
|
|
@@ -134,6 +134,18 @@ module Lutaml
|
|
|
134
134
|
end
|
|
135
135
|
end
|
|
136
136
|
|
|
137
|
+
# Options given inline with the type, as in
|
|
138
|
+
# `adapter: { type: :filesystem, path: "./data" }` or
|
|
139
|
+
# `adapter: { type: :sqlite, options: { path: "a.db" } }`.
|
|
140
|
+
def inline_adapter_options(adapter_type)
|
|
141
|
+
return {} unless adapter_type.is_a?(Hash)
|
|
142
|
+
|
|
143
|
+
inline = symbolize_keys(adapter_type)
|
|
144
|
+
nested = inline.delete(:options) || {}
|
|
145
|
+
inline.delete(:type)
|
|
146
|
+
inline.merge(nested)
|
|
147
|
+
end
|
|
148
|
+
|
|
137
149
|
def validate_adapter_config
|
|
138
150
|
valid_adapters = %i[memory filesystem sqlite]
|
|
139
151
|
unless valid_adapters.include?(@adapter_type)
|
data/lib/lutaml/store/format.rb
CHANGED
|
@@ -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
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi"
|
|
4
|
+
require "digest"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require "json"
|
|
7
|
+
|
|
8
|
+
module Lutaml
|
|
9
|
+
module Store
|
|
10
|
+
# The uniform consumer facade: one read API whether the repository is a
|
|
11
|
+
# local package, a downloaded distribution, a temp-folder cache, or the
|
|
12
|
+
# cloud API. Backends are chosen by explicit composition — the
|
|
13
|
+
# repository never guesses, never falls back, and never hides which
|
|
14
|
+
# layer answered.
|
|
15
|
+
#
|
|
16
|
+
# # cloud, with an explicit local package cache
|
|
17
|
+
# repo = Repository.new(
|
|
18
|
+
# source: Source.for(:rest, base_url: "https://api.relaton.org",
|
|
19
|
+
# collection: "ietf"),
|
|
20
|
+
# cache: Source.for(:directory, path: "~/.cache/relaton/ietf"),
|
|
21
|
+
# )
|
|
22
|
+
#
|
|
23
|
+
# # a downloaded official package (zip) — identical read API
|
|
24
|
+
# repo = Repository.new(source: Source.for(:zip, path: "ietf.zip"))
|
|
25
|
+
#
|
|
26
|
+
# # a local GCR-style checkout
|
|
27
|
+
# repo = Repository.new(source: Source.for(:directory, path: "~/gcr/ietf"))
|
|
28
|
+
#
|
|
29
|
+
# Modes are explicit:
|
|
30
|
+
# :online — read cache first, then the source (default)
|
|
31
|
+
# :offline — never touch the source; absent keys raise NotFoundError
|
|
32
|
+
class Repository
|
|
33
|
+
MODES = %i[online offline].freeze
|
|
34
|
+
|
|
35
|
+
# @param source [Source::Base] the authoritative repository
|
|
36
|
+
# @param cache [Source::Directory, nil] explicit read-through local
|
|
37
|
+
# package (same layout as Mirror#pull writes); nil disables caching
|
|
38
|
+
# @param mode [Symbol] :online or :offline
|
|
39
|
+
def initialize(source:, cache: nil, mode: :online)
|
|
40
|
+
raise ConfigurationError, "mode must be one of #{MODES.inspect}" unless MODES.include?(mode)
|
|
41
|
+
raise ConfigurationError, "offline mode requires a cache source" if mode == :offline && cache.nil?
|
|
42
|
+
|
|
43
|
+
@source = source
|
|
44
|
+
@cache = cache
|
|
45
|
+
@mode = mode
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Read-through convenience constructors — still explicit about every
|
|
49
|
+
# backend, just shorter.
|
|
50
|
+
def self.for_cloud(base_url:, collection:, cache: nil, **source_options)
|
|
51
|
+
new(source: Source.for(:rest, base_url: base_url, collection: collection,
|
|
52
|
+
**source_options), cache: cache)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.for_package(path, type: :directory, **source_options)
|
|
56
|
+
new(source: Source.for(type, path: path, **source_options))
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
attr_reader :source, :cache, :mode
|
|
60
|
+
|
|
61
|
+
# @return [String] the record's bytes
|
|
62
|
+
def read(key)
|
|
63
|
+
if @cache
|
|
64
|
+
begin
|
|
65
|
+
return @cache.read(key)
|
|
66
|
+
rescue NotFoundError
|
|
67
|
+
raise if @mode == :offline
|
|
68
|
+
end
|
|
69
|
+
body = @source.read(key)
|
|
70
|
+
write_cache_entry(key, body)
|
|
71
|
+
return body
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
@source.read(key)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# @return the model instance built by the consumer's model class
|
|
78
|
+
def get(key, model_class)
|
|
79
|
+
format_for(key).deserialize(read(key), model_class)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def exist?(key)
|
|
83
|
+
read(key)
|
|
84
|
+
true
|
|
85
|
+
rescue NotFoundError
|
|
86
|
+
false
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def keys
|
|
90
|
+
@source.keys
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def each_key(&block)
|
|
94
|
+
keys.each(&block)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def manifest
|
|
98
|
+
@source.manifest
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Filters the collection's manifest entries by metadata key/values.
|
|
102
|
+
# The store is field-agnostic: it matches the literal metadata hash —
|
|
103
|
+
# the domain (relaton pubid, Glossarist concepts) narrows semantics.
|
|
104
|
+
#
|
|
105
|
+
# repo.search(doctype: "rfc", stream: "IETF")
|
|
106
|
+
# repo.search(docid: "RFC 7231")
|
|
107
|
+
#
|
|
108
|
+
# @param filter [Hash<String=>String>] ALL key/values must match
|
|
109
|
+
# @return [Array<Manifest::Entry>] matching entries (empty if none)
|
|
110
|
+
def search(**filter)
|
|
111
|
+
return [] if filter.empty?
|
|
112
|
+
|
|
113
|
+
manifest.entries.select do |entry|
|
|
114
|
+
filter.all? do |k, v|
|
|
115
|
+
mv = entry.metadata[k.to_s]
|
|
116
|
+
mv.is_a?(String) ? mv.casecmp?(v.to_s) : mv == v
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Mirror the whole source into a local package (the cache layout).
|
|
122
|
+
# Returns a Repository over the written package.
|
|
123
|
+
def pull!(into:, collection: nil, force: false)
|
|
124
|
+
pulled = Mirror.pull(@source, into: into, collection: collection, force: force)
|
|
125
|
+
Repository.new(source: pulled, mode: @mode)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private
|
|
129
|
+
|
|
130
|
+
# The write-through target is a Directory package (the Mirror layout);
|
|
131
|
+
# anything else is a configuration error, not a duck-type guess. The
|
|
132
|
+
# package manifest is maintained on every write so later reads —
|
|
133
|
+
# including reference resolution and offline mode — see the entry.
|
|
134
|
+
def write_cache_entry(key, body)
|
|
135
|
+
unless @cache.is_a?(Source::Directory)
|
|
136
|
+
raise ConfigurationError,
|
|
137
|
+
"cache must be a Source::Directory package, got #{@cache.class}"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
location = "entries/#{Source.encode_key(key)}"
|
|
141
|
+
FileUtils.mkdir_p(::File.join(@cache.package_root, "entries"))
|
|
142
|
+
::File.binwrite(::File.join(@cache.package_root, location), body)
|
|
143
|
+
|
|
144
|
+
manifest = begin
|
|
145
|
+
@cache.manifest
|
|
146
|
+
rescue NotFoundError
|
|
147
|
+
Lutaml::Store::Manifest.build([])
|
|
148
|
+
end
|
|
149
|
+
# The source manifest's metadata (e.g. the domain docid) rides
|
|
150
|
+
# along — offline reference resolution depends on it.
|
|
151
|
+
declared = begin
|
|
152
|
+
@source.manifest.entry_for(key)
|
|
153
|
+
rescue StandardError
|
|
154
|
+
nil
|
|
155
|
+
end
|
|
156
|
+
entry = Manifest::Entry.new(
|
|
157
|
+
key: key, location: location,
|
|
158
|
+
digest: "sha256:#{Digest::SHA256.hexdigest(body)}",
|
|
159
|
+
metadata: declared&.metadata || {}
|
|
160
|
+
)
|
|
161
|
+
entries = manifest.entries.reject { |e| e.key == key } + [entry]
|
|
162
|
+
updated = Manifest.build(entries, shards: manifest.shards)
|
|
163
|
+
::File.write(
|
|
164
|
+
::File.join(@cache.package_root, "manifest.json"),
|
|
165
|
+
JSON.pretty_generate(updated.to_hash)
|
|
166
|
+
)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def format_for(key)
|
|
170
|
+
fmt = Format.for_extension(::File.extname(key.to_s))
|
|
171
|
+
fmt ||= @source.options[:default_format] &&
|
|
172
|
+
Format.resolve(@source.options[:default_format])
|
|
173
|
+
fmt || raise(ConfigurationError,
|
|
174
|
+
"cannot determine the format of #{key.inspect}: pass default_format:")
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|