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
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Store
5
+ module Source
6
+ # Contract shared by every source.
7
+ #
8
+ # Subclasses implement {#read} (bytes for one key, raising
9
+ # NotFoundError for a definitive miss and BackendError for transport
10
+ # trouble) and {#manifest_raw} (the manifest document). Everything
11
+ # else — keys, existence, typed reads — composes those two.
12
+ class Base
13
+ OPTIONS = [].freeze
14
+
15
+ attr_reader :options
16
+
17
+ def initialize(**options)
18
+ @options = options
19
+ missing = (self.class::OPTIONS - options.keys)
20
+ unless missing.empty?
21
+ raise ConfigurationError,
22
+ "#{self.class.name.split("::").last.downcase} source requires: " \
23
+ "#{missing.map(&:inspect).join(", ")}"
24
+ end
25
+ configure
26
+ end
27
+
28
+ # Bytes of one record. Raises NotFoundError (definitive) or
29
+ # BackendError (transport). Never returns nil.
30
+ #
31
+ # @param key [String]
32
+ # @return [String]
33
+ def read(_key)
34
+ raise NotImplementedError, "#{self.class}#read"
35
+ end
36
+
37
+ # The manifest document as a string.
38
+ #
39
+ # @return [String]
40
+ def manifest_raw
41
+ raise NotImplementedError, "#{self.class}#manifest_raw"
42
+ end
43
+
44
+ # The parsed manifest, memoized.
45
+ #
46
+ # @return [Manifest]
47
+ def manifest
48
+ @manifest ||= Manifest.parse(manifest_raw, format: manifest_format)
49
+ end
50
+
51
+ # All keys declared by the manifest. There is deliberately no
52
+ # fallback listing: an unenumerable source raises ConfigurationError
53
+ # instead of guessing.
54
+ #
55
+ # @return [Array<String>]
56
+ def keys
57
+ manifest.keys
58
+ end
59
+
60
+ # Manifest entry for one key.
61
+ #
62
+ # @param key [String]
63
+ # @return [Manifest::Entry, nil]
64
+ def entry_for(key)
65
+ manifest.entry_for(key)
66
+ end
67
+
68
+ def each_key(&block)
69
+ return to_enum(:each_key) unless block
70
+
71
+ keys.each(&block)
72
+ end
73
+
74
+ # Existence via a full read. A HEAD-based optimization may be
75
+ # provided per source; the default is honest and uniform.
76
+ #
77
+ # @param key [String]
78
+ # @return [Boolean]
79
+ def exist?(key)
80
+ read(key)
81
+ true
82
+ rescue NotFoundError
83
+ false
84
+ end
85
+
86
+ # Deserialized record using the consumer's model class — the model
87
+ # is declared by the caller, never inferred.
88
+ #
89
+ # @param key [String]
90
+ # @param model_class [Class] a Lutaml::Model::Serializable subclass
91
+ # @return the model instance
92
+ def get(key, model_class)
93
+ format_for(key).deserialize(read(key), model_class)
94
+ end
95
+
96
+ # Location of one key relative to the source. Manifest-declared
97
+ # locations win; otherwise the single-segment convention
98
+ # "entries/<percent-encoded key>" applies.
99
+ #
100
+ # @param key [String]
101
+ # @return [String]
102
+ def path_for(key)
103
+ entry = manifest.entry_for(key) if manifestable?
104
+ return entry.location if entry&.location
105
+
106
+ "entries/#{Source.encode_key(key)}"
107
+ end
108
+
109
+ private
110
+
111
+ def manifestable?
112
+ manifest
113
+ true
114
+ rescue NotImplementedError, BackendError, NotFoundError
115
+ false
116
+ end
117
+
118
+ def manifest_format
119
+ @options[:manifest_format] || :json
120
+ end
121
+
122
+ def format_for(key)
123
+ fmt = Format.for_extension(::File.extname(path_for(key).to_s))
124
+ fmt ||= Format.for_extension(::File.extname(key.to_s))
125
+ fmt ||= @options[:default_format] &&
126
+ Format.resolve(@options[:default_format])
127
+ fmt || raise(ConfigurationError,
128
+ "cannot determine the format of #{key.inspect}: pass default_format:")
129
+ end
130
+
131
+ def require_option(name)
132
+ @options[name] || raise(ConfigurationError, "#{name} is required")
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+ require "fileutils"
5
+
6
+ module Lutaml
7
+ module Store
8
+ module Source
9
+ # A local package directory: `manifest.json` + `entries/`. This is the
10
+ # layout {Mirror}#pull writes and what a downloaded official package
11
+ # (GCR-style) ships — one reader for caches, distributions and
12
+ # checkouts.
13
+ class Directory < Base
14
+ OPTIONS = %i[path].freeze
15
+
16
+ attr_reader :package_root
17
+
18
+ private
19
+
20
+ def configure
21
+ @path = require_option(:path)
22
+ @package_root = @path
23
+ # A read-only source never writes - not even directory creation.
24
+ # A missing path simply holds no entries and no manifest.
25
+ end
26
+
27
+ def entry_path(key)
28
+ File.join(@path, path_for(key))
29
+ end
30
+
31
+ public
32
+
33
+ def read(key)
34
+ File.binread(entry_path(key))
35
+ rescue Errno::ENOENT
36
+ raise NotFoundError, "no entry #{key.inspect} in #{@path}"
37
+ rescue SystemCallError => e
38
+ raise BackendError, "cannot read #{key.inspect} in #{@path}: #{e.message}"
39
+ end
40
+
41
+ def manifest_raw
42
+ File.binread(File.join(@path, "manifest.json"))
43
+ rescue Errno::ENOENT
44
+ raise NotFoundError, "no manifest.json in #{@path}"
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "openssl"
6
+ require "uri"
7
+
8
+ module Lutaml
9
+ module Store
10
+ module Source
11
+ # Any static HTTP host — GitHub Pages, raw.githubusercontent, an
12
+ # object-storage bucket. The consumer states the base URL; the source
13
+ # appends one percent-encoded path segment per key.
14
+ #
15
+ # Every read may be wrapped in an explicit `cache:` (an
16
+ # HttpCacheConfig hash) so ETag/304 revalidation is handled by the
17
+ # store's HttpCache. With no cache configured, reads go straight to
18
+ # the network — nothing is implied.
19
+ #
20
+ # `transport:` accepts a callable for tests and alternative HTTP
21
+ # stacks: `->(uri, headers) { {status_code:, headers:, body:} }`.
22
+ class Https < Base
23
+ OPTIONS = %i[base_url].freeze
24
+
25
+ RETRIED_ERRORS = [
26
+ SocketError, Timeout::Error, IOError, SystemCallError,
27
+ OpenSSL::SSL::SSLError, Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError,
28
+ Net::ProtocolError
29
+ ].freeze
30
+
31
+ private
32
+
33
+ def configure
34
+ base = require_option(:base_url)
35
+ @base = URI.parse(base.to_s)
36
+ raise ConfigurationError, "base_url must be http(s)" unless @base.is_a?(URI::HTTP) ||
37
+ @base.is_a?(URI::HTTPS)
38
+
39
+ @headers = @options.fetch(:headers, {})
40
+ @timeout = @options.fetch(:timeout, 60)
41
+ @open_timeout = @options.fetch(:open_timeout, 60)
42
+ @max_redirects = @options.fetch(:max_redirects, 3)
43
+ @transport = @options[:transport]
44
+ @cache = @options[:cache] && HttpCache.new(@options[:cache])
45
+ end
46
+
47
+ def fetch(uri, headers = {}, redirects = @max_redirects)
48
+ response = raw_fetch(uri, headers)
49
+ case response[:status_code]
50
+ when 200..299 then response
51
+ when 301, 302, 307, 308
52
+ raise BackendError, "too many redirects fetching #{uri}" if redirects.zero?
53
+
54
+ fetch(URI.parse(response[:headers]["location"]), headers, redirects - 1)
55
+ when 404 then raise NotFoundError, "no entry at #{uri}"
56
+ else raise BackendError, "HTTP #{response[:status_code]} fetching #{uri}"
57
+ end
58
+ end
59
+
60
+ def raw_fetch(uri, headers)
61
+ if @transport
62
+ begin
63
+ return @transport.call(uri, headers)
64
+ rescue *RETRIED_ERRORS => e
65
+ raise BackendError, "cannot fetch #{uri}: #{e.class}: #{e.message}"
66
+ end
67
+ end
68
+
69
+ if @cache
70
+ return @cache.fetch(:get, uri.to_s, headers) do |h|
71
+ http_get(uri, h)
72
+ end
73
+ end
74
+
75
+ http_get(uri, headers)
76
+ end
77
+
78
+ def http_get(uri, headers)
79
+ http = Net::HTTP.new(uri.host, uri.port)
80
+ http.use_ssl = uri.is_a?(URI::HTTPS)
81
+ http.open_timeout = @open_timeout
82
+ http.read_timeout = @timeout
83
+ begin
84
+ resp = http.get(uri, @headers.merge(headers))
85
+ { status_code: resp.code.to_i, headers: resp.each_header.to_h, body: resp.body }
86
+ rescue *RETRIED_ERRORS => e
87
+ raise BackendError, "cannot fetch #{uri}: #{e.class}: #{e.message}"
88
+ end
89
+ end
90
+
91
+ def url_for(relative_path)
92
+ base = @base.to_s.sub(%r{/+\z}, "")
93
+ path = relative_path.sub(%r{\A/+}, "")
94
+ URI.parse("#{base}/#{path}")
95
+ end
96
+
97
+ public
98
+
99
+ def read(key)
100
+ fetch(url_for(Source.encode_key(key)))[:body]
101
+ end
102
+
103
+ def manifest_raw
104
+ fetch(url_for(@options.fetch(:manifest_path, "manifest.json")))[:body]
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Lutaml
6
+ module Store
7
+ module Source
8
+ # A repository served by the lutaml cloud store API (the contract
9
+ # distilled from api.relaton.org):
10
+ #
11
+ # GET {base}/collections → collection list
12
+ # GET {base}/collections/{c}/manifest → Manifest
13
+ # GET {base}/collections/{c}/entries/{key} → record
14
+ #
15
+ # 404 is a definitive "no such key/collection" (NotFoundError); every
16
+ # other non-2xx is a BackendError. Path shapes are fixed here so
17
+ # server implementers have one contract — not per-client guesswork.
18
+ class Rest < Https
19
+ OPTIONS = %i[base_url collection].freeze
20
+
21
+ private
22
+
23
+ def configure
24
+ super
25
+ @collection = require_option(:collection)
26
+ end
27
+
28
+ def collection_path(suffix)
29
+ "collections/#{Source.encode_key(@collection)}#{suffix}"
30
+ end
31
+
32
+ public
33
+
34
+ def read(key)
35
+ fetch(url_for(collection_path("/entries/#{Source.encode_key(key)}")))[:body]
36
+ end
37
+
38
+ def manifest_raw
39
+ fetch(url_for(collection_path("/manifest")))[:body]
40
+ end
41
+
42
+ # All collections the API publishes.
43
+ #
44
+ # @return [Array<Hash>]
45
+ def collections
46
+ JSON.parse(fetch(url_for("collections"))[:body])["collections"]
47
+ end
48
+
49
+ # The keys of one declared shard, when the collection's manifest
50
+ # advertises sharding. Shard-number semantics belong to the domain;
51
+ # this only fetches the named part.
52
+ #
53
+ # @param number [Integer]
54
+ # @return [Array<String>]
55
+ def shard(number)
56
+ JSON.parse(fetch(url_for(collection_path("/shards/#{Integer(number)}")))[:body])["keys"]
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zip"
4
+
5
+ module Lutaml
6
+ module Store
7
+ module Source
8
+ # A packaged repository read in place from a .zip — the shape of a
9
+ # downloaded official package. Same internal layout as
10
+ # {Directory}: manifest.json + entries/.
11
+ class Zip < Base
12
+ OPTIONS = %i[path].freeze
13
+
14
+ private
15
+
16
+ def configure
17
+ @path = require_option(:path)
18
+ return if File.file?(@path)
19
+
20
+ raise ConfigurationError, "no such package file: #{@path}"
21
+ end
22
+
23
+ def with_zip(&block)
24
+ ::Zip::File.open(@path, &block)
25
+ rescue ::Zip::Error => e
26
+ raise BackendError, "cannot open package #{@path}: #{e.message}"
27
+ end
28
+
29
+ public
30
+
31
+ def read(key)
32
+ inner = path_for(key)
33
+ with_zip do |zip|
34
+ entry = zip.find_entry(inner)
35
+ raise NotFoundError, "no entry #{key.inspect} in #{::File.basename(@path)}" unless entry
36
+
37
+ entry.get_input_stream.read
38
+ end
39
+ end
40
+
41
+ def manifest_raw
42
+ with_zip do |zip|
43
+ entry = zip.find_entry("manifest.json")
44
+ raise NotFoundError, "no manifest.json in #{::File.basename(@path)}" unless entry
45
+
46
+ entry.get_input_stream.read
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Store
5
+ # Read-only views over a LutaML data repository.
6
+ #
7
+ # A source answers four questions about a remote or local collection of
8
+ # LutaML records: does a key exist, what are its bytes, which keys exist,
9
+ # and what does the repository's manifest declare. Sources never write.
10
+ #
11
+ # Selection is explicit: `Source.for(type, options)` raises
12
+ # `ConfigurationError` for a missing option — there is no discovery,
13
+ # no environment guessing and no fallback between sources. Consumers
14
+ # compose sources with {Repository} for caching policies.
15
+ module Source
16
+ autoload :Base, "lutaml/store/source/base"
17
+ autoload :Directory, "lutaml/store/source/directory"
18
+ autoload :Zip, "lutaml/store/source/zip"
19
+ autoload :Https, "lutaml/store/source/https"
20
+ autoload :Rest, "lutaml/store/source/rest"
21
+
22
+ TYPES = {
23
+ directory: "Directory",
24
+ zip: "Zip",
25
+ https: "Https",
26
+ rest: "Rest"
27
+ }.freeze
28
+
29
+ class << self
30
+ # Builds an explicitly configured source.
31
+ #
32
+ # @param type [Symbol] one of {TYPES}
33
+ # @param options [Hash] passed to the source class
34
+ # @return [Source::Base]
35
+ def for(type, **options)
36
+ name = TYPES[type.to_sym]
37
+ raise ConfigurationError, "unknown source type: #{type.inspect}" unless name
38
+
39
+ Source.const_get(name).new(**options)
40
+ end
41
+
42
+ # A key is percent-encoded into exactly one path segment, so storage
43
+ # keys like "RFC 3986" or "ISO/IEC DIR 1" map to one file each on
44
+ # every platform. The manifest keeps the real key.
45
+ def encode_key(key)
46
+ CGI.escape(key.to_s)
47
+ end
48
+
49
+ def decode_key(segment)
50
+ CGI.unescape(segment.to_s)
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Store
5
- VERSION = "0.2.4"
5
+ VERSION = "0.3.1"
6
6
  end
7
7
  end
data/lib/lutaml/store.rb CHANGED
@@ -5,6 +5,7 @@ module Lutaml
5
5
  class Error < StandardError; end
6
6
  class ConfigurationError < Error; end
7
7
  class BackendError < Error; end
8
+ class NotFoundError < Error; end
8
9
  class ModelNotRegisteredError < Error; end
9
10
  class InvalidKeyError < Error; end
10
11
  class PolymorphicUpdateError < Error; end
@@ -39,6 +40,10 @@ module Lutaml
39
40
  autoload :HttpCacheConfig, "lutaml/store/http_cache_config"
40
41
  autoload :HttpCacheEntry, "lutaml/store/http_cache_entry"
41
42
  autoload :HttpHeaderProcessor, "lutaml/store/http_header_processor"
43
+ autoload :Manifest, "lutaml/store/manifest"
44
+ autoload :Mirror, "lutaml/store/mirror"
45
+ autoload :Repository, "lutaml/store/repository"
46
+ autoload :Source, "lutaml/store/source"
42
47
 
43
48
  def self.new(adapter:, models: [], **options)
44
49
  DatabaseStore.new(adapter: adapter, models: models, **options)
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lutaml-store
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.4
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ronald Tse
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-09-17 00:00:00.000000000 Z
11
+ date: 2026-09-29 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: lutaml-model
@@ -61,6 +61,24 @@ files:
61
61
  - CODE_OF_CONDUCT.md
62
62
  - README.adoc
63
63
  - Rakefile
64
+ - docs/astro.config.mjs
65
+ - docs/package-lock.json
66
+ - docs/package.json
67
+ - docs/public/favicon.svg
68
+ - docs/public/lutaml-logo_logo-full-dark.svg
69
+ - docs/public/lutaml-logo_logo-full-light.svg
70
+ - docs/src/layouts/Base.astro
71
+ - docs/src/layouts/Docs.astro
72
+ - docs/src/pages/adapters.md
73
+ - docs/src/pages/architecture.md
74
+ - docs/src/pages/cloud-contract.md
75
+ - docs/src/pages/formats.md
76
+ - docs/src/pages/http-cache.md
77
+ - docs/src/pages/index.md
78
+ - docs/src/pages/quick-start.md
79
+ - docs/src/pages/sources.md
80
+ - docs/src/pages/stores.md
81
+ - docs/src/styles/global.css
64
82
  - lib/lutaml/store.rb
65
83
  - lib/lutaml/store/adapter.rb
66
84
  - lib/lutaml/store/adapter/base.rb
@@ -90,6 +108,8 @@ files:
90
108
  - lib/lutaml/store/http_cache_entry.rb
91
109
  - lib/lutaml/store/http_header_processor.rb
92
110
  - lib/lutaml/store/integrity.rb
111
+ - lib/lutaml/store/manifest.rb
112
+ - lib/lutaml/store/mirror.rb
93
113
  - lib/lutaml/store/model_registration.rb
94
114
  - lib/lutaml/store/model_registry.rb
95
115
  - lib/lutaml/store/model_serializer.rb
@@ -102,6 +122,13 @@ files:
102
122
  - lib/lutaml/store/package_transport/zip_transport.rb
103
123
  - lib/lutaml/store/predicate.rb
104
124
  - lib/lutaml/store/query.rb
125
+ - lib/lutaml/store/repository.rb
126
+ - lib/lutaml/store/source.rb
127
+ - lib/lutaml/store/source/base.rb
128
+ - lib/lutaml/store/source/directory.rb
129
+ - lib/lutaml/store/source/https.rb
130
+ - lib/lutaml/store/source/rest.rb
131
+ - lib/lutaml/store/source/zip.rb
105
132
  - lib/lutaml/store/storage_key.rb
106
133
  - lib/lutaml/store/version.rb
107
134
  - sig/lutaml/store.rbs