A YAML tag-based resolution engine. Register resolvers for custom tags (!Oculus, !Task, !Fixture), load YAML with thunks, resolve lazily with a memoizing cache. Lives in wanderland-core, available to all engines.
Descended from the Peregrine Configuration Language's reactive graph system — same two-phase model (construct thunks, then resolve), same tag-dispatched architecture, simplified for the wanderland context. The YAML is a function, not a value.
One method per resolver:
class OculusResolver < Wanderland::Resolver
tag "Oculus"
ttl 60 # seconds, default cache duration
def fetch(arg)
# arg is String, Hash, or Array depending on YAML syntax
case arg
when String then fetch_path(arg)
when Hash then fetch_path(arg["path"], params: arg)
end
end
end
That's it. tag registers the name. ttl sets the default cache duration. fetch does the work.
Three forms depending on complexity:
# Scalar — just a path (90% case)
node: !Oculus wanderland-core
tags: !Oculus tags
current: !Task current
# Scalar with subpath — dig into the response
content: !Oculus wanderland-core/content
first_tag: !Oculus tags/tags/0
# Mapping — path + params + cache control
nodes: !Oculus
path: nodes
sort: slug
limit: 5
ttl: 300
# Mapping — skip cache
live: !Task
path: current
nocache: true
# Fixtures for testing
mock: !Fixture mocks/oculus-nodes
YAML.safe_load with custom tag handlers. Each !Tag creates a thunk — a Proc that captures the resolver and argument but doesn't execute yet.
# Psych tag handler creates thunks
Psych.add_domain_type("", "Oculus") do |_type, value|
resolver = Wanderland::ResolverRegistry.lookup("Oculus")
Wanderland::Thunk.new(resolver: resolver, arg: value)
end
After loading, the YAML tree contains a mix of plain values and Thunk objects. No fetches have happened.
Walk the tree. When you hit a Thunk, resolve it — call the resolver's fetch, cache the result, replace the Thunk with the value.
def resolve(obj)
case obj
when Thunk then resolve_thunk(obj)
when Hash then obj.transform_values { |v| resolve(v) }
when Array then obj.map { |v| resolve(v) }
else obj
end
end
def resolve_thunk(thunk)
key = thunk.cache_key
cached = @cache(key)
return cached if cached
value = thunk.resolve
resolved = resolve(value) # recursive — resolved value may contain more thunks
@cache(key, resolved, ttl: thunk.effective_ttl)
resolved
end
Resolution is recursive — a resolved value can itself contain thunks (cross-references, nested fetches). The cache prevents duplicate fetches within the same resolution pass.
Memoizing cache keyed on "tag:canonical_arg". Each entry has a TTL.
class ResolverCache
def initialize
@store = {}
end
def get(key)
entry = @store
return nil unless entry
return nil if entry[:expires_at] && Time.now > entry[:expires_at]
entry[:value]
end
def set(key, value, ttl:)
expires = ttl && ttl > 0 ? Time.now + ttl : nil
@store = { value: value, expires_at: expires }
end
def invalidate(key)
@store(key)
end
def clear
@store = {}
end
def stats
{ size: @store keys: @store }
end
end
ttl: nil or ttl: 0 means ephemeral — fetched every time, never cached. ttl: -1 or omitted on Fixture/Config means forever (no expiry).
module Wanderland
module ResolverRegistry
@resolvers = {}
def self.register(tag_name, resolver)
@resolvers = resolver
end
def self.lookup(tag_name)
@resolvers
end
def self.registered
@resolvers
end
def self.reset!
@resolvers = {}
end
end
end
Resolvers register at boot. Core resolvers (Config, Env) ship with wanderland-core. Site resolvers (Oculus, Task) register from the site's provider config. Test resolvers (Fixture) register in the test harness.
class Thunk
attr_reader :resolver, :arg
def initialize(resolver:, arg:)
@resolver = resolver
@arg = normalize_arg(arg)
end
def resolve
resolver.fetch(@arg)
end
def cache_key
"#{resolver.tag_name}:#{canonical_arg}"
end
def effective_ttl
if @arg?(Hash)
return nil if @arg["nocache"]
@arg["ttl"] || resolver.default_ttl
else
resolver.default_ttl
end
end
private
def normalize_arg(arg)
case arg
when Hash then arg.transform_keys(&:to_s)
else arg
end
end
def canonical_arg
case @arg
when String then @arg
when Hash then @arg { |k, _| %w[ttl nocache].include?(k) }.sort.to_h.to_s
when Array then @arg
end
end
end
| Tag | Resolver | Default TTL | Source |
|---|---|---|---|
!Config |
ConfigResolver | forever | Site YAML domain config |
!Env |
EnvResolver | forever | Environment variables |
!Fixture |
FixtureResolver | forever | YAML fixture files |
!Oculus |
OculusResolver | 60s | Oculus API via DataProvider |
!Task |
TaskResolver | 0 (ephemeral) | Task API via DataProvider |
!Mapped |
MappedResolver | n/a (internal ref) | Resolved tree cross-reference |
!Mapped is special — it doesn't fetch externally. It references a path in the already-resolved tree. It's the internal cross-reference mechanism, equivalent to Peregrine's !Mapped.
Resolvers wire to providers via the site YAML:
providers:
oculus:
backend: https://i.loss.dev
prefix: /api/oculus
auth: { user: x, pass: y }
prefixes:
node: /api/oculus/node
nodes: /api/oculus/nodes
tags: /api/oculus/tags
tasks:
backend: https://i.loss.dev
prefix: /api/tasks
auth: { user: x, pass: y }
prefixes:
list: /api/tasks
current: /api/tasks/current
At boot, each provider config creates an HttpProvider and registers a resolver for its tag. The resolver's fetch delegates to the provider's HTTP client.
The $namespace.path and {attribute} syntaxes remain as lightweight alternatives for common cases:
| Syntax | When to use | Mechanism |
|---|---|---|
!Tag arg |
External data fetch | Resolver + cache |
$namespace.path |
Internal tree reference | Variable resolve (walk resolved tree) |
{attribute} |
Entity binding in iteration | Attribute resolve (walk current record) |
!Tag brings data in. $ and {} reference data that's already in the tree. Three tiers, each for its scope.