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.
- checksums.yaml +4 -4
- data/README.adoc +56 -0
- 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/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 +30 -6
|
@@ -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.
|
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
|