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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/CLAUDE.md +6 -0
  3. data/README.adoc +100 -3
  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 +14 -1
  23. data/lib/lutaml/store/adapter/filesystem.rb +283 -108
  24. data/lib/lutaml/store/adapter/memory.rb +16 -1
  25. data/lib/lutaml/store/adapter/sqlite.rb +26 -1
  26. data/lib/lutaml/store/basic_store.rb +11 -0
  27. data/lib/lutaml/store/cache_store.rb +104 -71
  28. data/lib/lutaml/store/config.rb +13 -1
  29. data/lib/lutaml/store/format.rb +13 -0
  30. data/lib/lutaml/store/manifest.rb +85 -0
  31. data/lib/lutaml/store/mirror.rb +94 -0
  32. data/lib/lutaml/store/repository.rb +178 -0
  33. data/lib/lutaml/store/source/base.rb +137 -0
  34. data/lib/lutaml/store/source/directory.rb +49 -0
  35. data/lib/lutaml/store/source/https.rb +109 -0
  36. data/lib/lutaml/store/source/rest.rb +61 -0
  37. data/lib/lutaml/store/source/zip.rb +52 -0
  38. data/lib/lutaml/store/source.rb +55 -0
  39. data/lib/lutaml/store/version.rb +1 -1
  40. data/lib/lutaml/store.rb +5 -0
  41. 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
- cleanup_expired if should_cleanup?
70
+ @lock.synchronize do
71
+ cleanup_if_due
65
72
 
66
- entry_data = @adapter.get(key)
67
- return nil unless entry_data
73
+ entry_data = @adapter.get(key)
74
+ next nil unless entry_data
68
75
 
69
- begin
70
- entry = deserialize_entry(entry_data)
76
+ begin
77
+ entry = deserialize_entry(entry_data)
78
+
79
+ if entry.expired?
80
+ delete(key)
81
+ next nil
82
+ end
71
83
 
72
- if entry.expired?
84
+ @access_times[key] = Time.now
85
+ entry.value
86
+ rescue StandardError
73
87
  delete(key)
74
- return nil
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
- cleanup_expired if should_cleanup?
87
- evict_if_needed
94
+ @lock.synchronize do
95
+ cleanup_if_due
96
+ evict_if_needed
88
97
 
89
- effective_ttl = ttl == :default ? @default_ttl : ttl
90
- entry = CacheEntry.new(value, ttl: effective_ttl, metadata: metadata)
98
+ effective_ttl = ttl == :default ? @default_ttl : ttl
99
+ entry = CacheEntry.new(value, ttl: effective_ttl, metadata: metadata)
91
100
 
92
- serialized_entry = serialize_entry(entry)
93
- @adapter.set(key, serialized_entry)
101
+ serialized_entry = serialize_entry(entry)
102
+ @adapter.set(key, serialized_entry)
94
103
 
95
- @access_times[key] = Time.now
96
- value
104
+ @access_times[key] = Time.now
105
+ value
106
+ end
97
107
  end
98
108
 
99
109
  def delete(key)
100
- value = nil
101
- entry_data = @adapter.get(key)
102
- if entry_data
103
- begin
104
- entry = deserialize_entry(entry_data)
105
- value = entry.value unless entry.expired?
106
- rescue StandardError
107
- # If we can't deserialize, treat as nil
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
- @access_times.delete(key)
122
+ @access_times.delete(key)
112
123
 
113
- deleted = @adapter.delete(key)
124
+ deleted = @adapter.delete(key)
114
125
 
115
- deleted ? value : nil
126
+ deleted ? value : nil
127
+ end
116
128
  end
117
129
 
118
130
  def clear
119
- @access_times.clear
120
- @adapter.clear
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
- cleanup_expired if should_cleanup?
139
- @adapter.keys.select { |key| exists?(key) }
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
- expired_keys = []
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
- entry_data = @adapter.get(key)
207
- return false unless entry_data
206
+ @lock.synchronize do
207
+ entry_data = @adapter.get(key)
208
+ next false unless entry_data
208
209
 
209
- begin
210
- entry = deserialize_entry(entry_data)
211
- return false if entry.expired?
210
+ begin
211
+ entry = deserialize_entry(entry_data)
212
+ next false if entry.expired?
212
213
 
213
- new_ttl = ttl || entry.ttl
214
- new_entry = CacheEntry.new(entry.value, ttl: new_ttl, metadata: entry.metadata)
214
+ new_ttl = ttl || entry.ttl
215
+ new_entry = CacheEntry.new(entry.value, ttl: new_ttl, metadata: entry.metadata)
215
216
 
216
- serialized_entry = serialize_entry(new_entry)
217
- @adapter.set(key, serialized_entry)
217
+ serialized_entry = serialize_entry(new_entry)
218
+ @adapter.set(key, serialized_entry)
218
219
 
219
- @access_times[key] = Time.now
220
- true
221
- rescue StandardError
222
- false
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
- keys_by_access = @access_times.sort_by { |_, time| time }.map(&:first)
271
- keys_to_evict = keys_by_access.first(size - @max_size + 1)
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
- keys_to_evict.each { |key| delete(key) }
306
+ (untouched + by_access).first(overflow).each { |key| delete(key) }
274
307
  end
275
308
  end
276
309
  end
@@ -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)
@@ -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