Wanderland

Wanderland Core Template Engine

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.

The Interface

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.

YAML Syntax

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

Two-Phase Resolution

Phase 1: Load

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.

Phase 2: Resolve

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.

Cache

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).

Resolver Registry

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.

Thunk

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

Default Resolvers

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.

Site Configuration

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.

Integration with Existing Syntax

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.