lutaml-store 0.2.4 → 0.3.0

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.
@@ -0,0 +1,48 @@
1
+ ---
2
+ layout: ../layouts/Docs.astro
3
+ ---
4
+
5
+ # Cloud Store Contract
6
+
7
+ The lutaml cloud store API contract — distilled from api.relaton.org and generalized. Any service implementing it is a lutaml cloud store.
8
+
9
+ ## The contract
10
+
11
+ ```
12
+ GET {base}/collections → { collections: [...] }
13
+ GET {base}/collections/{c}/manifest → Manifest (JSON)
14
+ GET {base}/collections/{c}/entries/{key} → record bytes (ETag; 404 = not-found)
15
+ GET {base}/collections/{c}/shards/{n} → { keys: [...] } (optional)
16
+ ```
17
+
18
+ ## Rules
19
+
20
+ 1. **Keys are URL-safe storage keys** — a single path segment with no raw `/`. Domain identifiers that contain slashes (docids like `ISO/IEC DIR 1`) travel in `entries[].metadata.docid`, and clients resolve reference → storage key through the manifest.
21
+
22
+ 2. **404 is an answer, not an error** — clients raise `NotFoundError` (Ruby) or return `{ ok: false, reason: "not_found" }` (TS). Only 5xx / transport failures are retryable.
23
+
24
+ 3. **Manifests are immutable per generation** — `version`, `generated`, `count` move together; shards are derived from the same generation.
25
+
26
+ 4. **Sharding semantics are domain-owned** — the store/source only fetches named parts; the domain computes which shard (relaton: crc32 of pubid root number).
27
+
28
+ ## The Manifest schema
29
+
30
+ ```json
31
+ {
32
+ "version": 1,
33
+ "generated": "2026-09-27T00:00:00Z",
34
+ "count": 178681,
35
+ "shards": 256,
36
+ "entries": [
37
+ { "key": "rfc 7231", "digest": "sha256:...", "shard": 12, "metadata": { "docid": "RFC 7231" } }
38
+ ]
39
+ }
40
+ ```
41
+
42
+ ## Reference implementation
43
+
44
+ api.relaton.org serves 30 collections (flavors) with 178k+ records:
45
+
46
+ - **Browsers** get server-rendered HTML: collections index, searchable record table, framed record pages
47
+ - **API clients** get JSON manifests and raw records from the same URLs
48
+ - **Content negotiation** on the `Accept` header — no separate site
@@ -0,0 +1,54 @@
1
+ ---
2
+ layout: ../layouts/Docs.astro
3
+ title: Formats
4
+ ---
5
+
6
+ # Formats
7
+
8
+ A **Format** handler defines how model entries serialize to and from files. All handlers inherit from `Format::Base` and are registered in `Format::FORMATS`, resolved via `Format.resolve(:symbol)`. Registering a new format never touches existing code.
9
+
10
+ | Symbol | Format | Multi-doc | Extension | Binary |
11
+ |---|---|---|---|---|
12
+ | `:yaml` | Single YAML document | no | `.yaml` | no |
13
+ | `:yamls` | YAML stream (multi-document) | yes | `.yaml` | no |
14
+ | `:json` | Single JSON document | no | `.json` | no |
15
+ | `:jsonl` | JSON Lines (one object per line) | yes | `.jsonl` | no |
16
+ | `:marshal` | Ruby Marshal | no | `.marshal` | yes |
17
+ | `:xml` | XML | yes | `.xml` | no |
18
+
19
+ ## Handler interface
20
+
21
+ ```ruby
22
+ class MyFormat < Lutaml::Store::Format::Base
23
+ def serialize(models) = ...
24
+ def deserialize(data) = ...
25
+ def serialize_many(models) = ... # multi-doc formats only
26
+ def deserialize_many(data) = ... # multi-doc formats only
27
+ def extension = ".myfmt"
28
+ def glob_pattern = "*.myfmt"
29
+ def binary? = false
30
+ end
31
+
32
+ Lutaml::Store::Format.register(:myfmt, MyFormat)
33
+ ```
34
+
35
+ ## Choosing a format
36
+
37
+ - **`:yamls`** — human-editable multi-record files (the Glossarist pattern): one file per concept collection, streamed documents.
38
+ - **`:jsonl`** — append-heavy, line-oriented processing; one model per line.
39
+ - **`:marshal`** — fastest round-trip, Ruby-only, opaque to humans.
40
+ - **`:xml`** — interchange with XML-based pipelines; round-trips LutaML Model XML mappings.
41
+ - **`:json` / `:yaml`** — single-model files, configuration-style data.
42
+
43
+ ## Usage with DatabaseStore
44
+
45
+ Pass `format:` per model registration:
46
+
47
+ ```ruby
48
+ store = Lutaml::Store.new(
49
+ adapter: { type: :filesystem, location: "./glossary" },
50
+ models: [{ model: GlossaryTerm, key: :id, format: :yamls }]
51
+ )
52
+ ```
53
+
54
+ `FormatSerializer` bridges the handler into the store's serializer interface, so CRUD operations transparently read and write the chosen format on disk.
@@ -0,0 +1,43 @@
1
+ ---
2
+ layout: ../layouts/Docs.astro
3
+ title: HTTP Caching
4
+ ---
5
+
6
+ # HTTP Caching
7
+
8
+ `Lutaml::Store::HttpCache` provides HTTP-aware caching for network-backed sources, used by the `:https` and `:rest` sources. It is model-driven: cache state serializes through `to_json`/`from_json` on LutaML Model classes, not hand-rolled hashes.
9
+
10
+ ## What it supports
11
+
12
+ - **ETags** — stored and re-sent as `If-None-Match`
13
+ - **Conditional requests** — a `304 Not Modified` response resolves to the cached body with zero transfer
14
+ - **Cache-Control** — `max-age`, `no-cache`, `no-store` directives are honored
15
+ - **Vary** — responses varied by header are cached separately
16
+
17
+ ## Enabling on the HTTPS source
18
+
19
+ ```ruby
20
+ src = Lutaml::Store::Source.for(:https,
21
+ base_url: "https://data.example.com/index/",
22
+ cache: { path: "./cache/http" } # HttpCacheConfig hash
23
+ )
24
+
25
+ src.read("some-key") # first call: 200, cached
26
+ src.read("some-key") # revalidates with ETag; 304 → cached bytes
27
+ ```
28
+
29
+ ## Revalidation semantics
30
+
31
+ 1. **Fresh window** (`max-age` not elapsed): served from cache without a request.
32
+ 2. **Stale**: the source sends `If-None-Match`; on `304` the cached body is reused and freshness is extended.
33
+ 3. **`no-store` / missing validators**: the response is returned but not cached.
34
+
35
+ ## Transport injection
36
+
37
+ The `:https` source accepts an injected `transport:` callable for specs and alternate HTTP stacks, so cache behavior is fully testable without the network:
38
+
39
+ ```ruby
40
+ transport = ->(uri, headers) { fake_response }
41
+ src = Lutaml::Store::Source.for(:https,
42
+ base_url: "https://example.com/", transport: transport, cache: { path: tmpdir })
43
+ ```
@@ -0,0 +1,82 @@
1
+ ---
2
+ layout: ../layouts/Docs.astro
3
+ ---
4
+
5
+ # Lutaml::Store
6
+
7
+ Store-centric database-style API for [Lutaml::Model](https://github.com/lutaml/lutaml-model) objects, with model registry, polymorphic support, composite relationships, and multiple storage backends.
8
+
9
+ It also provides **read-only Sources** for working with data repositories that live outside your process — GitHub Pages, any static host, a REST API, a local package directory, or a `.zip` distribution — through one facade that never guesses which layer answered.
10
+
11
+ ## What it does
12
+
13
+ | Layer | Role |
14
+ |---|---|
15
+ | **Repository** | The uniform facade: one read API over any source (cloud, local, zip), explicit cache, online/offline modes |
16
+ | **Source** | Read-only views: `Directory`, `Zip`, `Https` (ETag/304), `Rest` (the lutaml cloud store API) |
17
+ | **Manifest** | The enumeration SSOT: keys, sha256 digests, opaque shard metadata |
18
+ | **Mirror** | Pull any source into a GCR-style package; pack to a distributable `.zip` |
19
+ | **DatabaseStore** | High-level CRUD with model registry, polymorphism, composites |
20
+ | **CacheStore** | TTL-aware cache with LRU eviction |
21
+ | **HttpCache** | HTTP-aware caching with ETags, conditional requests, Cache-Control |
22
+ | **PackageStore** | Structured multi-model packages with ZIP and directory transports |
23
+
24
+ ## Installation
25
+
26
+ ```ruby
27
+ gem "lutaml-store"
28
+ ```
29
+
30
+ For SQLite backend support, also add:
31
+
32
+ ```ruby
33
+ gem "sqlite3"
34
+ ```
35
+
36
+ ## Quick start: read a LutaML data repository
37
+
38
+ The same read API whether the data lives in the cloud, a local package, or a downloaded `.zip`:
39
+
40
+ ```ruby
41
+ require "lutaml/store"
42
+
43
+ # From a cloud API (api.relaton.org is the reference implementation)
44
+ repo = Lutaml::Store::Repository.new(
45
+ source: Lutaml::Store::Source.for(:rest,
46
+ base_url: "https://api.relaton.org", collection: "ietf"),
47
+ cache: Lutaml::Store::Source.for(:directory, path: "~/.cache/relaton/ietf"),
48
+ )
49
+
50
+ # From a local GCR-style package — identical read API
51
+ repo = Lutaml::Store::Repository.new(
52
+ source: Lutaml::Store::Source.for(:directory, path: "~/gcr/relaton/ietf"),
53
+ )
54
+
55
+ # From a downloaded .zip distribution — same API
56
+ repo = Lutaml::Store::Repository.new(
57
+ source: Lutaml::Store::Source.for(:zip, path: "ietf.zip"),
58
+ )
59
+ ```
60
+
61
+ ## Read, search, pull
62
+
63
+ ```ruby
64
+ repo.read("RFC 7231") # bytes
65
+ repo.exist?("RFC 7231") # true
66
+ repo.keys # all keys
67
+ repo.manifest # Lutaml::Store::Manifest
68
+
69
+ repo.get("RFC 7231", MyModel) # typed model — declared, never inferred
70
+ repo.search(docid: "RFC 7231") # metadata filter
71
+
72
+ repo.pull!(into: "~/gcr/relaton", collection: "ietf") # GCR-style package
73
+ ```
74
+
75
+ ## Offline mode
76
+
77
+ ```ruby
78
+ offline = Lutaml::Store::Repository.new(
79
+ source: repo.source, cache: repo.cache, mode: :offline
80
+ )
81
+ offline.read("RFC 7231") # served from the local package, no network
82
+ ```
@@ -0,0 +1,109 @@
1
+ ---
2
+ layout: ../layouts/Docs.astro
3
+ ---
4
+
5
+ # Quick Start
6
+
7
+ ## Installation
8
+
9
+ Add this line to your application's Gemfile:
10
+
11
+ ```ruby
12
+ gem 'lutaml-store'
13
+ ```
14
+
15
+ And then execute:
16
+
17
+ ```sh
18
+ $ bundle install
19
+ ```
20
+
21
+ Or install it yourself as:
22
+
23
+ ```sh
24
+ $ gem install lutaml-store
25
+ ```
26
+
27
+ For SQLite backend support, also add:
28
+
29
+ ```ruby
30
+ gem 'sqlite3'
31
+ ```
32
+
33
+ ## Define your models
34
+
35
+ ```ruby
36
+ require 'lutaml/model'
37
+ require 'lutaml/store'
38
+
39
+ class Studio < Lutaml::Model::Serializable
40
+ attribute :studio_key, :string
41
+ attribute :name, :string
42
+ attribute :location, :string
43
+ end
44
+
45
+ class PotteryClass < Lutaml::Model::Serializable
46
+ attribute :studio, Studio
47
+ attribute :class_id, :string
48
+ attribute :description, :string
49
+ end
50
+
51
+ class Enrollment < Lutaml::Model::Serializable
52
+ attribute :pottery_class, PotteryClass
53
+ attribute :student_name, :string
54
+ end
55
+ ```
56
+
57
+ ## DatabaseStore: CRUD with model registry
58
+
59
+ ```ruby
60
+ store = Lutaml::Store.new(
61
+ adapter: { type: :filesystem, location: "./data" },
62
+ models: [
63
+ { model: Studio, key: :studio_key },
64
+ { model: PotteryClass, key: :class_id },
65
+ { model: Enrollment, key: :student_name }
66
+ ]
67
+ )
68
+
69
+ # Save a model
70
+ store.save(Studio.new(studio_key: "st-001", name: "Riverside Pottery", location: "123 River St"))
71
+
72
+ # Fetch by key
73
+ studio = store.fetch(model: Studio, studio_key: "st-001")
74
+
75
+ # Update with dot-notation
76
+ store.update(model: Studio, studio_key: "st-001") do |s|
77
+ s.name = "Riverside Pottery Studio"
78
+ end
79
+
80
+ # Delete
81
+ store.destroy(model: Studio, studio_key: "st-001")
82
+ ```
83
+
84
+ ## CacheStore: TTL-aware caching
85
+
86
+ ```ruby
87
+ cache = Lutaml::Store::CacheStore.new(
88
+ adapter: { type: :memory },
89
+ default_ttl: 3600, # 1 hour
90
+ max_size: 1000 # LRU eviction
91
+ )
92
+
93
+ cache.set("key", "value", ttl: 1800)
94
+ cache.get("key") # "value" (within TTL)
95
+ cache.exists?("key") # true
96
+ cache.fetch("key") { expensive_computation } # block on miss
97
+ ```
98
+
99
+ ## PackageStore: multi-model packages
100
+
101
+ ```ruby
102
+ package = Lutaml::Store::PackageStore.new(definition)
103
+ package.add_model(Studio.new(studio_key: "st-001", name: "Riverside"))
104
+ package.save("./my-package", transport: :directory)
105
+
106
+ # Load it back
107
+ loaded = Lutaml::Store::PackageStore.load(definition, "./my-package")
108
+ loaded.fetch_model(Studio, "st-001")
109
+ ```
@@ -0,0 +1,83 @@
1
+ ---
2
+ layout: ../layouts/Docs.astro
3
+ ---
4
+
5
+ # Sources
6
+
7
+ A **Source** is a read-only view over a LutaML data repository. It answers four questions:
8
+
9
+ - Does a key exist? (`exist?`)
10
+ - What are its bytes? (`read`)
11
+ - Which keys exist? (`keys`, `each_key`)
12
+ - What does the repository's manifest declare? (`manifest`)
13
+
14
+ Sources **never write**. Selection is explicit — `Source.for(type, options)` raises `ConfigurationError` for a missing option; there is no discovery, no fallback, and no magic.
15
+
16
+ ## Available sources
17
+
18
+ | Type | What | Key options |
19
+ |---|---|---|
20
+ | `:directory` | A local package directory (`manifest.json` + `entries/`) | `path:` |
21
+ | `:zip` | A `.zip` package read in place | `path:` |
22
+ | `:https` | Any static HTTP host (Pages, raw, buckets) | `base_url:` |
23
+ | `:rest` | The lutaml cloud store API | `base_url:`, `collection:` |
24
+
25
+ ## Error contract
26
+
27
+ A definitive miss raises `NotFoundError`; transport trouble raises `BackendError`. Consumers can distinguish "absent" from "network broke":
28
+
29
+ ```ruby
30
+ begin
31
+ source.read("RFC 9999")
32
+ rescue Lutaml::Store::NotFoundError
33
+ # definitively not in the repository
34
+ rescue Lutaml::Store::BackendError
35
+ # network/service failure — retryable
36
+ end
37
+ ```
38
+
39
+ ## Usage
40
+
41
+ ```ruby
42
+ # Directory source
43
+ src = Lutaml::Store::Source.for(:directory, path: "~/gcr/relaton/ietf")
44
+
45
+ # Zip source
46
+ src = Lutaml::Store::Source.for(:zip, path: "ietf-distribution.zip")
47
+
48
+ # HTTPS source (any static host)
49
+ src = Lutaml::Store::Source.for(:https,
50
+ base_url: "https://raw.githubusercontent.com/relaton/relaton-data-ietf/main/data/")
51
+
52
+ # REST source (the lutaml cloud store contract)
53
+ src = Lutaml::Store::Source.for(:rest,
54
+ base_url: "https://api.relaton.org", collection: "ietf")
55
+
56
+ # Common operations
57
+ src.keys # => ["RFC 7231", "RFC 3986", ...]
58
+ src.read("RFC 7231") # => bytes
59
+ src.exist?("RFC 7231") # => true
60
+ src.get("RFC 7231", MyModel) # => typed model (model class declared)
61
+ src.search(docid: "RFC 7231") # => matching manifest entries
62
+ ```
63
+
64
+ ## The Https transport
65
+
66
+ The `:https` source supports:
67
+ - **ETag/304 revalidation** via an optional `cache:` config (`HttpCacheConfig` hash)
68
+ - **Injected transport** (`transport:` callable) for tests and alternate HTTP stacks
69
+ - **Redirect budget** (`max_redirects:` default 3)
70
+ - **Auth headers** (`headers:`) for private repositories
71
+
72
+ ## The Rest contract
73
+
74
+ The `:rest` source implements the lutaml cloud store API contract:
75
+
76
+ ```
77
+ GET {base}/collections → collection list
78
+ GET {base}/collections/{c}/manifest → Manifest
79
+ GET {base}/collections/{c}/entries/{key} → record
80
+ GET {base}/collections/{c}/shards/{n} → shard keys (optional)
81
+ ```
82
+
83
+ 404 is a definitive "no such key/collection"; every other non-2xx is a `BackendError`.
@@ -0,0 +1,110 @@
1
+ ---
2
+ layout: ../layouts/Docs.astro
3
+ title: Stores
4
+ ---
5
+
6
+ # Stores
7
+
8
+ ## DatabaseStore — typed CRUD
9
+
10
+ ```ruby
11
+ store = Lutaml::Store.new(
12
+ adapter: { type: :filesystem, location: "./data" },
13
+ models: [
14
+ { model: Studio, key: :studio_key },
15
+ { model: PotteryClass, key: :class_id },
16
+ { model: Enrollment, key: :student_name }
17
+ ]
18
+ )
19
+
20
+ store.save(Studio.new(studio_key: "st-001", name: "Riverside Pottery"))
21
+ studio = store.fetch(model: Studio, studio_key: "st-001")
22
+
23
+ store.update(model: Studio, studio_key: "st-001") do |s|
24
+ s.name = "Riverside Pottery Studio"
25
+ end
26
+
27
+ store.destroy(model: Studio, studio_key: "st-001")
28
+ ```
29
+
30
+ ### Polymorphic models
31
+
32
+ Registering multiple models against the same key attribute enables polymorphic storage: fetching returns whichever registered model was stored under the key. Configure per-registration:
33
+
34
+ ```ruby
35
+ models: [
36
+ { model: Person, key: :email },
37
+ { model: Organization, key: :email, polymorphic: true }
38
+ ]
39
+ ```
40
+
41
+ ### Composite models
42
+
43
+ Nested registered models are stored independently; `CompositeModelHandler` restores object references on fetch. A `PotteryClass` that embeds a `Studio` keeps the studio in its own key space — updating the studio once updates every referencing object's view of it.
44
+
45
+ ## BasicStore — key-value layer
46
+
47
+ ```ruby
48
+ basic = Lutaml::Store::BasicStore.new(
49
+ adapter: { type: :memory },
50
+ cache: { type: :memory, ttl: 60 },
51
+ monitor: my_monitor, # optional
52
+ events: [my_subscriber] # optional
53
+ )
54
+
55
+ basic.set("key", "value")
56
+ basic.get("key") # => "value"
57
+ basic.exists?("key") # => true
58
+ basic.keys # => ["key"]
59
+ basic.all # => { "key" => "value" }
60
+ basic.delete("key")
61
+ ```
62
+
63
+ ## CacheStore — TTL and LRU
64
+
65
+ ```ruby
66
+ cache = Lutaml::Store::CacheStore.new(
67
+ adapter: { type: :memory },
68
+ default_ttl: 3600,
69
+ max_size: 1000
70
+ )
71
+
72
+ cache.set("key", "value", ttl: 1800)
73
+ cache.get("key") # "value" within TTL, nil after
74
+ cache.fetch("key") { expensive_call } # block runs only on miss
75
+ ```
76
+
77
+ Entries past their TTL are evicted lazily on access; when `max_size` is exceeded the least-recently-used entries are evicted first.
78
+
79
+ ## PackageStore — multi-model packages
80
+
81
+ ```ruby
82
+ definition = Lutaml::Store::PackageDefinition.new do |d|
83
+ d.model Studio
84
+ d.model PotteryClass
85
+ d.metadata version: "1.0"
86
+ end
87
+
88
+ package = Lutaml::Store::PackageStore.new(definition)
89
+ package.add_model(Studio.new(studio_key: "st-001", name: "Riverside"))
90
+ package.save("./my-package", transport: :directory)
91
+ package.save("./my-package.zip", transport: :zip)
92
+
93
+ loaded = Lutaml::Store::PackageStore.load(definition, "./my-package")
94
+ loaded.fetch_model(Studio, "st-001")
95
+ ```
96
+
97
+ `PackageDefinition` declares which models, assets, and metadata a package contains; `DirectoryTransport` and `ZipTransport` handle reading and writing. Formats are chosen per model entry (see [Formats](/lutaml-store/formats/)).
98
+
99
+ ## FormatSerializer — store in any format
100
+
101
+ `FormatSerializer` wraps a Format handler to implement the serialize/deserialize interface, letting `DatabaseStore` persist entries as YAMLS, XML, Marshal, etc. instead of the default hash serialization:
102
+
103
+ ```ruby
104
+ store = Lutaml::Store.new(
105
+ adapter: { type: :filesystem, location: "./data" },
106
+ models: [{ model: GlossaryTerm, key: :id, format: :yamls }]
107
+ )
108
+ ```
109
+
110
+ This is the pattern used for Glossarist-style multi-document YAML files.
@@ -0,0 +1,10 @@
1
+ @import "tailwindcss";
2
+ @plugin "@tailwindcss/typography";
3
+
4
+ .light-only { display: block; }
5
+ .dark-only { display: none; }
6
+
7
+ @media (prefers-color-scheme: dark) {
8
+ .light-only { display: none; }
9
+ .dark-only { display: block; }
10
+ }
@@ -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